diff --git a/README.md b/README.md index 7481b86..cb00815 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/cli/commands.py b/cli/commands.py index 3e6a91a..c24f699 100644 --- a/cli/commands.py +++ b/cli/commands.py @@ -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: diff --git a/cli/tui.py b/cli/tui.py index ba56528..fece92e 100644 --- a/cli/tui.py +++ b/cli/tui.py @@ -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", diff --git a/docs/16-permissions-rootless-podman.md b/docs/16-permissions-rootless-podman.md new file mode 100644 index 0000000..183aa1a --- /dev/null +++ b/docs/16-permissions-rootless-podman.md @@ -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 +``` diff --git a/elemes.py b/elemes.py index 264f11c..3e262b6 100755 --- a/elemes.py +++ b/elemes.py @@ -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"): diff --git a/podman-compose.yml b/podman-compose.yml index 763cfb2..c203c4c 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -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