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:
parent
afa31116c3
commit
1852cbca45
18
README.md
18
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) |
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue