feat(oci): offer access to an existing host directory and gather the file-sharing applications

feat(oci): offer access to a host directory that already exists
feat(oci): gather the file-sharing applications in Files & Sharing
fix(oci): ShareCoveX keeps its settings in a 1 GB volume, and the new translations are mended
This commit is contained in:
MacRimi
2026-10-08 22:28:53 +02:00
parent 1a5fec13ec
commit abf436c352
24 changed files with 350 additions and 145 deletions
+4 -4
View File
@@ -35,16 +35,16 @@ Questions and ideas about OCI testing go in [discussion #360](https://github.com
| AdGuard Home | Adblock & DNS |
| Adguardhome Sync | Adblock & DNS |
| Alby Hub ✨ | Finance & Budgeting |
| Alist | Productivity & Workflows |
| Alist | Files & Sharing |
| aMule | Files & Downloads |
| Chromium | Browsers & Web Desktops |
| CodeProject.AI Server | AI |
| CopyParty | Files & Downloads |
| CopyParty | Files & Sharing |
| Crafty | Gaming & Leisure |
| Ddclient | Adblock & DNS |
| Duplicati | Backup & Recovery |
| Etherpad | Documents & Notes |
| FileBrowser Quantum | Tools |
| FileBrowser Quantum | Files & Sharing |
| FlareSolverr | *Arr Suite |
| Flexget | *Arr Suite |
| Frigate | NVR & Cameras |
@@ -58,7 +58,7 @@ Questions and ideas about OCI testing go in [discussion #360](https://github.com
| Motioneye | NVR & Cameras |
| Nextcloud | Productivity & Workflows |
| Nextcloud Stack | Productivity & Workflows |
| OpenList | Productivity & Workflows |
| OpenList | Files & Sharing |
| Openssh Server | Remote Access & VPN |
| Paperless-ngx | Productivity & Workflows |
| Phpmyadmin | Databases |
+2 -2
View File
@@ -13,8 +13,8 @@
"description": {
"en_US": "Official image with managed persistent configuration and selectable content storage. Validated on amd64: clean install, authenticated login, same-digest recreation and interrupted-candidate recovery. Cross-release migration and arm64 runtime not tested."
},
"category": "tools",
"category_label": "Tools",
"category": "sharing",
"category_label": "Files & Sharing",
"author": "gtsteffaniak",
"developer": "gtsteffaniak",
"icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/filebrowser-quantum.webp",
+1 -1
View File
@@ -89,7 +89,7 @@
"default": "managed-volume",
"managed_volume": {
"backup": true,
"default_size_gb": 16
"default_size_gb": 1
}
}
],
+15 -4
View File
@@ -34,6 +34,8 @@
"adguard-home": "adblock",
"adminer": "databases",
"airsonic-advanced": "media",
"alist": "sharing",
"alist-sync": "sharing",
"altus": "communication",
"ardour": "creative",
"audacity": "creative",
@@ -53,6 +55,7 @@
"cloudbeaver": "databases",
"code-server": "ai",
"cops": "media",
"copyparty": "sharing",
"crafty": "gaming",
"cura": "creative",
"darktable": "creative",
@@ -74,6 +77,8 @@
"emulatorjs": "gaming",
"faster-whisper": "ai",
"ferdium": "communication",
"filebrowser-quantum": "sharing",
"filedrop": "sharing",
"filezilla": "downloads",
"firefox": "desktop",
"flexget": "arr",
@@ -126,6 +131,7 @@
"ngircd": "communication",
"nzbhydra2": "arr",
"obsidian": "documents",
"openlist": "sharing",
"openshot": "creative",
"openspeedtest": "monitoring",
"openssh-server": "remote",
@@ -133,22 +139,25 @@
"opera": "desktop",
"orcaslicer": "creative",
"oscam": "media",
"pairdrop": "sharing",
"pcsx2": "gaming",
"pelorus": "ai",
"pidgin": "communication",
"pingvin-share": "sharing",
"piper": "ai",
"piwigo": "media",
"playit-agent": "web",
"ppsspp": "gaming",
"pwndrop": "downloads",
"psitransfer": "sharing",
"pwndrop": "sharing",
"pycharm": "ai",
"pydio-cells": "downloads",
"pydio-cells": "sharing",
"pyload-ng": "downloads",
"qdirstat": "tools",
"raneto": "documents",
"rawtherapee": "creative",
"remmina": "remote",
"resilio-sync": "downloads",
"resilio-sync": "sharing",
"retroarch": "gaming",
"retroarch-inglebard": "gaming",
"rpcs3": "gaming",
@@ -162,12 +171,14 @@
"sickgear": "arr",
"signal": "communication",
"smokeping": "monitoring",
"snapdrop": "sharing",
"speedtest-tracker": "monitoring",
"spotube": "media",
"sqlitebrowser": "databases",
"steam": "gaming",
"swag": "web",
"synclounge": "media",
"syncthing": "sharing",
"syslog-ng": "monitoring",
"tailscale": "remote",
"telegram": "communication",
@@ -193,7 +204,7 @@
"winegui": "desktop",
"wireshark": "network",
"wps-office": "documents",
"xbackbone": "downloads",
"xbackbone": "sharing",
"xemu": "gaming",
"yaak": "ai",
"your_spotify": "media",
+2 -2
View File
@@ -13,8 +13,8 @@
"description": {
"en_US": "Official image with managed persistent configuration and selectable content storage. Validated on amd64: clean install, authenticated login, same-digest recreation and interrupted-candidate recovery. Cross-release migration and arm64 runtime not tested."
},
"category": "tools",
"category_label": "Tools",
"category": "sharing",
"category_label": "Files & Sharing",
"author": "gtsteffaniak",
"developer": "gtsteffaniak",
"icon": null,
+1 -1
View File
@@ -89,7 +89,7 @@
"default": "managed-volume",
"managed_volume": {
"backup": true,
"default_size_gb": 16
"default_size_gb": 1
}
}
],
+30 -30
View File
@@ -269,8 +269,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2024-11-22",
"main_image": "xhofe/alist:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/alist.json",
"template_status": "laboratory-validated",
"automatic_install_candidate": true,
@@ -305,8 +305,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2025-03-15",
"main_image": "xjxjin/alist-sync:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/alist-sync.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -1680,8 +1680,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2026-03-25",
"main_image": "icewhaletech/copyparty:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/copyparty.json",
"template_status": "laboratory-validated",
"automatic_install_candidate": true,
@@ -2964,8 +2964,8 @@
],
"updated_at": "2026-09-16",
"main_image": "gtstef/filebrowser:latest",
"category": "tools",
"category_label": "Tools",
"category": "sharing",
"category_label": "Files & Sharing",
"replaces_discovered_ids": [],
"curated_path": "catalog/curated/filebrowser-quantum.json",
"template": "apps/filebrowser-quantum.json",
@@ -3002,8 +3002,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2023-09-13",
"main_image": "noecl/filedrop:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/filedrop.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -7576,8 +7576,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2026-05-25",
"main_image": "openlistteam/openlist:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/openlist.json",
"template_status": "laboratory-validated",
"automatic_install_candidate": true,
@@ -7906,8 +7906,8 @@
"pushed_at": "2026-09-12T14:37:28Z",
"updated_at": "2025-07-05",
"main_image": "lscr.io/linuxserver/pairdrop:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/pairdrop.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -8317,8 +8317,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2025-05-25",
"main_image": "stonith404/pingvin-share:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/pingvin-share.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -8791,8 +8791,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2026-02-14",
"main_image": "psitrax/psitransfer:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/psitransfer.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -8823,8 +8823,8 @@
"pushed_at": "2026-09-07T20:21:35Z",
"updated_at": "2026-07-17",
"main_image": "lscr.io/linuxserver/pwndrop:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/pwndrop.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -8886,8 +8886,8 @@
"pushed_at": "2026-08-27T14:24:17Z",
"updated_at": "2026-07-17",
"main_image": "lscr.io/linuxserver/pydio-cells:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/pydio-cells.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -9359,8 +9359,8 @@
"pushed_at": "2026-09-08T01:30:09Z",
"updated_at": "2024-08-21",
"main_image": "lscr.io/linuxserver/resilio-sync:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/resilio-sync.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -10016,8 +10016,8 @@
"pushed_at": "2026-09-11T10:43:22Z",
"updated_at": "2024-07-18",
"main_image": "linuxserver/snapdrop:latest",
"category": "productivity",
"category_label": "Productivity & Workflows",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/snapdrop.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -10486,8 +10486,8 @@
"pushed_at": "2026-09-10T15:29:13Z",
"updated_at": "2026-09-10",
"main_image": "lscr.io/linuxserver/syncthing:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/syncthing.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
@@ -12077,8 +12077,8 @@
"pushed_at": "2026-09-13T07:29:57Z",
"updated_at": "2026-07-05",
"main_image": "lscr.io/linuxserver/xbackbone:latest",
"category": "downloads",
"category_label": "Files & Downloads",
"category": "sharing",
"category_label": "Files & Sharing",
"template": "apps/xbackbone.json",
"template_status": "generated-unvalidated",
"automatic_install_candidate": true,
+5
View File
@@ -116,5 +116,10 @@
"unpackerr": [
"/data"
]
},
"sizes": {
"sharecovex": {
"/config": 1
}
}
}
+44 -2
View File
@@ -1779,12 +1779,42 @@ fi
if [[ $SELINUX_LABEL_DISABLED == true ]]; then
oci_log "label:disable kept as metadata; Proxmox uses AppArmor, not SELinux, for this LXC"
fi
# Gives the identity the application writes with access to a host directory
# that belongs to someone else, with an ACL: the owner and the permissions of
# what is there stay as they are, and the owner of the directory keeps access
# to what the application creates in it.
grant_shared_access() {
local source=$1 owner group triplet default_spec
command -v setfacl >/dev/null 2>&1 && command -v getfacl >/dev/null 2>&1 || return 1
# Granted already: an update or a modification installs the container again.
if getfacl -cpn "$source" 2>/dev/null | grep -q "^user:${HOST_BIND_UID}:rwx" \
&& getfacl -cpn "$source" 2>/dev/null | grep -q "^default:user:${HOST_BIND_UID}:rwx"; then
return 0
fi
owner=$(stat -c '%u' "$source") || return 1
group=$(stat -c '%g' "$source") || return 1
default_spec="d:u:${HOST_BIND_UID}:rwx,d:g:${HOST_BIND_GID}:rwx"
if [[ $owner != 0 && $owner != "$HOST_BIND_UID" ]]; then
default_spec="${default_spec},d:u:${owner}:rwx"
fi
if [[ $group != 0 && $group != "$HOST_BIND_GID" ]]; then
triplet=$(stat -c '%A' "$source")
triplet=${triplet:4:3}
triplet=${triplet//s/x}
triplet=${triplet//S/-}
default_spec="${default_spec},d:g:${group}:${triplet}"
fi
setfacl -R -P -m "u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX" "$source" 2>>"${OCI_LOG:-/dev/null}" || return 1
find "$source" -xdev -type d -exec setfacl -m "$default_spec" {} + 2>>"${OCI_LOG:-/dev/null}" || return 1
}
MOUNT_ENTRIES=$(jq '.mounts | if type == "array" then length else 0 end' "$DEPLOYMENT_FILE")
MOUNT_NOTES=()
MOUNT_WARNINGS=()
if (( MOUNT_ENTRIES > 0 )); then
msg_info "$(translate "Adding the mount points...")"
fi
while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSING; do
while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSING GRANT_ACCESS; do
[[ -n $TYPE ]] || continue
[[ $TARGET == /* && $TARGET != *","* ]] || die "$(translate "Invalid container path:") $TARGET"
RO_OPT=""
@@ -1821,6 +1851,15 @@ while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSI
MOUNT_NOTES+=("$(translate "Shared directory created:") $SOURCE")
fi
[[ -e $SOURCE ]] || die "$(translate "The host bind source does not exist:") $SOURCE"
if [[ -d $SOURCE && $GRANT_ACCESS == true ]]; then
if grant_shared_access "$SOURCE"; then
oci_log "Access granted with an ACL: $SOURCE (uid=$HOST_BIND_UID gid=$HOST_BIND_GID)"
MOUNT_NOTES+=("$(translate "Access granted to the application on:") $SOURCE")
else
oci_log "Could not grant access with an ACL: $SOURCE"
MOUNT_WARNINGS+=("$(translate "Access could not be granted; the application cannot write to:") $SOURCE")
fi
fi
if [[ -d $SOURCE ]]; then
if [[ -n ${PROXMENUX_OCI_TRANSACTION:-} ]]; then
python3 "${SCRIPT_DIR}/oci_instance_transaction.py" --root "$INSTANCE_ROOT" \
@@ -1847,13 +1886,16 @@ while IFS=$'\t' read -r TYPE TARGET SOURCE SIZE BACKUP READ_ONLY CREATE_IF_MISSI
oci_quiet pct set "$VMID" "--mp${MOUNT_INDEX}" "$MP_VALUE" \
|| die "$(translate "Could not add the mount point:") $TARGET"
MOUNT_INDEX=$((MOUNT_INDEX + 1))
done < <(jq -r '.mounts[] | [.type,.container_path,.source,(.size_gb // "-"),(.backup | if . then 1 else 0 end),.read_only,(.create_if_missing // false)] | @tsv' "$DEPLOYMENT_FILE")
done < <(jq -r '.mounts[] | [.type,.container_path,.source,(.size_gb // "-"),(.backup | if . then 1 else 0 end),.read_only,(.create_if_missing // false),(.grant_access // false)] | @tsv' "$DEPLOYMENT_FILE")
if (( MOUNT_ENTRIES > 0 )); then
msg_ok "$(translate "Mount points added:") $MOUNT_ENTRIES"
fi
for MOUNT_NOTE in ${MOUNT_NOTES[@]+"${MOUNT_NOTES[@]}"}; do
msg_ok "$MOUNT_NOTE"
done
for MOUNT_WARNING in ${MOUNT_WARNINGS[@]+"${MOUNT_WARNINGS[@]}"}; do
msg_warn "$MOUNT_WARNING"
done
while IFS=$'\t' read -r TARGET SIZE_MB OPTIONS; do
[[ -n $TARGET ]] || continue
+18
View File
@@ -79,6 +79,20 @@ def host_directories(root, members):
return paths
def granted_directories(root, members):
"""The host directories the installation was given access to with an ACL."""
paths = []
for vmid in members:
try:
record = instances.read(root, vmid)
except (OSError, ValueError, KeyError):
continue
for mount in record.get('deployment', {}).get('mounts', []):
if mount.get('grant_access') is True and mount.get('source') not in paths:
paths.append(mount['source'])
return paths
def private_bridge(primary):
network = (primary.get('stack') or {}).get('deployment', {}).get('network', {})
bridge = network.get('private_bridge')
@@ -313,6 +327,7 @@ def remove(root, vmid):
raise ValueError(f"{translate('The container runs on another node of the cluster; migrate it back to this node to remove it:')} "
f"CT {member} ({node})")
kept = host_directories(root, members)
granted = granted_directories(root, members)
bridge = private_bridge(primary)
incomplete = False
msg_info(translate('Removing the containers...'))
@@ -359,6 +374,9 @@ def remove(root, vmid):
msg_ok(f"{translate('Unused image removed from the cache:')} {path.name}")
for path in kept:
msg_info(f"{translate('Host directory listed in saved records (not targeted for removal):')} {path}")
for path in granted:
# Another container may write to it with the same identity.
msg_info(f"{translate('The access rule added to this host directory is kept:')} {path}")
return incomplete
+7 -2
View File
@@ -368,7 +368,8 @@ class Catalog:
except (OSError, json.JSONDecodeError):
data = {}
self._volume_policy = {"shared_paths": set(data.get("shared_paths", [])),
"applications": data.get("applications", {})}
"applications": data.get("applications", {}),
"sizes": data.get("sizes", {})}
return self._volume_policy
def _apply_volume_policy(self, app_id: str, contract: dict[str, Any]) -> None:
@@ -377,6 +378,8 @@ class Catalog:
state of the application stays in a container volume without asking."""
policy = self.volume_policy()
shared = policy["shared_paths"] | set(policy["applications"].get(app_id, []))
# A volume known to stay small is created at its stated size.
sizes = policy.get("sizes", {}).get(app_id, {})
for volume in contract.get("volumes", []):
choices = volume.get("installation_choice", [])
if "managed-volume" not in choices:
@@ -390,7 +393,9 @@ class Catalog:
volume["default"] = "managed-volume"
managed = volume.get("managed_volume")
if isinstance(managed, dict):
managed["default_size_gb"] = max(int(managed.get("default_size_gb") or 0), MINIMUM_VOLUME_GB)
stated = sizes.get(volume["container_path"])
managed["default_size_gb"] = (int(stated) if stated else
max(int(managed.get("default_size_gb") or 0), MINIMUM_VOLUME_GB))
def _enrich_index_from_templates(self, payload: dict[str, Any]) -> None:
for item in payload.get("applications", []):
+28
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import ipaddress
import json
import os
import re
import shutil
import subprocess
@@ -81,6 +82,33 @@ def ipv4_subnet(bridge: str) -> str | None:
return str(interface.network) if interface.version == 4 else None
def can_write(path: str, uid: int, gid: int) -> bool:
"""Whether this identity of the host can create files in an existing
directory, by its owner, its group, everybody else or an ACL entry."""
try:
status = os.stat(path)
except OSError:
return False
if status.st_uid == uid:
bits = status.st_mode >> 6
elif status.st_gid == gid:
bits = status.st_mode >> 3
else:
bits = status.st_mode
if bits & 3 == 3:
return True
try:
listing = subprocess.run(["getfacl", "-cpn", path], capture_output=True, text=True, check=False).stdout
except OSError:
return False
for line in listing.splitlines():
fields = line.split(":")
if (len(fields) >= 3 and fields[0] in ("user", "group") and fields[1] == str(uid if fields[0] == "user" else gid)
and "w" in fields[2][:3] and "x" in fields[2][:3]):
return True
return False
def timezone() -> str:
try:
value = Path("/etc/timezone").read_text(encoding="utf-8").strip()
+20 -3
View File
@@ -17,7 +17,7 @@ from typing import Any
from . import host
from . import network as access
from .i18n import translate
from .i18n import source_text, translate
from .ui import DialogUI, TerminalUI, UserCancelled
from .custom_mounts import ask_custom_mounts
from .extra_devices import device_permissions
@@ -141,7 +141,7 @@ 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]]:
advanced: bool, optional: bool = False, unprivileged: bool = True) -> 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
@@ -185,6 +185,22 @@ def ask_shared_directories(ui, template: dict[str, Any], mounts: list[dict[str,
else:
mount["source"] = ui.ask(f"{translate('Host path for')} {target}",
f"/mnt/oci-shared/{app_id}/{name}")
# A directory that is already there belongs to whoever made it. The
# identity the application writes with is the one of its volumes,
# shifted by the range of an unprivileged container.
owner = profile.get("volume_owner") or {}
shift = 100000 if unprivileged else 0
uid, gid = int(owner.get("uid", 0)) + shift, int(owner.get("gid", 0)) + shift
if os.path.isdir(mount["source"]) and not host.can_write(mount["source"], uid, gid):
title = source_text(template["catalog_ui"]["title"]) or app_id
if ui.confirm(translate(
"{path} already exists and belongs to another user of the host, so {app} cannot "
"write to it.\n\nProxMenux can add an access rule (ACL) for the identity {app} uses "
"on the host (UID {uid}). Nothing that is there changes owner or permissions: "
"whatever reads or writes those files today keeps doing so, and the owner of the "
"directory also gets access to the files {app} creates.\n\nGrant {app} access to "
"this directory?").format(path=mount["source"], app=title, uid=uid), True):
mount["grant_access"] = True
from .custom_mounts import validate_mount
mount["container_path"] = validate_mount(mount, [*mounts, *added])
added.append(mount)
@@ -485,7 +501,8 @@ def build_deployment(
)
if installer_profile.get("shared_directories"):
mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced)
mounts += ask_shared_directories(ui, template, mounts, volume_storage, advanced,
unprivileged=unprivileged)
if advanced:
mounts = ask_custom_mounts(ui, mounts, volume_storage)
+2 -1
View File
@@ -115,7 +115,8 @@ def edit_recreation(record, ui):
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)
ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True,
unprivileged=deployment.get('security', {}).get('unprivileged', True))
from .custom_mounts import ask_custom_mounts
deployment['mounts'] = ask_custom_mounts(
ui, deployment['mounts'], deployment['rootfs']['storage'])
+71
View File
@@ -2,7 +2,9 @@
installation, and its privileged profile is offered by the advanced one only."""
from pathlib import Path
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
@@ -10,6 +12,7 @@ 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 import host
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
@@ -101,6 +104,10 @@ class SharedDirectoriesTests(unittest.TestCase):
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)
# The settings stay small: their volume is created at the size the policy states.
self.assertEqual(plan["mounts"][0]["size_gb"], 1)
other = self.catalog.compose("seerr")["container_contract"]["volumes"][0]["managed_volume"]
self.assertGreaterEqual(other["default_size_gb"], 16)
def test_only_the_advanced_installation_offers_nfsv4_and_it_makes_the_container_privileged(self, *_):
ui, _template, plan = self.build({NAME: ["media"]}, ADVANCED_MODE)
@@ -125,6 +132,70 @@ class SharedDirectoriesTests(unittest.TestCase):
with self.assertRaisesRegex(InstallError, "minimum number of characters is 8"):
self.build({NAME: ["media"], "(empty = generate)": ["short"]}, ADVANCED_MODE)
def test_an_existing_directory_the_application_cannot_write_to_offers_access(self, *_):
with tempfile.TemporaryDirectory() as directory:
answers = {NAME: ["media"], "Where to store /shares/media": ["host-bind"],
"Host path for /shares/media": [directory]}
with patch("proxmenux_oci.installer.host.can_write", return_value=False) as check:
ui, _template, plan = self.build(answers)
question = next(text for text in ui.asked if "Grant ShareCoveX access" in text)
# The identity is the one of the volumes, in the range of an unprivileged container.
check.assert_called_once_with(directory, 101000, 101000)
self.assertIn("UID 101000", question)
self.assertIn("changes owner or permissions", question)
self.assertIs(plan["mounts"][1]["grant_access"], True)
_ui, _template, declined = self.build({**answers, question: [False]})
self.assertNotIn("grant_access", declined["mounts"][1])
with patch("proxmenux_oci.installer.host.can_write", return_value=True):
ui, _template, plan = self.build(answers)
self.assertFalse(any("Grant ShareCoveX access" in text for text in ui.asked))
self.assertNotIn("grant_access", plan["mounts"][1])
# A privileged container writes with the identity as it is.
nfsv4 = "NFSv4 needs a privileged container"
with patch("proxmenux_oci.installer.host.can_write", return_value=False) as check:
ui = ScriptedUI(answers)
template = self.catalog.compose("sharecovex")
first = build_deployment(template, ui, ADVANCED_MODE)
prompt = next(text for text in ui.asked if nfsv4 in text)
check.reset_mock()
build_deployment(self.catalog.compose("sharecovex"), ScriptedUI({**answers, prompt: [True]}),
ADVANCED_MODE)
check.assert_called_once_with(directory, 1000, 1000)
self.assertIsNotNone(first)
def test_a_directory_that_does_not_exist_yet_is_created_for_the_application(self, *_):
with patch("proxmenux_oci.installer.host.can_write") as check:
ui, _template, plan = self.build({NAME: ["media"], "Where to store /shares/media": ["host-bind"],
"Host path for /shares/media": ["/nonexistent/proxmenux-test/media"]})
check.assert_not_called()
self.assertNotIn("grant_access", plan["mounts"][1])
self.assertIs(plan["mounts"][1]["create_if_missing"], True)
def test_write_access_is_read_from_the_mode_bits(self, *_):
with tempfile.TemporaryDirectory() as directory:
status = os.stat(directory)
os.chmod(directory, 0o750)
self.assertTrue(host.can_write(directory, status.st_uid, 99999))
self.assertFalse(host.can_write(directory, 99998, status.st_gid))
self.assertFalse(host.can_write(directory, 99998, 99999))
os.chmod(directory, 0o770)
self.assertTrue(host.can_write(directory, 99998, status.st_gid))
os.chmod(directory, 0o757)
self.assertTrue(host.can_write(directory, 99998, 99999))
os.chmod(directory, 0o700)
self.assertFalse(host.can_write("/nonexistent/proxmenux-test", 0, 0))
def test_the_installer_script_grants_access_only_when_asked(self, *_):
script = (ROOT / "remote/install_oci.sh").read_text()
self.assertIn("(.grant_access // false)] | @tsv", script)
self.assertIn('if [[ -d $SOURCE && $GRANT_ACCESS == true ]]; then', script)
# The owner is never changed, and the rule is not applied twice.
grant = script[script.index("grant_shared_access() {"):script.index("MOUNT_ENTRIES=")]
self.assertNotIn("chown", grant)
self.assertNotIn("chmod", grant)
self.assertIn('grep -q "^default:user:${HOST_BIND_UID}:rwx"', grant)
self.assertIn('setfacl -R -P -m "u:${HOST_BIND_UID}:rwX,g:${HOST_BIND_GID}:rwX"', grant)
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"):