fix(permission): rootless Podman keep-id + perintah fixperms

- elemes service memakai userns_mode: keep-id -> container uid 1000
  dipetakan ke user host, dua arah (upload/publish & edit konten) beres
- ./elemes.sh fixperms: podman unshare chown -R 0:0 content/assets,
  idempotent (scan ownership dulu), otomatis di run/runbuild/runclearbuild
- dokumentasi model permission + troubleshooting (README + docs/16)
This commit is contained in:
a2nr 2026-10-26 13:59:43 +07:00
parent afa31116c3
commit 1852cbca45
6 changed files with 201 additions and 0 deletions

View File

@ -430,6 +430,23 @@ cd elemes
LMS akan berjalan dan bisa diakses melalui Tailscale (jika dikonfigurasi)
atau langsung di `http://localhost:3000`.
### 8b. Permission `content/` & `assets/` (Linux rootless Podman)
Service `elemes` memakai `userns_mode: keep-id`, sehingga container uid 1000
(`app`) dipetakan ke user host yang menjalankan Podman — file hasil
publish/upload bisa dihapus dari file manager tanpa sudo, dan editor konten
tidak kena 403.
Bila deployment lama sudah terlanjur punya file milik subuid (`100999`),
jalankan sekali:
```bash
./elemes.sh fixperms
```
Perintah ini idempotent dan juga dijalankan otomatis oleh `run`/`runbuild`.
Detail model permission & troubleshooting: `docs/16-permissions-rootless-podman.md`.
### 9. Kelola Siswa via Round-Trip CSV (Export → Edit → Import)
Halaman **Laporan Progress** (`/progress`) menyediakan workflow bulat untuk
@ -486,6 +503,7 @@ student_id;token;nama_siswa;hello_world;variabel
| `./elemes.sh init` | Setup awal: generate `.env`, `content/`, `assets/`, `state/` dari contoh |
| `./elemes.sh run` | Jalankan container (termasuk migrasi schema & bootstrap guru otomatis) |
| `./elemes.sh runbuild` | Build ulang & jalankan container |
| `./elemes.sh fixperms` | Selaraskan ownership `content/` & `assets/` (rootless Podman `keep-id`) |
| `./elemes.sh stop` | Hentikan container |
| `./elemes.sh teacher` | Buat/update akun guru (upsert; prompt nama & token tersembunyi) |
| `./elemes.sh dbupgrade` | Jalankan migrasi schema (alembic upgrade head) |

View File

