diff --git a/oci/catalog/overlays/bookstack.json b/oci/catalog/overlays/bookstack.json index f4c6870c..afc17874 100644 --- a/oci/catalog/overlays/bookstack.json +++ b/oci/catalog/overlays/bookstack.json @@ -109,7 +109,27 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } + ], + "external_services": [ + "MariaDB" ] } + }, + "environment_overrides": { + "APP_URL": { + "prompt": "Address BookStack will be opened at (for example http://192.168.1.50:6875)" + }, + "DB_HOST": { + "prompt": "Address of the MariaDB server" + }, + "DB_USERNAME": { + "prompt": "User of the MariaDB server" + }, + "DB_DATABASE": { + "prompt": "Name of the database on the MariaDB server" + }, + "DB_PASSWORD": { + "prompt": "Password of the MariaDB user" + } } } diff --git a/oci/catalog/overlays/chatgpt-next-web.json b/oci/catalog/overlays/chatgpt-next-web.json new file mode 100644 index 00000000..6140668f --- /dev/null +++ b/oci/catalog/overlays/chatgpt-next-web.json @@ -0,0 +1,12 @@ +{ + "environment_overrides": { + "CODE": { + "required": false, + "prompt": "Access password of the web interface (optional)" + }, + "PROXY_URL": { + "required": false, + "prompt": "Proxy address to reach the API (optional)" + } + } +} diff --git a/oci/catalog/overlays/ddns-updater.json b/oci/catalog/overlays/ddns-updater.json new file mode 100644 index 00000000..986e0836 --- /dev/null +++ b/oci/catalog/overlays/ddns-updater.json @@ -0,0 +1,12 @@ +{ + "environment_overrides": { + "CONFIG": { + "required": false, + "prompt": "Configuration in JSON on one line (optional)" + }, + "SHOUTRRR_ADDRESSES": { + "required": false, + "prompt": "Shoutrrr addresses for the notifications (optional)" + } + } +} diff --git a/oci/catalog/overlays/foldingathome.json b/oci/catalog/overlays/foldingathome.json index b1cbf5eb..09ee1618 100644 --- a/oci/catalog/overlays/foldingathome.json +++ b/oci/catalog/overlays/foldingathome.json @@ -10,5 +10,13 @@ } ] } + }, + "environment_overrides": { + "ACCOUNT_TOKEN": { + "prompt": "Account token from the settings of https://app.foldingathome.org" + }, + "MACHINE_NAME": { + "prompt": "Name for this machine in Folding@home (no spaces)" + } } } diff --git a/oci/catalog/overlays/habridge.json b/oci/catalog/overlays/habridge.json index b1cbf5eb..890fbfa3 100644 --- a/oci/catalog/overlays/habridge.json +++ b/oci/catalog/overlays/habridge.json @@ -8,7 +8,19 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } - ] + ], + "generated_sensitive_environment": { + "SEC_KEY": { + "strategy": "token-hex", + "bytes": 16, + "prompt": false + } + } + } + }, + "environment_overrides": { + "SEC_KEY": { + "sensitive": true } } } diff --git a/oci/catalog/overlays/healthchecks.json b/oci/catalog/overlays/healthchecks.json index b1cbf5eb..695a0023 100644 --- a/oci/catalog/overlays/healthchecks.json +++ b/oci/catalog/overlays/healthchecks.json @@ -10,5 +10,22 @@ } ] } + }, + "environment_overrides": { + "SECRET_KEY": { + "required": false + }, + "SITE_ROOT": { + "prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)" + }, + "SITE_NAME": { + "example": "Healthchecks" + }, + "SUPERUSER_EMAIL": { + "prompt": "Email address of the superuser account" + }, + "SUPERUSER_PASSWORD": { + "prompt": "Password of the superuser account" + } } } diff --git a/oci/catalog/overlays/lychee.json b/oci/catalog/overlays/lychee.json index ae775b06..9e020fa2 100644 --- a/oci/catalog/overlays/lychee.json +++ b/oci/catalog/overlays/lychee.json @@ -16,5 +16,30 @@ } ] } + }, + "environment_overrides": { + "DB_CONNECTION": { + "example": "sqlite", + "prompt": "Database type (sqlite, mysql or pgsql)" + }, + "DB_DATABASE": { + "example": "/config/lychee.sqlite", + "prompt": "Database file for sqlite, or database name for mysql and pgsql" + }, + "DB_HOST": { + "required": false, + "prompt": "Address of the database server (mysql and pgsql only)" + }, + "DB_PORT": { + "required": false, + "prompt": "Port of the database server (mysql and pgsql only)" + }, + "DB_USERNAME": { + "required": false, + "prompt": "User of the database server (mysql and pgsql only)" + }, + "DB_PASSWORD": { + "required": false + } } } diff --git a/oci/catalog/overlays/manyfold.json b/oci/catalog/overlays/manyfold.json index ab6d89e8..a9f3ed8b 100644 --- a/oci/catalog/overlays/manyfold.json +++ b/oci/catalog/overlays/manyfold.json @@ -14,7 +14,22 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } + ], + "external_services": [ + "Redis/Valkey" ] } + }, + "environment_overrides": { + "DATABASE_URL": { + "example": "sqlite3:/config/manyfold.sqlite3", + "prompt": "Database (a SQLite file, or postgresql://user:password@address:5432/name)" + }, + "REDIS_URL": { + "prompt": "Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)" + }, + "SECRET_KEY_BASE": { + "required": false + } } } diff --git a/oci/catalog/overlays/minisatip.json b/oci/catalog/overlays/minisatip.json index b1cbf5eb..20a360d2 100644 --- a/oci/catalog/overlays/minisatip.json +++ b/oci/catalog/overlays/minisatip.json @@ -10,5 +10,11 @@ } ] } + }, + "environment_overrides": { + "RUN_OPTS": { + "required": false, + "prompt": "Extra parameters for minisatip (optional)" + } } } diff --git a/oci/catalog/overlays/netbird.json b/oci/catalog/overlays/netbird.json index 00a4ca7c..fbf2666b 100644 --- a/oci/catalog/overlays/netbird.json +++ b/oci/catalog/overlays/netbird.json @@ -10,5 +10,14 @@ } ] } + }, + "environment_overrides": { + "NB_SETUP_KEY": { + "prompt": "Setup key from the NetBird dashboard" + }, + "NB_MANAGEMENT_URL": { + "example": "https://api.netbird.io:443", + "prompt": "Address of the NetBird management server" + } } } diff --git a/oci/catalog/overlays/netbox.json b/oci/catalog/overlays/netbox.json index 5a6986a1..82defe18 100644 --- a/oci/catalog/overlays/netbox.json +++ b/oci/catalog/overlays/netbox.json @@ -203,7 +203,55 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } + ], + "external_services": [ + "PostgreSQL", + "Redis/Valkey" ] } + }, + "environment_overrides": { + "SUPERUSER_EMAIL": { + "prompt": "Email address of the admin account" + }, + "ALLOWED_HOST": { + "prompt": "Name or address NetBox will be opened at (for example netbox.example.com)" + }, + "DB_HOST": { + "prompt": "Address of the PostgreSQL server" + }, + "DB_PORT": { + "example": "5432" + }, + "DB_NAME": { + "example": "netbox" + }, + "DB_USER": { + "prompt": "User of the PostgreSQL server" + }, + "REDIS_HOST": { + "prompt": "Address of the Redis or Valkey server" + }, + "REDIS_PORT": { + "example": "6379" + }, + "REDIS_USERNAME": { + "required": false + }, + "REDIS_PASSWORD": { + "required": false + }, + "REDIS_DB_TASK": { + "example": "0" + }, + "REDIS_DB_CACHE": { + "example": "1" + }, + "DB_PASSWORD": { + "prompt": "Password of the PostgreSQL user" + }, + "SUPERUSER_PASSWORD": { + "prompt": "Password of the admin account" + } } } diff --git a/oci/catalog/overlays/pinchflat.json b/oci/catalog/overlays/pinchflat.json index b1103515..40bf74d4 100644 --- a/oci/catalog/overlays/pinchflat.json +++ b/oci/catalog/overlays/pinchflat.json @@ -14,7 +14,10 @@ "owner_strategy": "mapped-root", "only_when_mount_type": "managed-volume" } - ] + ], + "resources": { + "memory_default_mb": 1024 + } } } } diff --git a/oci/catalog/overlays/speedtest-tracker.json b/oci/catalog/overlays/speedtest-tracker.json index 959dbd87..09cc2d79 100644 --- a/oci/catalog/overlays/speedtest-tracker.json +++ b/oci/catalog/overlays/speedtest-tracker.json @@ -139,5 +139,18 @@ } ] } + }, + "environment_overrides": { + "APP_URL": { + "prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)" + }, + "SPEEDTEST_SCHEDULE": { + "example": "0 */6 * * *", + "prompt": "Schedule of the tests in cron format" + }, + "SPEEDTEST_SERVERS": { + "required": false, + "prompt": "Server IDs to test against, separated by commas (optional)" + } } } diff --git a/oci/catalog/overlays/twingate-connector.json b/oci/catalog/overlays/twingate-connector.json new file mode 100644 index 00000000..250ecab5 --- /dev/null +++ b/oci/catalog/overlays/twingate-connector.json @@ -0,0 +1,16 @@ +{ + "environment_overrides": { + "TWINGATE_NETWORK": { + "prompt": "Name of the Twingate network (the part before .twingate.com)" + }, + "TWINGATE_ACCESS_TOKEN": { + "prompt": "Access token of the connector, from the Twingate admin console" + }, + "TWINGATE_REFRESH_TOKEN": { + "prompt": "Refresh token of the connector, from the Twingate admin console" + }, + "TWINGATE_LABEL_HOSTNAME": { + "required": false + } + } +} diff --git a/oci/tests/test_content_volume_owner.py b/oci/tests/test_content_volume_owner.py new file mode 100644 index 00000000..f98c7c17 --- /dev/null +++ b/oci/tests/test_content_volume_owner.py @@ -0,0 +1,81 @@ +"""A path that holds content of the user is offered as a container volume or +as a host directory. Either way it belongs to the user the application writes +with: a volume left to root is one the application cannot write to.""" + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci.catalog import Catalog + + +def owners(template): + profile = template["proxmox"]["installer_profile"] + return {item["container_path"]: item["owner_strategy"] for item in profile["volume_preparations"]} + + +class ContentVolumeOwnerTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.catalog = Catalog(ROOT) + + def test_the_content_of_the_user_belongs_to_the_application(self): + found = owners(self.catalog.compose("duplicati")) + self.assertEqual(found["/backups"], "mapped-application-user") + self.assertEqual(found["/source"], "mapped-application-user") + + def test_the_state_of_the_application_stays_as_the_recipe_says(self): + self.assertEqual(owners(self.catalog.compose("duplicati"))["/config"], "mapped-root") + + def test_it_follows_the_choice_the_installation_offers(self): + contract = {"volumes": [ + {"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind", "skip"]}, + {"container_path": "/config", "installation_choice": ["managed-volume", "host-bind"], + "managed_volume": {"default_size_gb": 4}}]} + profile = {"volume_preparations": [ + {"container_path": "/downloads", "owner_strategy": "mapped-root"}, + {"container_path": "/config", "owner_strategy": "mapped-root"}]} + self.catalog._apply_volume_policy("an-application", contract, profile) + self.assertEqual({item["container_path"]: item["owner_strategy"] for item in profile["volume_preparations"]}, + {"/downloads": "mapped-application-user", "/config": "mapped-root"}) + self.assertEqual(contract["volumes"][1]["installation_choice"], ["managed-volume"]) + + def test_the_content_of_the_user_is_always_mounted(self): + contract = {"volumes": [ + {"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind", "skip"], + "default": "skip"}, + {"container_path": "/var/run/docker.sock", "installation_choice": ["host-bind", "skip"], "default": "skip"}]} + self.catalog._apply_volume_policy("an-application", contract, {}) + self.assertEqual(contract["volumes"][0]["installation_choice"], ["managed-volume", "host-bind"]) + self.assertEqual(contract["volumes"][0]["default"], "managed-volume") + # What is not content of the user, as a socket of the host, can still be left out. + self.assertEqual(contract["volumes"][1]["installation_choice"], ["host-bind", "skip"]) + for app, path in (("sonarr", "/downloads"), ("manyfold", "/libraries"), ("qbittorrent", "/downloads")): + volume = next(item for item in self.catalog.compose(app)["container_contract"]["volumes"] + if item["container_path"] == path) + self.assertNotIn("skip", volume["installation_choice"], app) + self.assertEqual(volume["default"], "managed-volume", app) + + def test_a_container_volume_is_never_read_only(self): + source = (ROOT / "src" / "proxmenux_oci" / "installer.py").read_text() + self.assertIn('"read_only": item["read_only"] and volume_mode != "managed-volume"', source) + + def test_the_time_files_of_the_host_are_not_mounted(self): + for app in ("openhab", "lyrionmusicserver"): + template = self.catalog.compose(app) + paths = [volume["container_path"] for volume in template["container_contract"]["volumes"]] + self.assertNotIn("/etc/localtime", paths, app) + self.assertNotIn("/etc/timezone", paths, app) + self.assertNotIn("/etc/localtime", owners(template), app) + + def test_a_recipe_without_preparations_is_left_alone(self): + contract = {"volumes": [{"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind"]}]} + self.catalog._apply_volume_policy("an-application", contract, None) + self.catalog._apply_volume_policy("an-application", contract, {}) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_external_services.py b/oci/tests/test_external_services.py new file mode 100644 index 00000000..195e2857 --- /dev/null +++ b/oci/tests/test_external_services.py @@ -0,0 +1,74 @@ +"""Some images are only the application and expect a database that is already +running. The installation says so before its first question, with the address +of the image documentation, and asks for the connection in plain words.""" + +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci import i18n +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.installer import external_services_notice + + +def asked_without_a_proposal(template): + return [item["name"] for item in template["container_contract"]["environment"] + if item["required"] and item["name"] not in ("PUID", "PGID", "TZ") + and not item["sensitive"] and item.get("example") in (None, "")] + + +class ExternalServicesTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.language, i18n._language = getattr(i18n, "_language", None), "en" + cls.catalog = Catalog(ROOT) + + @classmethod + def tearDownClass(cls): + i18n._language = cls.language + + def test_the_notice_names_the_services_and_the_image_documentation(self): + notice = external_services_notice(self.catalog.compose("manyfold")) + self.assertIn("Manyfold needs these external services, already running: Redis/Valkey", notice) + self.assertIn("ProxMenux does not install them", notice) + self.assertIn("https://docs.linuxserver.io/images/docker-manyfold/", notice) + + def test_an_application_that_needs_nothing_else_has_no_notice(self): + self.assertIsNone(external_services_notice(self.catalog.compose("duplicati"))) + self.assertIsNone(external_services_notice(self.catalog.compose("lychee"))) + + def test_every_connection_question_says_what_it_asks_for(self): + for app in ("manyfold", "bookstack", "netbox"): + template = self.catalog.compose(app) + self.assertTrue(external_services_notice(template), app) + for item in template["container_contract"]["environment"]: + if item["name"] in asked_without_a_proposal(template): + self.assertTrue(item.get("prompt"), f"{app}: {item['name']}") + + def test_no_default_installation_asks_for_a_value_without_saying_what_it_is(self): + from proxmenux_oci.installer import _example_default + unexplained = {} + for item in self.catalog.load_index()["applications"]: + if item.get("hidden") or not item.get("automatic_install_candidate"): + continue + template = self.catalog.compose(item["id"]) + for variable in template["container_contract"].get("environment", []): + if (variable["required"] and not variable["sensitive"] and not variable.get("prompt") + and variable.get("prompt_user", True) is not False + and variable["name"] not in ("PUID", "PGID", "TZ") + and _example_default(variable["name"], variable["example"], "Etc/UTC") in (None, "")): + unexplained.setdefault(item["id"], []).append(variable["name"]) + self.assertEqual(unexplained, {}) + + def test_an_image_with_a_database_of_its_own_installs_without_asking_for_one(self): + self.assertEqual(asked_without_a_proposal(self.catalog.compose("lychee")), []) + database = {item["name"]: item for item in self.catalog.compose("lychee")["container_contract"]["environment"]} + self.assertEqual(database["DB_CONNECTION"]["example"], "sqlite") + self.assertFalse(database["DB_HOST"]["required"]) + + +if __name__ == "__main__": + unittest.main()