Merge pull request #528 from jcastro/feature/oci-input-review

Support owner-aware OCI tmpfs mounts and enable Input
This commit is contained in:
MacRimi
2026-10-26 18:17:48 +01:00
committed by GitHub
19 changed files with 1299 additions and 27 deletions
+2 -1
View File
@@ -509,8 +509,9 @@ A profile may also change the image, with `image`, for projects that publish one
| `external_services` | Names of services the application needs already running, such as `["MariaDB"]`. They are announced before the installation asks how to reach them |
| `generated_files` | Files written in the container before its first start, each with `container_path`, `content`, `owner` and `mode`. With `"only_if_missing": true` a file that already exists in a kept volume is not replaced |
| `volume_owner` | `uid` and `gid` of the user the application writes with, when it is not 1000 |
| `tmpfs_mounts` | Volatile paths with a size and mount options. An optional `owner_strategy` sets ownership to `mapped-root`, `mapped-application-user` (using `uid`/`gid` or `PUID`/`PGID`-style variables), or `image-user` (the effective `lxc.init.uid`/`lxc.init.gid`); `mode` sets permissions. The exact `/run` path requires an owner strategy. |
| `non_persistent_image_volumes` | Paths the image declares as volumes that are left inside the rootfs |
| `runtime`, `resources`, `security`, `device_requests`, `tmpfs_mounts`, `extra_hosts` | What the Compose keys of the [translation table](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose) become |
| `runtime`, `resources`, `security`, `device_requests`, `extra_hosts` | What the Compose keys of the [translation table](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose) become |
### `proxmox.security_profile`: privileges
+1 -1
View File
@@ -2,7 +2,7 @@
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
43 of 514 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
43 of 515 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it.
+460
View File
@@ -0,0 +1,460 @@
{
"schema_version": "0.5.0",
"kind": "proxmenux.oci-template",
"id": "image-input",
"status": "generated-unvalidated",
"catalog_ui": {
"title": {
"en_US": "Input"
},
"tagline": {
"en_US": "Self-hosted forms and workflows."
},
"description": {
"en_US": "A self-hosted form builder using its bundled SQLite configuration for a single-container setup. The application key is required to encrypt stored data."
},
"category": "business",
"category_label": "Business & ERP",
"author": "Deck9",
"developer": "Deck9",
"icon": "https://raw.githubusercontent.com/deck9/input/main/docs/public/input-icon.png",
"thumbnail": null,
"screenshots": [],
"architectures": [
"amd64",
"arm64"
],
"launch": {
"scheme": "http",
"port": 8080,
"path": "/"
},
"website": "https://getinput.co/",
"documentation": "https://github.com/deck9/input/blob/main/docs/quick-start.md",
"repository": "https://github.com/deck9/input",
"tips": [
"Create your first account in the web interface. This profile uses the single-container SQLite quick start; production deployments may instead use MariaDB, Redis, and S3."
],
"mini_changelog": [],
"display_version": null,
"updated_at": null
},
"source": {
"provider": "Deck9",
"repository": "https://github.com/deck9/input",
"revision": "2b08ab3b6bd73f12a03beedc19c6a561afe283c0",
"readme_raw_url": "https://github.com/deck9/input/blob/main/docs/quick-start.md",
"image_repository_url": "https://github.com/deck9/input/pkgs/container/input",
"readme_pushed_at": "",
"compose_sha256": "2153fd285760e5c8cd84b00efaa73641effa274e035ba86aa0ae153e7c17b8af",
"generated_at": "2026-10-26T06:30:00+00:00"
},
"container_contract": {
"service_name": "input",
"container_name": "input",
"image": {
"reference": "ghcr.io/deck9/input:main",
"registry": "ghcr.io",
"repository": "ghcr.io/deck9/input",
"tag": "main",
"digest": null,
"pull_policy": "resolve-written-reference-to-architecture-digest-at-install"
},
"environment": [
{
"name": "APP_URL",
"example": "http://@CONTAINER_ADDRESS@:8080",
"required": true,
"sensitive": false,
"source": "upstream-documentation",
"container_address": "http://@CONTAINER_ADDRESS@:8080"
},
{
"name": "APP_KEY",
"example": "",
"required": true,
"sensitive": true,
"source": "upstream-documentation"
}
],
"volumes": [
{
"id": "volume-0",
"container_path": "/var/www/html/storage",
"compose_source_example": "./storage",
"read_only": false,
"required": true,
"installation_choice": [
"managed-volume",
"host-bind"
],
"default": "managed-volume",
"managed_volume": {
"backup": true,
"default_size_gb": 4
}
}
],
"ports": [
{
"container_port": 8080,
"published_example": 8080,
"protocol": "tcp",
"required": true,
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
}
],
"related_services": [],
"restart": "unless-stopped",
"stop_grace_period": null,
"original_compose": "{\n \"name\": \"input\",\n \"services\": {\n \"input\": {\n \"image\": \"ghcr.io/deck9/input:main\",\n \"restart\": \"unless-stopped\",\n \"ports\": [\n \"8080:8080\"\n ],\n \"volumes\": [\n \"./storage:/var/www/html/storage\"\n ],\n \"environment\": {\n \"APP_URL\": \"http://@CONTAINER_ADDRESS@:8080\",\n \"APP_KEY\": \"\"\n }\n }\n }\n}"
},
"compose_stack": {
"project_name": "input",
"deployment_model": "one-native-oci-lxc-per-compose-service",
"user_experience": "single-application-install",
"main_service": "input",
"service_count": 1,
"services": [
{
"name": "input",
"image": "ghcr.io/deck9/input:main",
"is_main": true,
"role": "frontend",
"vmid_offset": 0,
"depends_on": [],
"frontend_network": true,
"private_network": false,
"compose": {
"image": "ghcr.io/deck9/input:main",
"restart": "unless-stopped",
"ports": [
"8080:8080"
],
"volumes": [
"./storage:/var/www/html/storage"
],
"environment": {
"APP_URL": "http://@CONTAINER_ADDRESS@:8080",
"APP_KEY": ""
}
}
}
],
"top_level": {
"name": "input"
},
"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": "input-volume-0",
"service": "input",
"container_path": "/var/www/html/storage",
"mode": "managed-volume",
"user_selectable": true,
"backup": true,
"shared_with_other_lxc": false,
"source_path": null,
"source_path_prompt": "Host path for Input data at /var/www/html/storage"
}
],
"orchestration": {
"reserve_vmids_atomically": 1,
"start_order": [
"input"
],
"stop_order": [
"input"
],
"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": "app-key",
"strategy": "generate-cryptographically-random-at-install",
"bindings": [
{
"service": "input",
"environment_variable": "APP_KEY"
}
]
}
]
}
},
"first_run": {
"endpoints": [
{
"label": "Input",
"scheme": "http",
"port": 8080,
"path": "/",
"source": "upstream-documentation"
}
],
"credentials": []
},
"proxmox": {
"runtime": "native-oci-lxc",
"technology_status": "proxmox-technology-preview",
"defaults": {
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
"memory_mb": 2048,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
"volume_storage": "local-lvm",
"template_storage": "local",
"bridge": "vmbr0",
"ipv4": "dhcp",
"firewall": true,
"host_managed_network": true,
"onboot": false,
"features": [
"nesting=1"
],
"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"
},
"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": "not-requested-by-compose"
},
{
"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"
}
],
"installer_profile": {
"volume_preparations": [
{
"container_path": "/var/www/html/storage",
"remove_lost_found": true,
"owner_strategy": "mapped-application-user",
"only_when_mount_type": "managed-volume"
}
],
"startup_healthcheck": {
"scheme": "http",
"port": 8080,
"path": "/",
"timeout_seconds": 180,
"request_timeout_seconds": 10,
"stability_seconds": 4,
"verify_tls": true
},
"tmpfs_mounts": [
{
"id": "runtime",
"container_path": "/run",
"default_size_mb": 64,
"minimum_size_mb": 16,
"prompt_size": false,
"mount_options": [
"rw",
"nosuid",
"nodev"
],
"owner_strategy": "image-user",
"mode": "0755"
}
],
"generated_sensitive_environment": {
"APP_KEY": {
"strategy": "token-hex",
"bytes": 16,
"prompt": false
}
}
}
},
"compatibility": {
"automatic_install_candidate": true,
"validated": false,
"supported_compose_keys": [
"image",
"restart",
"ports",
"volumes",
"environment"
],
"untranslated_blockers": [],
"policy": "The image runs nginx and PHP-FPM as its OCI image user. ProxMenux provides a native /run tmpfs owned by that effective LXC init user, with writable mode for the runtime files. The recipe remains unvalidated until a clean install passes its startup health check."
},
"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-then-apply-reviewed-native-lxc-update",
"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
}
}
+460
View File
@@ -0,0 +1,460 @@
{
"schema_version": "0.5.0",
"kind": "proxmenux.oci-template",
"id": "image-input",
"status": "generated-unvalidated",
"catalog_ui": {
"title": {
"en_US": "Input"
},
"tagline": {
"en_US": "Self-hosted forms and workflows."
},
"description": {
"en_US": "A self-hosted form builder using its bundled SQLite configuration for a single-container setup. The application key is required to encrypt stored data."
},
"category": "business",
"category_label": "Business & ERP",
"author": "Deck9",
"developer": "Deck9",
"icon": "https://raw.githubusercontent.com/deck9/input/main/docs/public/input-icon.png",
"thumbnail": null,
"screenshots": [],
"architectures": [
"amd64",
"arm64"
],
"launch": {
"scheme": "http",
"port": 8080,
"path": "/"
},
"website": "https://getinput.co/",
"documentation": "https://github.com/deck9/input/blob/main/docs/quick-start.md",
"repository": "https://github.com/deck9/input",
"tips": [
"Create your first account in the web interface. This profile uses the single-container SQLite quick start; production deployments may instead use MariaDB, Redis, and S3."
],
"mini_changelog": [],
"display_version": null,
"updated_at": null
},
"source": {
"provider": "Deck9",
"repository": "https://github.com/deck9/input",
"revision": "2b08ab3b6bd73f12a03beedc19c6a561afe283c0",
"readme_raw_url": "https://github.com/deck9/input/blob/main/docs/quick-start.md",
"image_repository_url": "https://github.com/deck9/input/pkgs/container/input",
"readme_pushed_at": "",
"compose_sha256": "2153fd285760e5c8cd84b00efaa73641effa274e035ba86aa0ae153e7c17b8af",
"generated_at": "2026-10-26T06:30:00+00:00"
},
"container_contract": {
"service_name": "input",
"container_name": "input",
"image": {
"reference": "ghcr.io/deck9/input:main",
"registry": "ghcr.io",
"repository": "ghcr.io/deck9/input",
"tag": "main",
"digest": null,
"pull_policy": "resolve-written-reference-to-architecture-digest-at-install"
},
"environment": [
{
"name": "APP_URL",
"example": "http://@CONTAINER_ADDRESS@:8080",
"required": true,
"sensitive": false,
"source": "upstream-documentation",
"container_address": "http://@CONTAINER_ADDRESS@:8080"
},
{
"name": "APP_KEY",
"example": "",
"required": true,
"sensitive": true,
"source": "upstream-documentation"
}
],
"volumes": [
{
"id": "volume-0",
"container_path": "/var/www/html/storage",
"compose_source_example": "./storage",
"read_only": false,
"required": true,
"installation_choice": [
"managed-volume",
"host-bind"
],
"default": "managed-volume",
"managed_volume": {
"backup": true,
"default_size_gb": 4
}
}
],
"ports": [
{
"container_port": 8080,
"published_example": 8080,
"protocol": "tcp",
"required": true,
"proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
}
],
"related_services": [],
"restart": "unless-stopped",
"stop_grace_period": null,
"original_compose": "{\n \"name\": \"input\",\n \"services\": {\n \"input\": {\n \"image\": \"ghcr.io/deck9/input:main\",\n \"restart\": \"unless-stopped\",\n \"ports\": [\n \"8080:8080\"\n ],\n \"volumes\": [\n \"./storage:/var/www/html/storage\"\n ],\n \"environment\": {\n \"APP_URL\": \"http://@CONTAINER_ADDRESS@:8080\",\n \"APP_KEY\": \"\"\n }\n }\n }\n}"
},
"compose_stack": {
"project_name": "input",
"deployment_model": "one-native-oci-lxc-per-compose-service",
"user_experience": "single-application-install",
"main_service": "input",
"service_count": 1,
"services": [
{
"name": "input",
"image": "ghcr.io/deck9/input:main",
"is_main": true,
"role": "frontend",
"vmid_offset": 0,
"depends_on": [],
"frontend_network": true,
"private_network": false,
"compose": {
"image": "ghcr.io/deck9/input:main",
"restart": "unless-stopped",
"ports": [
"8080:8080"
],
"volumes": [
"./storage:/var/www/html/storage"
],
"environment": {
"APP_URL": "http://@CONTAINER_ADDRESS@:8080",
"APP_KEY": ""
}
}
}
],
"top_level": {
"name": "input"
},
"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": "input-volume-0",
"service": "input",
"container_path": "/var/www/html/storage",
"mode": "managed-volume",
"user_selectable": true,
"backup": true,
"shared_with_other_lxc": false,
"source_path": null,
"source_path_prompt": "Host path for Input data at /var/www/html/storage"
}
],
"orchestration": {
"reserve_vmids_atomically": 1,
"start_order": [
"input"
],
"stop_order": [
"input"
],
"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": "app-key",
"strategy": "generate-cryptographically-random-at-install",
"bindings": [
{
"service": "input",
"environment_variable": "APP_KEY"
}
]
}
]
}
},
"first_run": {
"endpoints": [
{
"label": "Input",
"scheme": "http",
"port": 8080,
"path": "/",
"source": "upstream-documentation"
}
],
"credentials": []
},
"proxmox": {
"runtime": "native-oci-lxc",
"technology_status": "proxmox-technology-preview",
"defaults": {
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
"memory_mb": 2048,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
"volume_storage": "local-lvm",
"template_storage": "local",
"bridge": "vmbr0",
"ipv4": "dhcp",
"firewall": true,
"host_managed_network": true,
"onboot": false,
"features": [
"nesting=1"
],
"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"
},
"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": "not-requested-by-compose"
},
{
"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"
}
],
"installer_profile": {
"volume_preparations": [
{
"container_path": "/var/www/html/storage",
"remove_lost_found": true,
"owner_strategy": "mapped-application-user",
"only_when_mount_type": "managed-volume"
}
],
"startup_healthcheck": {
"scheme": "http",
"port": 8080,
"path": "/",
"timeout_seconds": 180,
"request_timeout_seconds": 10,
"stability_seconds": 4,
"verify_tls": true
},
"tmpfs_mounts": [
{
"id": "runtime",
"container_path": "/run",
"default_size_mb": 64,
"minimum_size_mb": 16,
"prompt_size": false,
"mount_options": [
"rw",
"nosuid",
"nodev"
],
"owner_strategy": "image-user",
"mode": "0755"
}
],
"generated_sensitive_environment": {
"APP_KEY": {
"strategy": "token-hex",
"bytes": 16,
"prompt": false
}
}
}
},
"compatibility": {
"automatic_install_candidate": true,
"validated": false,
"supported_compose_keys": [
"image",
"restart",
"ports",
"volumes",
"environment"
],
"untranslated_blockers": [],
"policy": "The image runs nginx and PHP-FPM as its OCI image user. ProxMenux provides a native /run tmpfs owned by that effective LXC init user, with writable mode for the runtime files. The recipe remains unvalidated until a clean install passes its startup health check."
},
"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-then-apply-reviewed-native-lxc-update",
"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
}
}
+32
View File
@@ -6646,6 +6646,38 @@
"hidden": false,
"template_id": "linuxserver-inkscape"
},
{
"id": "input",
"provider": "official",
"template_family": "curated-profile",
"title": "Input",
"repository": "https://github.com/deck9/input",
"description": "A self-hosted form builder using its bundled SQLite configuration for a single-container setup. The application key is required to encrypt stored data.",
"website": "https://getinput.co/",
"icon": "https://raw.githubusercontent.com/deck9/input/main/docs/public/input-icon.png",
"architectures": [
"amd64",
"arm64"
],
"updated_at": null,
"main_image": "ghcr.io/deck9/input:main",
"category": "business",
"category_label": "Business & ERP",
"replaces_discovered_ids": [],
"curated_path": "catalog/curated/input.json",
"template": "apps/input.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-input"
},
{
"id": "instatic",
"provider": "CoreBunch",
+7 -11
View File
@@ -2005,17 +2005,6 @@ 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
[[ $TARGET == /* && $TARGET != *","* && $TARGET != *[[:space:]]* ]] \
|| die "$(translate "Invalid tmpfs path:") $TARGET"
[[ $SIZE_MB =~ ^[0-9]+$ && $SIZE_MB -gt 0 ]] || die "$(translate "Invalid tmpfs size:") $TARGET"
[[ $OPTIONS =~ ^(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3})(,(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3}))*$ ]] || die "$(translate "Invalid tmpfs options:") $TARGET"
TARGET_RELATIVE=${TARGET#/}
printf 'lxc.mount.entry: tmpfs %s tmpfs %s,size=%sM,create=dir 0 0\n' \
"$TARGET_RELATIVE" "$OPTIONS" "$SIZE_MB" >>"$CONF"
done < <(jq -r '.tmpfs_mounts[]? | [.container_path,.size_mb,(.mount_options | join(","))] | @tsv' "$DEPLOYMENT_FILE")
DEVICE_INDEX=0
RESOLVED_CHARACTER_DEVICES=()
NVIDIA_RUNTIME_CONFIGURED=0
@@ -2101,6 +2090,13 @@ prepare_managed_format_directories() {
prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs"
apply_extra_hosts
apply_installer_profile
# Render tmpfs entries after the image and installer profile have set the
# effective LXC init uid/gid. The same renderer validates and records them.
TMPFS_CONFIG=$(mktemp)
python3 "${SCRIPT_DIR}/oci_runtime_settings.py" --render-tmpfs "$DEPLOYMENT_FILE" "$CONF" \
>"$TMPFS_CONFIG" || { rm -f "$TMPFS_CONFIG"; die "$(translate "Could not configure the tmpfs mounts")"; }
cat "$TMPFS_CONFIG" >>"$CONF"
rm -f "$TMPFS_CONFIG"
ensure_runtime_home
apply_rlimits
apply_host_monitor
+93 -7
View File
@@ -1,4 +1,6 @@
"""Validate declared volatile mounts and native network sysctl includes."""
import argparse
import json
import os
from pathlib import Path
import re
@@ -25,15 +27,63 @@ def sysctl_content(deployment):
return ''.join(result)
def tmpfs_lines(deployment):
def _config_ids(config):
"""Read the effective OCI init identity written by Proxmox."""
if config is None:
return 0, 0
text = config.decode() if isinstance(config, bytes) else str(config)
values = {}
for line in text.splitlines():
if ': ' not in line:
continue
key, value = line.split(': ', 1)
if key in ('lxc.init.uid', 'lxc.init.gid'):
if key in values:
raise ValueError(translate('The OCI image user or group is ambiguous'))
if not re.fullmatch(r'[0-9]{1,10}', value):
raise ValueError(translate('The OCI image user or group is not numeric'))
number = int(value)
if number > 4294967294:
raise ValueError(translate('The OCI image user or group is outside the supported range'))
values[key] = number
return values.get('lxc.init.uid', 0), values.get('lxc.init.gid', 0)
def _application_ids(deployment, item):
"""Resolve Compose-style application IDs inside the LXC user namespace."""
environment = {}
for variable in deployment.get('environment', []):
environment[variable.get('name')] = variable.get('value')
uid = item.get('uid')
gid = item.get('gid')
if uid is None:
uid = next((environment[name] for name in ('PUID', 'USER_ID', 'UID')
if environment.get(name) not in (None, '')), 0)
if gid is None:
gid = next((environment[name] for name in ('PGID', 'GROUP_ID', 'GID')
if environment.get(name) not in (None, '')), 0)
result = []
for value in (uid, gid):
if isinstance(value, bool) or not re.fullmatch(r'[0-9]{1,10}', str(value)):
raise ValueError(translate('The application user or group is not numeric'))
number = int(value)
if number > 4294967294:
raise ValueError(translate('The application user or group is outside the supported range'))
result.append(number)
return tuple(result)
def tmpfs_lines(deployment, config=None):
result = []
targets = set()
persistent = [m['container_path'].rstrip('/') for m in deployment.get('mounts', [])]
for item in deployment.get('tmpfs_mounts', []):
target, size = item['container_path'], item['size_mb']
owner_strategy = item.get('owner_strategy')
if (not re.fullmatch(r'/[A-Za-z0-9_./-]+', target) or '..' in target.split('/')
or '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev','/run')
or not (target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm')
or '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev')
or target == '/run' and owner_strategy not in ('mapped-root', 'mapped-application-user', 'image-user')
or not (target == '/run' or target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm')
or isinstance(size, bool) or not isinstance(size, int) or size < 1):
raise ValueError(translate('The tmpfs path or size is outside the supported profile'))
if any(target == p or target.startswith(p+'/') or p.startswith(target+'/') for p in [*persistent,*targets]):
@@ -43,11 +93,35 @@ def tmpfs_lines(deployment):
raise ValueError(translate('Unsupported tmpfs options'))
if len(options) != len(set(options)) or ('rw' in options and 'ro' in options):
raise ValueError(translate('Contradictory tmpfs options'))
mode = item.get('mode')
if mode is not None:
if not isinstance(mode, str) or not re.fullmatch(r'0[0-7]{3}', mode):
raise ValueError(translate('Unsupported tmpfs mode'))
if any(option.startswith('mode=') for option in options):
raise ValueError(translate('The tmpfs mode is specified more than once'))
if owner_strategy is not None:
if owner_strategy == 'mapped-root':
uid, gid = 0, 0
elif owner_strategy == 'mapped-application-user':
uid, gid = _application_ids(deployment, item)
elif owner_strategy == 'image-user':
uid, gid = _config_ids(config)
else:
raise ValueError(translate('Unsupported tmpfs owner strategy'))
options = [*options, f'uid={uid}', f'gid={gid}']
if mode is not None:
options.append(f'mode={mode}')
targets.add(target)
result.append(f'tmpfs {target.lstrip("/")} tmpfs {",".join(options)},size={size}M,create=dir 0 0')
return result
def render_tmpfs(deployment_path, config_path):
deployment = json.loads(Path(deployment_path).read_text())
config = Path(config_path).read_bytes()
return ''.join(f'lxc.mount.entry: {line}\n' for line in tmpfs_lines(deployment, config))
# /etc/pve/lxc is the folder of the local node only; this one is the same on
# every node of a cluster, so a migrated container still finds its include.
CLUSTER_DIR = Path('/etc/pve/proxmenux')
@@ -85,7 +159,7 @@ def seccomp_path(vmid):
def check(config, deployment, vmid):
expected = tmpfs_lines(deployment)
expected = tmpfs_lines(deployment, config)
lines = config.decode().splitlines()
actual = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.mount.entry: tmpfs ')]
if sorted(actual) != sorted(expected):
@@ -123,11 +197,11 @@ def file_binds(config):
return found
def filter_config(config, deployment):
def filter_config(config, deployment, runtime_config=None):
"""The configuration without the entries of the mounts the deployment
declares itself: what is left belongs to the acceleration profile."""
config = nested_mounts.without(config)
declared = set(tmpfs_lines(deployment))
declared = set(tmpfs_lines(deployment, runtime_config if runtime_config is not None else config))
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
def own(line):
@@ -141,7 +215,11 @@ def filter_config(config, deployment):
def check_recovery(config, state):
plans = [state.get(key, {}).get('deployment', {}) for key in ('record', 'candidate_contract')]
permitted = {line for plan in plans for line in tmpfs_lines(plan)}
configs = [config]
before_config = state.get('before_config')
if isinstance(before_config, str):
configs.append(before_config.encode())
permitted = {line for plan in plans for source in configs for line in tmpfs_lines(plan, source)}
for line in config.decode().splitlines():
if line.startswith('lxc.mount.entry: tmpfs ') and line.split(': ', 1)[1] not in permitted:
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
@@ -180,3 +258,11 @@ def restore(deployment, vmid):
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--render-tmpfs', nargs=2, metavar=('DEPLOYMENT', 'CONFIG'))
args = parser.parse_args()
if args.render_tmpfs:
print(render_tmpfs(*args.render_tmpfs), end='')
+8
View File
@@ -578,8 +578,16 @@ def build_deployment(
"container_path": item["container_path"],
"size_mb": size_mb,
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
**({"owner_strategy": item["owner_strategy"]} if item.get("owner_strategy") else {}),
**({"mode": item["mode"]} if item.get("mode") is not None else {}),
**({"uid": item["uid"]} if item.get("uid") is not None else {}),
**({"gid": item["gid"]} if item.get("gid") is not None else {}),
}
)
if item.get("owner_strategy") == "mapped-application-user":
for key in ("uid", "gid"):
if key not in tmpfs_mounts[-1] and installer_profile.get("volume_owner", {}).get(key) is not None:
tmpfs_mounts[-1][key] = installer_profile["volume_owner"][key]
if advanced:
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
+10 -2
View File
@@ -326,8 +326,16 @@ def refresh_template(candidate, ui):
size = item['default_size_mb']
if item.get('prompt_size', True):
size = positive_integer(ui, f"{translate('Tmpfs size in MiB for')} {item['container_path']}", size, item.get('minimum_size_mb', 1))
tmpfs.append({'container_path': item['container_path'], 'size_mb': size,
'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))})
tmpfs_mount = {'container_path': item['container_path'], 'size_mb': size,
'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))}
for key in ('owner_strategy', 'mode', 'uid', 'gid'):
if item.get(key) is not None:
tmpfs_mount[key] = copy.deepcopy(item[key])
if item.get('owner_strategy') == 'mapped-application-user':
for key in ('uid', 'gid'):
if key not in tmpfs_mount and profile.get('volume_owner', {}).get(key) is not None:
tmpfs_mount[key] = copy.deepcopy(profile['volume_owner'][key])
tmpfs.append(tmpfs_mount)
deployment['tmpfs_mounts'] = tmpfs
sysctls = deployment.get('security', {}).get('sysctls', [])
for item in profile.get('security', {}).get('sysctls', []):
+7 -4
View File
@@ -53,10 +53,13 @@ class RuntimeHomeInstallerContractTests(unittest.TestCase):
self.assertIn('set_runtime_environment HOME "$home"', self.source)
def test_home_fallback_runs_after_runtime_user_is_applied(self):
self.assertIn(
"apply_extra_hosts\napply_installer_profile\nensure_runtime_home\napply_rlimits",
self.source,
)
profile = self.source.index("apply_installer_profile\n", self.source.index("apply_extra_hosts\n"))
tmpfs = self.source.index("--render-tmpfs \"$DEPLOYMENT_FILE\" \"$CONF\"", profile)
home = self.source.index("ensure_runtime_home\n", tmpfs)
limits = self.source.index("apply_rlimits\n", home)
self.assertLess(profile, tmpfs)
self.assertLess(tmpfs, home)
self.assertLess(home, limits)
if __name__ == "__main__":
+134
View File
@@ -0,0 +1,134 @@
"""Ownership and mode are preserved on volatile native OCI-LXC mounts."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
sys.path.insert(0, str(ROOT / 'src'))
import oci_runtime_settings as runtime_settings
class TmpfsOwnershipTests(unittest.TestCase):
def test_image_user_is_taken_from_the_effective_lxc_init_identity(self):
plan = {'tmpfs_mounts': [{
'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw', 'nosuid', 'nodev'],
'owner_strategy': 'image-user', 'mode': '0755',
}]}
config = b'lxc.init.uid: 65534\nlxc.init.gid: 65534\n'
self.assertEqual(runtime_settings.tmpfs_lines(plan, config), [
'tmpfs run tmpfs rw,nosuid,nodev,uid=65534,gid=65534,mode=0755,size=64M,create=dir 0 0'
])
def test_mapped_root_and_application_user_strategies(self):
root = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-root'}]}
app = {'environment': [{'name': 'PUID', 'value': '1001'}, {'name': 'PGID', 'value': '1002'}],
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user'}]}
overridden = {'environment': [{'name': 'PUID', 'value': '1001'}, {'name': 'PGID', 'value': '1002'}],
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user',
'uid': 2001, 'gid': 2002}]}
self.assertIn('uid=0,gid=0', runtime_settings.tmpfs_lines(root)[0])
self.assertIn('uid=1001,gid=1002', runtime_settings.tmpfs_lines(app)[0])
self.assertIn('uid=2001,gid=2002', runtime_settings.tmpfs_lines(overridden)[0])
def test_plain_run_mount_is_rejected_without_an_owner_strategy(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64,
'mount_options': ['rw', 'nosuid', 'nodev']}]}
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(plan)
def test_invalid_owner_ids_modes_and_duplicate_init_identity_are_rejected(self):
app = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user',
'uid': 'root', 'gid': 10}]}
mode = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-root', 'mode': '777'}]}
image = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'image-user'}]}
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(app)
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(mode)
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(image, b'lxc.init.uid: 1000\nlxc.init.uid: 1001\n')
def test_runtime_validation_requires_the_exact_rendered_owner_and_mode(self):
plan = {'vmid': 120, 'tmpfs_mounts': [{
'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755',
}]}
entry = 'tmpfs run tmpfs rw,uid=1000,gid=1001,mode=0755,size=64M,create=dir 0 0'
runtime_settings.check(
f'lxc.init.uid: 1000\nlxc.init.gid: 1001\nlxc.mount.entry: {entry}\n'.encode(), plan, 120
)
with self.assertRaises(ValueError):
runtime_settings.check(
f'lxc.init.uid: 1000\nlxc.init.gid: 1001\n'
'lxc.mount.entry: tmpfs run tmpfs rw,uid=0,gid=0,mode=0755,size=64M,create=dir 0 0\n'.encode(),
plan, 120,
)
def test_acceleration_filter_recognises_image_owned_tmpfs_entry(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
line = ('lxc.mount.entry: tmpfs run tmpfs rw,uid=1000,gid=1001,mode=0755,'
'size=64M,create=dir 0 0\n')
config = ('lxc.init.uid: 1000\nlxc.init.gid: 1001\n' + line).encode()
filtered = runtime_settings.filter_config(config, plan).decode()
self.assertNotIn('tmpfs', filtered)
self.assertIn('lxc.init.uid: 1000', filtered)
def test_recovery_accepts_only_journalled_image_user_tmpfs_entries(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
old = b'lxc.init.uid: 1000\nlxc.init.gid: 1001\n'
current_line = ('lxc.mount.entry: tmpfs run tmpfs rw,uid=2000,gid=2001,mode=0755,'
'size=64M,create=dir 0 0\n')
current = ('lxc.init.uid: 2000\nlxc.init.gid: 2001\n' + current_line).encode()
state = {'vmid': 120, 'before_config': old.decode(), 'record': {'deployment': plan},
'candidate_contract': {'deployment': plan}}
runtime_settings.check_recovery(current, state)
unknown = current.replace(b'uid=2000,gid=2001', b'uid=2000,gid=3001')
with self.assertRaises(ValueError):
runtime_settings.check_recovery(unknown, state)
def test_input_is_visible_installable_and_uses_its_image_user_for_run(self):
template = json.loads((ROOT / 'catalog/curated/input.json').read_text())
profile = template['proxmox']['installer_profile']
entry = next(item for item in profile['tmpfs_mounts'] if item['container_path'] == '/run')
index = json.loads((ROOT / 'catalog/index.json').read_text())['applications']
index_item = next(item for item in index if item['id'] == 'input')
self.assertEqual(entry['owner_strategy'], 'image-user')
self.assertEqual(entry['mode'], '0755')
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertTrue(index_item['automatic_install_candidate'])
self.assertFalse(index_item['hidden'])
self.assertFalse(template['compatibility']['validated'])
def test_installer_renders_tmpfs_after_the_profile_sets_the_image_identity(self):
script = (ROOT / 'remote/install_oci.sh').read_text()
profile = script.index('apply_installer_profile\n', script.index('prepare_managed_format_directories "/var/lib'))
render = script.index('--render-tmpfs "$DEPLOYMENT_FILE" "$CONF"')
self.assertLess(profile, render)
def test_recipe_renderer_uses_the_same_lines_as_runtime_validation(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
config = b'lxc.init.uid: 65534\nlxc.init.gid: 65534\n'
with tempfile.TemporaryDirectory() as temporary:
deployment_path = Path(temporary) / 'deployment.json'
config_path = Path(temporary) / 'container.conf'
deployment_path.write_text(json.dumps(plan))
config_path.write_bytes(config)
self.assertEqual(runtime_settings.render_tmpfs(deployment_path, config_path),
'lxc.mount.entry: ' + runtime_settings.tmpfs_lines(plan, config)[0] + '\n')
if __name__ == '__main__':
unittest.main()