diff --git a/lang/es.json b/lang/es.json index bd4f0509..2ddd41da 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2357,6 +2357,7 @@ "FileBrowser Quantum (new installation)": "FileBrowser Quantum (nueva instalación)", "FileDrop is a free, open source file sharing service": "FileDrop es un servicio gratuito y de código abierto para compartir archivos", "Files & Downloads": "Archivos y descargas", + "Files & Sharing": "Files & Sharing", "Files volume size in GB": "Tamaño del volumen de archivos en GB", "Filesystem": "Sistema de archivos", "Filesystem Tools Required": "Herramientas del sistema de archivos necesarias", diff --git a/oci/catalog/apps/sharecovex.json b/oci/catalog/apps/sharecovex.json new file mode 100644 index 00000000..ef10bb9b --- /dev/null +++ b/oci/catalog/apps/sharecovex.json @@ -0,0 +1,554 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-sharecovex", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "ShareCoveX" + }, + "tagline": { + "en_US": "SMB, NFS and Time Machine shares from a web panel" + }, + "description": { + "en_US": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares." + }, + "category": "sharing", + "category_label": "Files & Sharing", + "author": "MacRimi", + "developer": "MacRimi", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": 8080, + "path": "/" + }, + "website": "https://github.com/MacRimi/ShareCoveX", + "documentation": "https://github.com/MacRimi/ShareCoveX#readme", + "repository": "https://github.com/MacRimi/ShareCoveX", + "tips": [ + "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", + "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Create the Samba users in Settings of the panel, then publish each directory from Resources.", + "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", + "/config: Proxmox volume included in backup and kept when the application is updated or modified." + ], + "mini_changelog": [], + "display_version": null, + "updated_at": "2026-10-08", + "hidden": false + }, + "source": { + "provider": "MacRimi", + "repository": "https://github.com/MacRimi/ShareCoveX", + "revision": "d8499627f459559574118781ba78ff29b50b5083", + "image_repository_url": "https://github.com/MacRimi/ShareCoveX/pkgs/container/sharecovex", + "readme_pushed_at": "", + "compose_sha256": "6629a9e2aa650c430aeecc66308029304d4ffde3be8a76a9b7dc665470c67f1e", + "generated_at": "2026-10-08T19:09:08+00:00", + "default_branch": "main" + }, + "container_contract": { + "service_name": "sharecovex", + "container_name": "sharecovex", + "image": { + "reference": "ghcr.io/macrimi/sharecovex:latest", + "registry": "ghcr.io", + "repository": "ghcr.io/macrimi/sharecovex", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "SHARECOVEX_ADMIN_PASSWORD", + "example": "", + "prompt": "Password of the ShareCoveX panel administrator (8 characters or more)", + "prompt_user": true, + "required": true, + "sensitive": true, + "source": "upstream-documentation" + } + ], + "volumes": [ + { + "id": "config", + "container_path": "/config", + "compose_source_example": "sharecovex-config", + "read_only": false, + "required": true, + "installation_choice": [ + "managed-volume" + ], + "default": "managed-volume", + "managed_volume": { + "backup": true, + "default_size_gb": 16 + } + } + ], + "ports": [ + { + "container_port": 8080, + "published_example": 8080, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 445, + "published_example": 445, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 2049, + "published_example": 2049, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 20048, + "published_example": 20048, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": "30s", + "original_compose": "name: sharecovex\nservices:\n sharecovex:\n image: ghcr.io/macrimi/sharecovex:latest\n container_name: sharecovex\n restart: unless-stopped\n stop_grace_period: 30s\n environment:\n SHARECOVEX_ADMIN_PASSWORD: ${GENERATED_SHARECOVEX_ADMIN_PASSWORD}\n ports:\n - 8080:8080/tcp\n - 445:445/tcp\n - 2049:2049/tcp\n - 20048:20048/tcp\n volumes:\n - sharecovex-config:/config\n healthcheck:\n test:\n - CMD\n - python3\n - -c\n - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',\n timeout=4)\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 20s\n" + }, + "compose_stack": { + "project_name": "sharecovex", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "sharecovex", + "service_count": 1, + "services": [ + { + "name": "sharecovex", + "image": "ghcr.io/macrimi/sharecovex:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "ghcr.io/macrimi/sharecovex:latest", + "container_name": "sharecovex", + "restart": "unless-stopped", + "stop_grace_period": "30s", + "environment": { + "SHARECOVEX_ADMIN_PASSWORD": "${GENERATED_SHARECOVEX_ADMIN_PASSWORD}" + }, + "ports": [ + "8080:8080/tcp", + "445:445/tcp", + "2049:2049/tcp", + "20048:20048/tcp" + ], + "volumes": [ + "sharecovex-config:/config" + ], + "healthcheck": { + "test": [ + "CMD", + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4)" + ], + "interval": "30s", + "timeout": "5s", + "retries": 3, + "start_period": "20s" + } + } + } + ], + "top_level": { + "name": "sharecovex" + }, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [ + { + "id": "sharecovex-volume-0", + "service": "sharecovex", + "container_path": "/config", + "mode": "managed-volume", + "user_selectable": false, + "backup": true, + "shared_with_other_lxc": false, + "source_path": null, + "source_path_prompt": null + } + ], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "sharecovex" + ], + "stop_order": [ + "sharecovex" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "sharecovex-admin-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "sharecovex", + "environment_variable": "SHARECOVEX_ADMIN_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [ + { + "label": "ShareCoveX panel", + "scheme": "http", + "port": 8080, + "path": "/", + "source": "upstream-documentation" + } + ], + "credentials": [ + { + "label": "ShareCoveX panel", + "type": "configured-or-installer-generated", + "username": "admin", + "password": null, + "password_environment": "SHARECOVEX_ADMIN_PASSWORD", + "change_required": false, + "source": "https://github.com/MacRimi/ShareCoveX#readme" + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 2, + "memory_mb": 1024, + "swap_mb": 512, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "startup_healthcheck": { + "type": "http", + "scheme": "http", + "port": 8080, + "path": "/healthz", + "timeout_seconds": 110, + "request_timeout_seconds": 5, + "stability_seconds": 0, + "verify_tls": true, + "required": true, + "source": "compose-healthcheck" + }, + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/config", + "remove_lost_found": true, + "owner_strategy": "mapped-root", + "only_when_mount_type": "managed-volume" + } + ], + "generated_sensitive_environment": { + "SHARECOVEX_ADMIN_PASSWORD": { + "strategy": "token-hex", + "bytes": 8 + } + }, + "minimum_secret_length": { + "SHARECOVEX_ADMIN_PASSWORD": 8 + }, + "shared_directories": { + "container_root": "/shares", + "reserved_names": [ + "config" + ], + "default_size_gb": 32 + }, + "security": { + "optional_relaxations": [ + { + "id": "nfsv4", + "enable_prompt": "NFSv4 needs a privileged container without AppArmor or seccomp confinement: a compromised image could affect the host. NFSv3, SMB and Time Machine work without it.\n\nPrepare this container for NFSv4?", + "enabled_default": false, + "privileged": true, + "options": { + "apparmor_profile": "unconfined", + "seccomp_profile": "unconfined" + } + } + ] + }, + "completion_notes": [ + "Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.", + "Publish each directory from Resources: open it, switch on SMB, NFS or both, and save." + ] + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "pending-per-application" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ], + "catalog": { + "replaces_discovered_ids": [] + } + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/config-and-shared-directories", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/categories.json b/oci/catalog/categories.json index 3fe4430f..d9589a88 100644 --- a/oci/catalog/categories.json +++ b/oci/catalog/categories.json @@ -7,6 +7,7 @@ "network": "Network & Firewall", "documents": "Documents & Notes", "downloads": "Files & Downloads", + "sharing": "Files & Sharing", "arr": "*Arr Suite", "smarthome": "IoT & Smart Home", "monitoring": "Monitoring & Analytics", @@ -156,6 +157,7 @@ "scummvm": "gaming", "sealskin": "desktop", "shadps4": "gaming", + "sharecovex": "sharing", "shotcut": "creative", "sickgear": "arr", "signal": "communication", diff --git a/oci/catalog/curated/sharecovex.json b/oci/catalog/curated/sharecovex.json new file mode 100644 index 00000000..ef10bb9b --- /dev/null +++ b/oci/catalog/curated/sharecovex.json @@ -0,0 +1,554 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-sharecovex", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "ShareCoveX" + }, + "tagline": { + "en_US": "SMB, NFS and Time Machine shares from a web panel" + }, + "description": { + "en_US": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares." + }, + "category": "sharing", + "category_label": "Files & Sharing", + "author": "MacRimi", + "developer": "MacRimi", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": 8080, + "path": "/" + }, + "website": "https://github.com/MacRimi/ShareCoveX", + "documentation": "https://github.com/MacRimi/ShareCoveX#readme", + "repository": "https://github.com/MacRimi/ShareCoveX", + "tips": [ + "Each directory to share is mounted under /shares with the name given to it during the installation; more can be added later with Modify.", + "A directory of the host keeps the owner it already has. The panel shows whether the shared identity (1000:1000 by default) can write to it.", + "Create the Samba users in Settings of the panel, then publish each directory from Resources.", + "NFS is served as NFSv3. NFSv4 needs the privileged profile offered by the advanced installation.", + "/config: Proxmox volume included in backup and kept when the application is updated or modified." + ], + "mini_changelog": [], + "display_version": null, + "updated_at": "2026-10-08", + "hidden": false + }, + "source": { + "provider": "MacRimi", + "repository": "https://github.com/MacRimi/ShareCoveX", + "revision": "d8499627f459559574118781ba78ff29b50b5083", + "image_repository_url": "https://github.com/MacRimi/ShareCoveX/pkgs/container/sharecovex", + "readme_pushed_at": "", + "compose_sha256": "6629a9e2aa650c430aeecc66308029304d4ffde3be8a76a9b7dc665470c67f1e", + "generated_at": "2026-10-08T19:09:08+00:00", + "default_branch": "main" + }, + "container_contract": { + "service_name": "sharecovex", + "container_name": "sharecovex", + "image": { + "reference": "ghcr.io/macrimi/sharecovex:latest", + "registry": "ghcr.io", + "repository": "ghcr.io/macrimi/sharecovex", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "SHARECOVEX_ADMIN_PASSWORD", + "example": "", + "prompt": "Password of the ShareCoveX panel administrator (8 characters or more)", + "prompt_user": true, + "required": true, + "sensitive": true, + "source": "upstream-documentation" + } + ], + "volumes": [ + { + "id": "config", + "container_path": "/config", + "compose_source_example": "sharecovex-config", + "read_only": false, + "required": true, + "installation_choice": [ + "managed-volume" + ], + "default": "managed-volume", + "managed_volume": { + "backup": true, + "default_size_gb": 16 + } + } + ], + "ports": [ + { + "container_port": 8080, + "published_example": 8080, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 445, + "published_example": 445, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 2049, + "published_example": 2049, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + }, + { + "container_port": 20048, + "published_example": 20048, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": "30s", + "original_compose": "name: sharecovex\nservices:\n sharecovex:\n image: ghcr.io/macrimi/sharecovex:latest\n container_name: sharecovex\n restart: unless-stopped\n stop_grace_period: 30s\n environment:\n SHARECOVEX_ADMIN_PASSWORD: ${GENERATED_SHARECOVEX_ADMIN_PASSWORD}\n ports:\n - 8080:8080/tcp\n - 445:445/tcp\n - 2049:2049/tcp\n - 20048:20048/tcp\n volumes:\n - sharecovex-config:/config\n healthcheck:\n test:\n - CMD\n - python3\n - -c\n - import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',\n timeout=4)\n interval: 30s\n timeout: 5s\n retries: 3\n start_period: 20s\n" + }, + "compose_stack": { + "project_name": "sharecovex", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "sharecovex", + "service_count": 1, + "services": [ + { + "name": "sharecovex", + "image": "ghcr.io/macrimi/sharecovex:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "ghcr.io/macrimi/sharecovex:latest", + "container_name": "sharecovex", + "restart": "unless-stopped", + "stop_grace_period": "30s", + "environment": { + "SHARECOVEX_ADMIN_PASSWORD": "${GENERATED_SHARECOVEX_ADMIN_PASSWORD}" + }, + "ports": [ + "8080:8080/tcp", + "445:445/tcp", + "2049:2049/tcp", + "20048:20048/tcp" + ], + "volumes": [ + "sharecovex-config:/config" + ], + "healthcheck": { + "test": [ + "CMD", + "python3", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4)" + ], + "interval": "30s", + "timeout": "5s", + "retries": 3, + "start_period": "20s" + } + } + } + ], + "top_level": { + "name": "sharecovex" + }, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [ + { + "id": "sharecovex-volume-0", + "service": "sharecovex", + "container_path": "/config", + "mode": "managed-volume", + "user_selectable": false, + "backup": true, + "shared_with_other_lxc": false, + "source_path": null, + "source_path_prompt": null + } + ], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "sharecovex" + ], + "stop_order": [ + "sharecovex" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "sharecovex-admin-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "sharecovex", + "environment_variable": "SHARECOVEX_ADMIN_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [ + { + "label": "ShareCoveX panel", + "scheme": "http", + "port": 8080, + "path": "/", + "source": "upstream-documentation" + } + ], + "credentials": [ + { + "label": "ShareCoveX panel", + "type": "configured-or-installer-generated", + "username": "admin", + "password": null, + "password_environment": "SHARECOVEX_ADMIN_PASSWORD", + "change_required": false, + "source": "https://github.com/MacRimi/ShareCoveX#readme" + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 2, + "memory_mb": 1024, + "swap_mb": 512, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "startup_healthcheck": { + "type": "http", + "scheme": "http", + "port": 8080, + "path": "/healthz", + "timeout_seconds": 110, + "request_timeout_seconds": 5, + "stability_seconds": 0, + "verify_tls": true, + "required": true, + "source": "compose-healthcheck" + }, + "volume_owner": { + "uid": 1000, + "gid": 1000 + }, + "volume_preparations": [ + { + "container_path": "/config", + "remove_lost_found": true, + "owner_strategy": "mapped-root", + "only_when_mount_type": "managed-volume" + } + ], + "generated_sensitive_environment": { + "SHARECOVEX_ADMIN_PASSWORD": { + "strategy": "token-hex", + "bytes": 8 + } + }, + "minimum_secret_length": { + "SHARECOVEX_ADMIN_PASSWORD": 8 + }, + "shared_directories": { + "container_root": "/shares", + "reserved_names": [ + "config" + ], + "default_size_gb": 32 + }, + "security": { + "optional_relaxations": [ + { + "id": "nfsv4", + "enable_prompt": "NFSv4 needs a privileged container without AppArmor or seccomp confinement: a compromised image could affect the host. NFSv3, SMB and Time Machine work without it.\n\nPrepare this container for NFSv4?", + "enabled_default": false, + "privileged": true, + "options": { + "apparmor_profile": "unconfined", + "seccomp_profile": "unconfined" + } + } + ] + }, + "completion_notes": [ + "Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.", + "Publish each directory from Resources: open it, switch on SMB, NFS or both, and save." + ] + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "pending-per-application" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ], + "catalog": { + "replaces_discovered_ids": [] + } + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-and-replace-rootfs-preserving-managed-/config-and-shared-directories", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 6ef77cda..0705fe63 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -9765,6 +9765,38 @@ "hidden": false, "template_id": "linuxserver-shadps4" }, + { + "id": "sharecovex", + "provider": "MacRimi", + "template_family": "curated-profile", + "title": "ShareCoveX", + "repository": "https://github.com/MacRimi/ShareCoveX", + "description": "File-sharing appliance with a web panel: publish directories over SMB, NFS or both, with Samba users, client networks and Time Machine destinations. Each directory to share is a container volume or a directory of the host, mounted under /shares.", + "website": "https://github.com/MacRimi/ShareCoveX", + "icon": "https://raw.githubusercontent.com/MacRimi/ShareCoveX/refs/heads/main/assets/sharecovex-icon.svg", + "architectures": [ + "amd64", + "arm64" + ], + "updated_at": "2026-10-08", + "main_image": "ghcr.io/macrimi/sharecovex:latest", + "category": "sharing", + "category_label": "Files & Sharing", + "replaces_discovered_ids": [], + "curated_path": "catalog/curated/sharecovex.json", + "template": "apps/sharecovex.json", + "template_status": "generated-unvalidated", + "automatic_install_candidate": true, + "untranslated_blockers": [], + "requires_privileged_lxc": false, + "optional_privileged_lxc": false, + "requires_host_pid_namespace": false, + "requires_relaxed_confinement": false, + "optional_relaxed_confinement": false, + "requires_security_confirmation": false, + "hidden": false, + "template_id": "image-sharecovex" + }, { "id": "shotcut", "provider": "linuxserver.io", diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index fca048a9..214b9396 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -137,6 +137,62 @@ def _answers_from_recipe(ui) -> bool: return isinstance(ui, DefaultsUI) +SHARED_DIRECTORY_NAME = re.compile(r"[a-z][a-z0-9_-]{0,31}") + + +def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str, Any]], storage: str, + advanced: bool, optional: bool = False) -> list[dict[str, Any]]: + """The directories an application exists to share. Its profile declares one + container directory, and each directory the user names is mounted below + it, on a container volume or from a directory of the host. A new + installation asks for the first one and then for more until the user says + no; `optional` is for an installed application, which may add none.""" + profile = template["proxmox"]["installer_profile"] + declared = profile["shared_directories"] + root = declared["container_root"].rstrip("/") + reserved = set(declared.get("reserved_names", [])) + taken = {mount["container_path"] for mount in mounts} + app_id = template["id"].removeprefix("image-") + labels = [("managed-volume", translate("Container volume (included in backups)")), + ("host-bind", translate("Host directory (not included in Proxmox backups)"))] + added: list[dict[str, Any]] = [] + if optional and not ui.confirm(translate("Add a directory to share"), False): + return added + while True: + name = str(ui.ask(translate("Name of the directory to share (lowercase letters, digits, - or _)"))).strip() + target = f"{root}/{name}" + if not SHARED_DIRECTORY_NAME.fullmatch(name) or name in reserved: + raise ValueError(translate("The name must start with a lowercase letter and use only lowercase " + "letters, digits, - or _")) + if target in taken: + raise ValueError(f"{translate('This directory is already shared:')} {target}") + location = ui.choose(f"{translate('Where to store')} {target}", labels, "managed-volume") + if location is None: + raise UserCancelled(translate("Volume configuration cancelled")) + mount = {"type": location, "container_path": target, "custom": True, "source": storage, + "size_gb": None, "backup": location == "managed-volume", "read_only": False, + "create_if_missing": location == "host-bind"} + if location == "managed-volume": + if advanced: + mount["source"] = ask_storage(ui, f"{translate('Storage for')} {target}", "rootdir", storage) + mount["size_gb"] = _ask_size(ui, target, int(declared.get("default_size_gb", 32))) + # The application writes to it as its own user, not as root. + preparation = {"container_path": target, "remove_lost_found": True, + "owner_strategy": "mapped-application-user", + "only_when_mount_type": "managed-volume"} + if preparation not in profile.setdefault("volume_preparations", []): + profile["volume_preparations"].append(preparation) + else: + mount["source"] = ui.ask(f"{translate('Host path for')} {target}", + f"/mnt/oci-shared/{app_id}/{name}") + from .custom_mounts import validate_mount + mount["container_path"] = validate_mount(mount, [*mounts, *added]) + added.append(mount) + taken.add(target) + if not ui.confirm(translate("Add another directory to share?"), False): + return added + + def ask_application_extra_paths(ui, existing: list[str], storage: str) -> list[dict[str, Any]]: """Extra paths for the application container of a multi-container application. An advanced installation asks them; a default one does not.""" @@ -327,6 +383,9 @@ def build_deployment( if enabled: selected_security_options.update(item.get("options", {})) security_relaxation_acknowledged = True + if item.get("privileged"): + unprivileged = False + privileged_acknowledged = True runtime_profile = installer_profile.get("runtime", {}) hostname_default = _hostname_default(runtime_profile.get("hostname") or template["container_contract"]["container_name"]) @@ -425,6 +484,9 @@ def build_deployment( } ) + if installer_profile.get("shared_directories"): + mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced) + if advanced: mounts = ask_custom_mounts(ui, mounts, volume_storage) @@ -522,6 +584,9 @@ def build_deployment( value = secrets.token_hex(int(generated.get("bytes", 16))) if not value and item["required"]: raise InstallError(f"{name}: {translate('a value is required')}") + minimum = installer_profile.get("minimum_secret_length", {}).get(name) + if minimum and len(value) < int(minimum): + raise InstallError(f"{name}: {translate('the minimum number of characters is')} {minimum}") elif not advanced and default not in (None, ""): value = default else: diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index c61ad56b..79ff53fa 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -111,6 +111,11 @@ def edit_recreation(record, ui): resources = deployment['resources'] resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + template = candidate.get('template', {}) + if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'): + from .installer import ask_shared_directories + deployment['mounts'] += ask_shared_directories( + ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True) from .custom_mounts import ask_custom_mounts deployment['mounts'] = ask_custom_mounts( ui, deployment['mounts'], deployment['rootfs']['storage']) diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py new file mode 100644 index 00000000..b928d2ab --- /dev/null +++ b/oci/tests/test_shared_directories.py @@ -0,0 +1,154 @@ +"""An application that exists to share directories asks for them in every +installation, and its privileged profile is offered by the advanced one only.""" + +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment +from proxmenux_oci.recreation import edit_recreation +from test_advanced_flow_order import storages + +NAME = "Name of the directory to share (lowercase letters, digits, - or _)" +MORE = "Add another directory to share?" +NFSV4 = "Prepare this container for NFSv4?" + + +class ScriptedUI: + """Answers a question with the next scripted answer for it, then with its default.""" + + def __init__(self, answers=None): + self.answers = {key: list(value) for key, value in (answers or {}).items()} + self.asked = [] + + def _answer(self, text, default): + self.asked.append(text) + for key, values in self.answers.items(): + if (text == key or text.endswith(key)) and values: + return values.pop(0) + return default + + def ask(self, text, default=None, required=True): + return self._answer(text, default if default is not None else "") + + def password(self, text, required=True): + return self._answer(text, "") + + def choose(self, text, options, default=None): + return self._answer(text, default) + + def confirm(self, text, default=False): + return self._answer(text, default) + + def message(self, text): + pass + + +@patch("proxmenux_oci.i18n.language", return_value="en") +@patch("proxmenux_oci.installer.host.storages", side_effect=storages) +@patch("proxmenux_oci.installer.host.default_storage", side_effect=lambda content, default: default) +@patch("proxmenux_oci.installer.host.default_bridge", side_effect=lambda default: default) +@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}]) +@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid") +@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False}) +@patch("proxmenux_oci.installer.access.ask_ipv4", return_value=("dhcp", None)) +class SharedDirectoriesTests(unittest.TestCase): + catalog = Catalog(ROOT) + + def build(self, answers, mode=DEFAULT_MODE): + template = self.catalog.compose("sharecovex") + ui = ScriptedUI(answers) + return ui, template, build_deployment(template, ui, mode) + + def test_a_default_installation_asks_for_directories_until_the_answer_is_no(self, *_): + ui, template, plan = self.build({ + NAME: ["media", "backups"], + "Where to store /shares/media": ["host-bind"], + "Host path for /shares/media": ["/tank/media"], + "Size in GB of /shares/backups": ["200"], + MORE: [True, False], + }) + mounts = {m["container_path"]: m for m in plan["mounts"]} + self.assertEqual(list(mounts), ["/config", "/shares/media", "/shares/backups"]) + self.assertEqual((mounts["/shares/media"]["type"], mounts["/shares/media"]["source"], + mounts["/shares/media"]["backup"], mounts["/shares/media"]["create_if_missing"]), + ("host-bind", "/tank/media", False, True)) + self.assertEqual((mounts["/shares/backups"]["type"], mounts["/shares/backups"]["size_gb"], + mounts["/shares/backups"]["backup"], mounts["/shares/backups"]["custom"]), + ("managed-volume", 200, True, True)) + self.assertEqual(mounts["/shares/backups"]["source"], mounts["/config"]["source"]) + self.assertEqual(ui.asked.count(NAME), 2) + self.assertEqual(ui.asked.count(MORE), 2) + # The volume is prepared for the identity the application writes with; /config stays with root. + prepared = {item["container_path"]: item["owner_strategy"] + for item in template["proxmox"]["installer_profile"]["volume_preparations"]} + self.assertEqual(prepared, {"/config": "mapped-root", "/shares/backups": "mapped-application-user"}) + + def test_a_default_installation_stays_unprivileged_and_generates_the_panel_password(self, *_): + ui, _template, plan = self.build({NAME: ["media"]}) + self.assertFalse(any(NFSV4 in text for text in ui.asked)) + self.assertFalse(any("Add an extra custom path" in text for text in ui.asked)) + self.assertEqual(plan["security"]["unprivileged"], True) + self.assertEqual(plan["security"]["options"], {}) + password = next(item for item in plan["environment"] if item["name"] == "SHARECOVEX_ADMIN_PASSWORD") + self.assertRegex(password["value"], r"^[0-9a-f]{16}$") + self.assertFalse(any("ShareCoveX panel administrator" in text for text in ui.asked)) + self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) + self.assertEqual(plan["mounts"][1]["size_gb"], 32) + + def test_only_the_advanced_installation_offers_nfsv4_and_it_makes_the_container_privileged(self, *_): + ui, _template, plan = self.build({NAME: ["media"]}, ADVANCED_MODE) + self.assertTrue(any(NFSV4 in text for text in ui.asked)) + self.assertEqual((plan["security"]["unprivileged"], plan["security"]["privileged_acknowledged"]), (True, False)) + question = next(text for text in ui.asked if NFSV4 in text) + ui, _template, plan = self.build({NAME: ["media"], question: [True]}, ADVANCED_MODE) + security = plan["security"] + self.assertEqual((security["unprivileged"], security["privileged_acknowledged"], + security["relaxation_acknowledged"]), (False, True, True)) + self.assertEqual(security["options"], {"apparmor_profile": "unconfined", "seccomp_profile": "unconfined"}) + + def test_an_advanced_installation_places_each_directory_and_takes_the_password(self, *_): + ui, _template, plan = self.build({ + NAME: ["media"], + "Storage for /shares/media": ["Public"], + "(empty = generate)": ["a-long-panel-password"], + }, ADVANCED_MODE) + mounts = {m["container_path"]: m["source"] for m in plan["mounts"]} + self.assertEqual(mounts["/shares/media"], "Public") + self.assertEqual(next(item["value"] for item in plan["environment"]), "a-long-panel-password") + with self.assertRaisesRegex(InstallError, "minimum number of characters is 8"): + self.build({NAME: ["media"], "(empty = generate)": ["short"]}, ADVANCED_MODE) + + def test_a_name_that_cannot_identify_a_share_is_refused(self, *_): + for name in ("Media", "my files", "9lives", "config", "", "a" * 33): + with self.assertRaisesRegex(ValueError, "lowercase"): + self.build({NAME: [name]}) + with self.assertRaisesRegex(ValueError, "already shared"): + self.build({NAME: ["media", "media"], MORE: [True, False]}) + + def test_modify_can_add_another_directory_and_may_add_none(self, *_): + _ui, template, plan = self.build({NAME: ["media"]}) + record = {"template": template, "deployment": plan} + with patch("proxmenux_oci.recreation.refresh_template"), \ + patch("proxmenux_oci.recreation.edit_acceleration"), \ + patch("proxmenux_oci.recreation.edit_environment"): + ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"], + "Where to store /shares/photos": ["host-bind"], + "Host path for /shares/photos": ["/tank/photos"]}) + proposal = edit_recreation(record, ui) + mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]] + self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"]) + untouched = edit_recreation(record, ScriptedUI()) + self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]], + ["/config", "/shares/media"]) + self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"]) + + +if __name__ == "__main__": + unittest.main()