@ -115,6 +115,86 @@ def cmd_init(verbose: bool = False) -> int:
return 0
def _has_foreign_owner(paths: List[Path]) -> bool:
"""True bila ada file/folder yang bukan milik user host saat ini.
Dipakai untuk membuat perbaikan permission idempotent: `podman unshare
chown -R` hanya dijalankan bila memang ada file warisan subuid (100999)
atau root.
"""
if not hasattr(os, "getuid"):
return False
uid = os.getuid()
for base in paths:
if not base.exists():
continue
for dirpath, dirnames, filenames in os.walk(base):
for name in dirnames + filenames:
try:
if (Path(dirpath) / name).lstat().st_uid != uid:
return True
except OSError:
continue
return False
def _sync_content_ownership(verbose: bool = False, announce: bool = False) -> int:
"""Selaraskan ownership `content/` & `assets/` untuk rootless Podman keep-id.
Rootless Podman memetakan container uid 1000 ke host subuid (100999) pada
mode default. Dengan `userns_mode: keep-id` container uid 1000 = host uid
1000. File lama yang terlanjur dimiliki subuid diperbaiki dengan
`podman unshare chown -R 0:0` (di namespace default, uid 0 = user host).
"""
if sys.platform.startswith("win"):
return 0
runtime = detect_container_command()
if runtime != "podman":
if announce:
print("ℹ️ fixperms hanya relevan untuk rootless Podman; Docker Desktop sudah memetakan user.")
return 0
if hasattr(os, "getuid") and os.getuid() == 0:
if announce:
print("ℹ️ Berjalan sebagai root — pemetaan keep-id tidak berlaku, permission dilewati.")
return 0
targets = [p for p in (PARENT_DIR / "content", PARENT_DIR / "assets") if p.exists()]
if not targets:
if announce:
print("⚠️ Folder content/ dan assets/ belum ada — jalankan ./elemes.sh init dahulu.")
return 1
if not _has_foreign_owner(targets):
if announce:
print("✅ Permission content/ & assets/ sudah selaras dengan user host.")
return 0
print("🔧 Menyelaraskan kepemilikan content/ & assets/ (rootless Podman keep-id)...")
ret, _, err = run_container_cmd(
["unshare", "chown", "-R", "0:0", *[str(target) for target in targets]],
verbose=verbose,
)
if ret != 0:
print("❌ Gagal menyelaraskan permission content/assets.")
if err and not verbose:
print(err.strip())
return ret
print("✅ Permission diselaraskan: file content/assets kini dimiliki user host.")
return 0
def cmd_fixperms(verbose: bool = False) -> int:
"""Perbaiki ownership content/assets yang terlanjur dimiliki subuid Podman."""
print("🔧 === Menyelaraskan Permission Content & Assets ===")
ret = _sync_content_ownership(verbose=verbose, announce=True)
if ret == 0:
print(" File administratif di ../content dan ../assets kini bisa diubah/dihapus tanpa sudo.")
return ret
def db_init(verbose: bool = False) -> int:
"""
First-run & database health: wait for PostgreSQL, migrate schema (idempotent),
@ -184,6 +264,7 @@ def cmd_run(verbose: bool = False) -> int:
"""Run existing LMS containers."""
is_v = verbose or is_verbose()
print("🚀 Menjalankan container...")
_sync_content_ownership(verbose=is_v)
generate_ts_config()
services = elemes_services()
if services:
@ -244,6 +325,7 @@ def cmd_runbuild(verbose: bool = False) -> int:
return ret
print("🚀 Menjalankan container di background...")
_sync_content_ownership(verbose=is_v)
generate_ts_config()
services = elemes_services()
if services:
@ -274,6 +356,7 @@ def cmd_runclearbuild(verbose: bool = False) -> int:
return ret
print("🚀 Menjalankan container di background...")
_sync_content_ownership(verbose=is_v)
generate_ts_config()
services = elemes_services()
if services:

View File

@ -26,6 +26,7 @@ from .commands import (
cmd_dbupgrade,
cmd_docs_validate,
cmd_exportall,
cmd_fixperms,
cmd_importall,
cmd_init,
cmd_run,
@ -594,6 +595,12 @@ def menu_server_lifecycle() -> None:
"Verifikasi status container, image tag, dan STALE status",
lambda: run_action_screen("Verify Containers", lambda: cmd_verify(verbose=is_verbose()), "verify"),
),
MenuItem(
"7",
"Fix Content Permissions",
"Selaraskan ownership content/assets untuk rootless Podman (keep-id)",
lambda: run_action_screen("Fix Content Permissions", lambda: cmd_fixperms(verbose=is_verbose()), "fixperms"),
),
MenuItem(
"t",
"Toggle Verbose Mode",

View File

@ -0,0 +1,84 @@
---
title: Permission Content & Assets (Rootless Podman)
order: 16
category: deployment
---
# Permission `content/` & `assets/` — Rootless Podman `keep-id`
**Tanggal:** 2026-10-26
**Status:** Aktif
**Lokasi kode:** `podman-compose.yml`, `cli/commands.py`, `cli/runner.py`
---
## 1. Masalah
Pada Podman rootless, container berjalan di *user namespace* sendiri. Secara
default uid 1000 di dalam container (`app`) dipetakan ke **subuid host**
(mis. `100999`, tergantung isi `/etc/subuid`), bukan ke user yang menjalankan
Podman (`a2nr`, uid 1000). Akibatnya dua arah sama-sama rusak:
| Arah | Gejala |
|------|--------|
| Container → host | File hasil publish/upload/create dimiliki host `100999`; user `a2nr` tidak bisa menghapus/mengubahnya dari file manager tanpa sudo. |
| Host → container | File hasil `./elemes.sh init` (milik host `1000`) tampak sebagai **root** di container; proses `app` tidak bisa menulis → editor konten gagal (403). |
Tidak ada mekanisme penyelarasan permission di repo sebelum perbaikan ini.
## 2. Solusi: `userns_mode: keep-id`
Service `elemes` di `podman-compose.yml` kini memakai:
```yaml
elemes:
userns_mode: keep-id
```
Dengan `keep-id`, uid container 1000 (`app`) dipetakan **1:1** ke host uid
1000 (user yang menjalankan compose). File yang dibuat backend otomatis
dimiliki user host, dan file milik user host bisa ditulis `app`.
> ⚠️ `keep-id` hanya untuk Podman rootless di Linux. Docker Desktop sudah
> memetakan user secara otomatis; opsi ini tidak diperlukan (dan tidak
> dikenali Docker Compose klasik).
## 3. Perbaikan deployment lama: `./elemes.sh fixperms`
File yang terlanjur dimiliki subuid (`100999`) tidak berubah otomatis hanya
karena compose diubah. Perbaiki sekali dengan:
```bash
cd elemes
./elemes.sh fixperms
```
Perintah ini menjalankan:
```bash
podman unshare chown -R 0:0 ../content ../assets
```
Di namespace default `podman unshare`, uid 0 = user host. Jadi seluruh isi
`content/` dan `assets/` kembali dimiliki `a2nr` dan tetap terbaca sebagai
uid 1000 (`app`) di container `keep-id`.
Perintah bersifat **idempotent**: jika semua file sudah dimiliki user host,
tidak ada `chown` yang dijalankan. `./elemes.sh run`, `runbuild`, dan
`runclearbuild` juga memanggil pemeriksaan yang sama secara otomatis sebelum
`up -d`.
## 4. Troubleshooting
| Gejala | Penyebab | Tindakan |
|--------|----------|----------|
| Tidak bisa hapus file di `content/`/`assets/` dari file manager | File lama dimiliki subuid `100999` | `./elemes.sh fixperms`, lalu cek `ls -ln` |
| Editor konten 403 / "Permission denied" saat simpan | Container tidak memakai `keep-id`, atau file milik root host | Pastikan `userns_mode: keep-id` aktif, jalankan ulang `./elemes.sh runbuild` |
| `fixperms` bilang "Docker Desktop sudah memetakan user" | Runtime terdeteksi Docker, bukan Podman | Tidak ada tindakan — permission Docker Desktop sudah benar |
| `podman unshare` gagal | Podman rootless belum dikonfigurasi / dijalankan sebagai root | Jalankan sebagai user biasa; cek `/etc/subuid` dan `/etc/subgid` |
Verifikasi cepat:
```bash
ls -ln ../content ../assets | head
# owner harus uid user Anda (mis. 1000), bukan 100999
```

View File

@ -96,6 +96,7 @@ from cli.commands import (
cmd_dbupgrade,
cmd_docs_validate,
cmd_exportall,
cmd_fixperms,
cmd_importall,
cmd_init,
cmd_run,
@ -136,6 +137,7 @@ def print_help() -> None:
run # Menjalankan container LMS yang sudah ada
runbuild # Build image lalu jalankan container
runclearbuild # Bersihkan cache, Re-build total, lalu jalankan
fixperms # Selaraskan ownership content/assets (rootless Podman)
stop # Menghentikan container yang sedang berjalan
stopclean # Hentikan container lalu bersihkan stopped container & image tak terpakai
verify # Verifikasi image dan konfigurasi container
@ -202,6 +204,8 @@ def main() -> int:
ret = cmd_runbuild(verbose=verbose)
elif command == "runclearbuild":
ret = cmd_runclearbuild(verbose=verbose)
elif command in ("fixperms", "fix-perms"):
ret = cmd_fixperms(verbose=verbose)
elif command == "stop":
ret = cmd_stop(verbose=verbose or is_verbose())
elif command in ("stopclean", "stop-clean"):

View File

@ -44,6 +44,11 @@ services:
build: .
image: lms-backend:latest
network_mode: service:elemes-ts
# Rootless Podman: container uid 1000 (`app`) dipetakan ke host uid 1000
# (user yang menjalankan compose), bukan ke subuid offset (100999).
# Tanpa ini file publish/upload dimiliki host subuid sehingga tidak bisa
# dihapus user, dan file hasil init tampak root di dalam container.
userns_mode: keep-id
volumes:
- ../content:/app/content:rw
- ../assets:/app/assets:rw