feat(furtka): in-browser app settings + ISO recovery-path fixes
End-to-end VM test today (2026-04-15) validated the resource manager golden path but exposed four things blocking "dein-Vater-tauglich": no way to configure an app without SSH+editor, no openssh, no nano, keyboard stuck on US, and a samba healthcheck that cried wolf. Resource-manager side: - Manifest schema gains optional `settings` list (name/label/ description/type/required/default) and `description_long`. - Bundled-app install opens a form rendered from the manifest; submit carries values to `POST /api/apps/install` which writes them into the new app's `.env` before the placeholder check runs. - Installed apps grow an "Einstellungen" button that merges a partial settings dict into the existing `.env` (unsubmitted password fields = keep current), then reconciles to restart. - New endpoints: `GET/POST /api/apps/<name>/settings`. Passwords are never returned to the client. - Fileshare manifest declares its SMB_USER/SMB_PASSWORD settings in German with help text. ISO side (so the next build is actually usable on the TTY): - Add `openssh` to the package list + `sshd` to enabled services. `archinstall: true` in 4.x did not install openssh-server. - Add `nano` — `vim` was the only editor pitched at users, which is brutal for first-timers (and was missing anyway). - Keyboard layout follows the installer language (`de→de`, `pl→pl`, `en→us`) instead of hardcoded `us`. A German user couldn't type `/` or `-` at the console, making even `sudo nano` painful. - Disable the dperson/samba healthcheck in the compose override — it timed out on every probe while the share itself worked fine. 19 new tests (manifest parsing + settings-merge + two new API endpoints over live HTTP); 94 total, format + lint clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0af2134b7e
commit
61c7ee232c
11 changed files with 820 additions and 33 deletions
|
|
@ -7,8 +7,16 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow an "Einstellungen" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
|
||||
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
|
||||
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
|
||||
|
||||
### Changed
|
||||
|
||||
- Keyboard layout at the TTY now follows the chosen installer language (`de` → `de`, `pl` → `pl`, `en` → `us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
|
||||
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
|
||||
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
|
||||
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
|
||||
- Python package renamed from `homebase` → `furtka` in `pyproject.toml`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ services:
|
|||
image: dperson/samba:latest
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
# The upstream image's HEALTHCHECK times out under normal operation on
|
||||
# our setup (2026-04-15 VM test — all 6 probes failed while the share
|
||||
# was reachable from clients). Disable to avoid a permanently-"unhealthy"
|
||||
# container that scares users reading `docker ps`.
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- USERID=1000
|
||||
- GROUPID=1000
|
||||
|
|
|
|||
|
|
@ -3,7 +3,25 @@
|
|||
"display_name": "Network Files",
|
||||
"version": "0.1.0",
|
||||
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
|
||||
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
|
||||
"volumes": ["files"],
|
||||
"ports": [445, 139],
|
||||
"icon": "icon.svg"
|
||||
"icon": "icon.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "SMB_USER",
|
||||
"label": "Benutzername",
|
||||
"description": "Der Name, mit dem sich Geräte am Share anmelden.",
|
||||
"type": "text",
|
||||
"default": "furtka",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SMB_PASSWORD",
|
||||
"label": "Passwort",
|
||||
"description": "Mindestens 8 Zeichen. Wird nie angezeigt — auch dir nicht.",
|
||||
"type": "password",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@
|
|||
"packages": [
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"nano",
|
||||
"vim",
|
||||
"git",
|
||||
"htop",
|
||||
"curl"
|
||||
"curl",
|
||||
"openssh"
|
||||
],
|
||||
|
||||
"profile": {
|
||||
|
|
@ -29,7 +31,8 @@
|
|||
},
|
||||
|
||||
"services": [
|
||||
"docker"
|
||||
"docker",
|
||||
"sshd"
|
||||
],
|
||||
|
||||
"network_config": {
|
||||
|
|
|
|||
299
furtka/api.py
299
furtka/api.py
|
|
@ -26,7 +26,7 @@ _HTML = """<!DOCTYPE html>
|
|||
<title>Furtka Apps</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; }
|
||||
:root { --bg:#0f1115; --fg:#e8eaed; --muted:#9aa0a6; --accent:#6ee7b7; --card:#1a1d24; --warn:#4a3030; --danger:#f08080; --border:#2a2d34; }
|
||||
* { box-sizing:border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; background: var(--bg); color: var(--fg); line-height:1.5; }
|
||||
h1 { font-size: 2rem; margin: 0; }
|
||||
|
|
@ -38,11 +38,28 @@ _HTML = """<!DOCTYPE html>
|
|||
.name { font-weight: 600; font-size: 1.05rem; }
|
||||
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
|
||||
.desc { color: var(--muted); font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; }
|
||||
.buttons { display:flex; gap: 0.5rem; flex-wrap: wrap; justify-content: flex-end; }
|
||||
button { background: var(--accent); border: none; color: var(--bg); font-weight: 600; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; white-space: nowrap; font-size: 0.9rem; }
|
||||
button.secondary { background: var(--card); color: var(--fg); border: 1px solid var(--border); }
|
||||
button.danger { background: var(--danger); }
|
||||
button:disabled { opacity: 0.5; cursor: wait; }
|
||||
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
|
||||
pre { background: var(--card); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; align-items: flex-start; justify-content: center; padding: 2rem 1rem; overflow-y: auto; z-index: 10; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { background: var(--card); border-radius: 8px; padding: 1.5rem; max-width: 520px; width: 100%; }
|
||||
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
|
||||
.modal .long { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; white-space: pre-wrap; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.95rem; }
|
||||
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
|
||||
.field input { width: 100%; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.6rem; font-size: 0.95rem; font-family: inherit; }
|
||||
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
||||
.modal .error { background: var(--warn); color: #fed; padding: 0.5rem 0.75rem; border-radius: 4px; margin-bottom: 1rem; font-size: 0.9rem; display: none; }
|
||||
.modal .error.show { display: block; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -59,6 +76,19 @@ _HTML = """<!DOCTYPE html>
|
|||
<h2>Last action</h2>
|
||||
<pre id="log">(none yet)</pre>
|
||||
|
||||
<div id="modal-backdrop" class="modal-backdrop" role="dialog" aria-modal="true">
|
||||
<div class="modal">
|
||||
<h3 id="modal-title"></h3>
|
||||
<div id="modal-long" class="long"></div>
|
||||
<div id="modal-error" class="error"></div>
|
||||
<form id="modal-form" onsubmit="return false;"></form>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" id="modal-cancel">Cancel</button>
|
||||
<button type="button" id="modal-submit">Install</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
|
|
@ -66,23 +96,138 @@ function esc(s) {
|
|||
return d.innerHTML;
|
||||
}
|
||||
|
||||
const modal = {
|
||||
backdrop: document.getElementById('modal-backdrop'),
|
||||
title: document.getElementById('modal-title'),
|
||||
long: document.getElementById('modal-long'),
|
||||
form: document.getElementById('modal-form'),
|
||||
error: document.getElementById('modal-error'),
|
||||
submit: document.getElementById('modal-submit'),
|
||||
cancel: document.getElementById('modal-cancel'),
|
||||
current: null, // { name, action: 'install' | 'edit' }
|
||||
};
|
||||
|
||||
modal.cancel.addEventListener('click', () => closeModal());
|
||||
modal.backdrop.addEventListener('click', (e) => { if (e.target === modal.backdrop) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||
|
||||
function closeModal() {
|
||||
modal.backdrop.classList.remove('open');
|
||||
modal.form.innerHTML = '';
|
||||
modal.error.classList.remove('show');
|
||||
modal.error.textContent = '';
|
||||
modal.current = null;
|
||||
}
|
||||
|
||||
async function openSettingsDialog(name, action) {
|
||||
const r = await fetch(`/api/apps/${encodeURIComponent(name)}/settings`);
|
||||
if (!r.ok) {
|
||||
document.getElementById('log').textContent =
|
||||
`[settings ${name}] HTTP ${r.status}\\n` + await r.text();
|
||||
return;
|
||||
}
|
||||
const data = await r.json();
|
||||
modal.current = { name, action };
|
||||
modal.title.textContent = data.display_name || data.name;
|
||||
modal.long.textContent = data.description_long || data.description || '';
|
||||
modal.long.style.display = modal.long.textContent ? '' : 'none';
|
||||
modal.submit.textContent = action === 'install' ? 'Install' : 'Save and restart';
|
||||
|
||||
if (!data.settings.length) {
|
||||
// No form fields — treat as simple confirm.
|
||||
modal.form.innerHTML = '<p class="hint">No settings to configure.</p>';
|
||||
} else {
|
||||
modal.form.innerHTML = data.settings.map(s => {
|
||||
const id = `field-${esc(s.name)}`;
|
||||
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
|
||||
const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : '';
|
||||
return `
|
||||
<div class="field">
|
||||
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
|
||||
${s.description ? `<div class="hint">${esc(s.description)}</div>` : ''}
|
||||
<input
|
||||
id="${id}"
|
||||
name="${esc(s.name)}"
|
||||
type="${s.type === 'password' ? 'password' : s.type === 'number' ? 'number' : 'text'}"
|
||||
value="${value}"
|
||||
placeholder="${esc(placeholder)}"
|
||||
${s.required && action === 'install' ? 'required' : ''}
|
||||
autocomplete="off"
|
||||
spellcheck="false">
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
modal.backdrop.classList.add('open');
|
||||
const first = modal.form.querySelector('input');
|
||||
if (first) first.focus();
|
||||
}
|
||||
|
||||
modal.submit.addEventListener('click', submitModal);
|
||||
|
||||
async function submitModal() {
|
||||
if (!modal.current) return;
|
||||
const { name, action } = modal.current;
|
||||
const values = {};
|
||||
for (const input of modal.form.querySelectorAll('input')) {
|
||||
// In edit mode, skip password fields left blank — server keeps existing.
|
||||
if (action === 'edit' && input.type === 'password' && input.value === '') continue;
|
||||
values[input.name] = input.value;
|
||||
}
|
||||
modal.submit.disabled = true;
|
||||
const original = modal.submit.textContent;
|
||||
modal.submit.textContent = action === 'install' ? 'Installing…' : 'Saving…';
|
||||
modal.error.classList.remove('show');
|
||||
try {
|
||||
const url = action === 'install'
|
||||
? '/api/apps/install'
|
||||
: `/api/apps/${encodeURIComponent(name)}/settings`;
|
||||
const body = action === 'install' ? { name, settings: values } : { settings: values };
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
document.getElementById('log').textContent =
|
||||
`[${action} ${name}] HTTP ${r.status}\\n` + JSON.stringify(data, null, 2);
|
||||
if (!r.ok) {
|
||||
modal.error.textContent = data.error || `HTTP ${r.status}`;
|
||||
modal.error.classList.add('show');
|
||||
modal.submit.disabled = false;
|
||||
modal.submit.textContent = original;
|
||||
return;
|
||||
}
|
||||
closeModal();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
modal.error.textContent = `Network error: ${e.message}`;
|
||||
modal.error.classList.add('show');
|
||||
modal.submit.disabled = false;
|
||||
modal.submit.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [installed, available] = await Promise.all([
|
||||
fetch('/api/apps').then(r => r.json()),
|
||||
fetch('/api/bundled').then(r => r.json()),
|
||||
]);
|
||||
document.getElementById('installed').innerHTML = installed.length
|
||||
? installed.map(a => `
|
||||
? installed.map(a => {
|
||||
const hasSettings = a.has_settings;
|
||||
return `
|
||||
<div class="app">
|
||||
<div class="meta">
|
||||
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
|
||||
<span class="desc">${esc(a.description || a.error || '')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button data-op="install" data-name="${esc(a.name)}">Reinstall</button>
|
||||
<div class="buttons">
|
||||
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Einstellungen</button>` : ''}
|
||||
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
|
||||
<button class="danger" data-op="remove" data-name="${esc(a.name)}">Remove</button>
|
||||
</div>
|
||||
</div>`).join('')
|
||||
</div>`;
|
||||
}).join('')
|
||||
: '<div class="empty">No apps installed yet.</div>';
|
||||
document.getElementById('available').innerHTML = available.length
|
||||
? available.map(a => `
|
||||
|
|
@ -95,16 +240,22 @@ async function refresh() {
|
|||
</div>`).join('')
|
||||
: '<div class="empty">No bundled apps left to install.</div>';
|
||||
for (const btn of document.querySelectorAll('button[data-op]')) {
|
||||
btn.addEventListener('click', () => act(btn.dataset.op, btn.dataset.name, btn));
|
||||
btn.addEventListener('click', () => handleButton(btn.dataset.op, btn.dataset.name, btn));
|
||||
}
|
||||
}
|
||||
|
||||
async function act(op, name, btn) {
|
||||
async function handleButton(op, name, btn) {
|
||||
if (op === 'install' || op === 'edit') {
|
||||
openSettingsDialog(name, op === 'install' ? 'install' : 'edit');
|
||||
return;
|
||||
}
|
||||
// Reinstall + remove are direct actions, no form.
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = op === 'install' ? 'Installing…' : 'Removing…';
|
||||
btn.textContent = op === 'reinstall' ? 'Reinstalling…' : 'Removing…';
|
||||
try {
|
||||
const r = await fetch(`/api/apps/${op}`, {
|
||||
const url = op === 'reinstall' ? '/api/apps/install' : '/api/apps/remove';
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name}),
|
||||
|
|
@ -132,8 +283,10 @@ def _manifest_summary(m):
|
|||
"display_name": m.display_name,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"description_long": m.description_long,
|
||||
"ports": list(m.ports),
|
||||
"icon": m.icon,
|
||||
"has_settings": bool(m.settings),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -169,10 +322,68 @@ def _list_bundled():
|
|||
return out
|
||||
|
||||
|
||||
def _do_install(name):
|
||||
def _load_manifest_for(name):
|
||||
"""Return (manifest, env_values, installed_bool) for an installed or bundled app.
|
||||
|
||||
Returns (None, None, False) if the name doesn't resolve anywhere.
|
||||
"""
|
||||
target = apps_dir() / name
|
||||
if target.exists() and (target / "manifest.json").exists():
|
||||
try:
|
||||
m = load_manifest(target / "manifest.json")
|
||||
except ManifestError:
|
||||
return None, None, False
|
||||
values = installer.read_env_values(target / ".env")
|
||||
return m, values, True
|
||||
bundled = bundled_apps_dir() / name
|
||||
if bundled.exists() and (bundled / "manifest.json").exists():
|
||||
try:
|
||||
m = load_manifest(bundled / "manifest.json")
|
||||
except ManifestError:
|
||||
return None, None, False
|
||||
env_example = bundled / ".env.example"
|
||||
values = installer.read_env_values(env_example) if env_example.exists() else {}
|
||||
return m, values, False
|
||||
return None, None, False
|
||||
|
||||
|
||||
def _do_get_settings(name):
|
||||
m, values, installed = _load_manifest_for(name)
|
||||
if m is None:
|
||||
return 404, {"error": f"{name!r} not found"}
|
||||
settings_out = []
|
||||
for s in m.settings:
|
||||
# Never return password values back to the client — user either keeps
|
||||
# the current value (blank input means "don't change") or types a new one.
|
||||
if s.type == "password":
|
||||
current = ""
|
||||
else:
|
||||
current = values.get(s.name, s.default if s.default is not None else "")
|
||||
settings_out.append(
|
||||
{
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"description": s.description,
|
||||
"type": s.type,
|
||||
"required": s.required,
|
||||
"default": s.default,
|
||||
"value": current,
|
||||
}
|
||||
)
|
||||
return 200, {
|
||||
"name": m.name,
|
||||
"display_name": m.display_name,
|
||||
"description": m.description,
|
||||
"description_long": m.description_long,
|
||||
"installed": installed,
|
||||
"settings": settings_out,
|
||||
}
|
||||
|
||||
|
||||
def _do_install(name, settings=None):
|
||||
try:
|
||||
src = installer.resolve_source(name)
|
||||
target = installer.install_from(src)
|
||||
target = installer.install_from(src, settings=settings)
|
||||
except installer.InstallError as e:
|
||||
return 400, {"error": str(e)}
|
||||
actions = reconciler.reconcile(apps_dir())
|
||||
|
|
@ -184,6 +395,29 @@ def _do_install(name):
|
|||
return (207 if reconciler.has_errors(actions) else 200, payload)
|
||||
|
||||
|
||||
def _do_update_settings(name, settings):
|
||||
"""Write settings into an installed app's .env and kick off a reinstall.
|
||||
|
||||
Only works for already-installed apps — use /api/apps/install for fresh
|
||||
installs (since bundled-app folders under /opt/... are read-only).
|
||||
"""
|
||||
target = apps_dir() / name
|
||||
if not target.exists():
|
||||
return 404, {"error": f"{name!r} is not installed"}
|
||||
try:
|
||||
installer.update_env(name, settings)
|
||||
except installer.InstallError as e:
|
||||
return 400, {"error": str(e)}
|
||||
actions = reconciler.reconcile(apps_dir())
|
||||
return (
|
||||
207 if reconciler.has_errors(actions) else 200,
|
||||
{
|
||||
"updated": name,
|
||||
"actions": [{"kind": a.kind, "target": a.target, "detail": a.detail} for a in actions],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _do_remove(name):
|
||||
target = apps_dir() / name
|
||||
if not target.exists():
|
||||
|
|
@ -200,6 +434,21 @@ def _do_remove(name):
|
|||
return 200, {"removed": name, "compose_warning": compose_warning}
|
||||
|
||||
|
||||
def _parse_settings_body(payload):
|
||||
"""Extract and coerce the settings dict from a JSON body. Returns dict or None."""
|
||||
s = payload.get("settings")
|
||||
if s is None:
|
||||
return None
|
||||
if not isinstance(s, dict):
|
||||
return False # sentinel — caller should reject
|
||||
out = {}
|
||||
for k, v in s.items():
|
||||
if not isinstance(k, str):
|
||||
return False
|
||||
out[k] = "" if v is None else str(v)
|
||||
return out
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def _json(self, status, payload):
|
||||
body = json.dumps(payload).encode()
|
||||
|
|
@ -224,6 +473,13 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
return self._json(200, _list_installed())
|
||||
if self.path == "/api/bundled":
|
||||
return self._json(200, _list_bundled())
|
||||
# /api/apps/<name>/settings
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||
if "/" in name or not name:
|
||||
return self._json(400, {"error": "invalid app name"})
|
||||
status, body = _do_get_settings(name)
|
||||
return self._json(status, body)
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
|
|
@ -233,12 +489,29 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
payload = json.loads(raw.decode()) if raw else {}
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return self._json(400, {"error": "invalid JSON body"})
|
||||
if not isinstance(payload, dict):
|
||||
return self._json(400, {"error": "body must be a JSON object"})
|
||||
|
||||
# Per-app settings update: /api/apps/<name>/settings
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||
if "/" in name or not name:
|
||||
return self._json(400, {"error": "invalid app name"})
|
||||
settings = _parse_settings_body(payload)
|
||||
if settings is False or settings is None:
|
||||
return self._json(400, {"error": "missing or invalid 'settings' object"})
|
||||
status, body = _do_update_settings(name, settings)
|
||||
return self._json(status, body)
|
||||
|
||||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
return self._json(400, {"error": "missing or empty 'name' field"})
|
||||
|
||||
if self.path == "/api/apps/install":
|
||||
status, body = _do_install(name)
|
||||
settings = _parse_settings_body(payload)
|
||||
if settings is False:
|
||||
return self._json(400, {"error": "'settings' must be an object"})
|
||||
status, body = _do_install(name, settings=settings)
|
||||
elif self.path == "/api/apps/remove":
|
||||
status, body = _do_remove(name)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,30 @@ def _placeholder_keys(env_path: Path) -> list[str]:
|
|||
return bad
|
||||
|
||||
|
||||
def _format_env_value(v: str) -> str:
|
||||
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
||||
# docker-compose's env substitution reads them back intact. Simple values
|
||||
# stay unquoted to keep the file readable when a user SSHes in.
|
||||
if v == "" or any(c in v for c in " \t\"'$`\\#"):
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
return v
|
||||
|
||||
|
||||
def write_env(env_path: Path, values: dict[str, str]) -> None:
|
||||
"""Write a KEY=VALUE .env file atomically with 0600 perms.
|
||||
|
||||
Preserves insertion order of `values` so the file reads in the same order
|
||||
the user filled in the form.
|
||||
"""
|
||||
lines = [f"{k}={_format_env_value(v)}" for k, v in values.items()]
|
||||
body = "\n".join(lines) + ("\n" if lines else "")
|
||||
tmp = env_path.with_suffix(env_path.suffix + ".tmp")
|
||||
tmp.write_text(body)
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(env_path)
|
||||
|
||||
|
||||
def resolve_source(source: str) -> Path:
|
||||
"""Resolve a `furtka app install <source>` arg to a real source folder.
|
||||
|
||||
|
|
@ -47,13 +71,17 @@ def resolve_source(source: str) -> Path:
|
|||
raise InstallError(f"{source!r} not found as a path or bundled app")
|
||||
|
||||
|
||||
def install_from(src: Path) -> Path:
|
||||
def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
||||
"""Copy a validated app folder into /var/lib/furtka/apps/<name>/.
|
||||
|
||||
Preserves an existing .env on upgrade. Bootstraps .env from .env.example
|
||||
on first install if .env wasn't shipped. Refuses to finish (raises
|
||||
InstallError) if the resulting .env still has placeholder secrets — the
|
||||
target folder is left in place so the user can edit and re-run.
|
||||
If `settings` is provided, the .env is written from those values (this is
|
||||
what the Web UI / API does — user fills in a form, values land here).
|
||||
Otherwise, preserves an existing .env on upgrade and bootstraps from
|
||||
.env.example on first install.
|
||||
|
||||
Refuses to finish (raises InstallError) if the resulting .env still has
|
||||
placeholder secrets — the target folder is left in place so the user can
|
||||
edit and re-run.
|
||||
|
||||
Returns the target folder on success.
|
||||
"""
|
||||
|
|
@ -65,22 +93,56 @@ def install_from(src: Path) -> Path:
|
|||
except ManifestError as e:
|
||||
raise InstallError(str(e)) from e
|
||||
|
||||
if settings is not None:
|
||||
declared = {s.name for s in m.settings}
|
||||
for key in settings:
|
||||
if key not in declared:
|
||||
raise InstallError(f"{m.name}: unknown setting {key!r}")
|
||||
|
||||
target = apps_dir() / m.name
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in src.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
# Never overwrite an existing user .env.
|
||||
# Never overwrite an existing user .env — either settings-driven write
|
||||
# or previous manual edit has authority.
|
||||
if item.name == ".env" and (target / ".env").exists():
|
||||
continue
|
||||
shutil.copy2(item, target / item.name)
|
||||
|
||||
# First install with no .env shipped: bootstrap from .env.example so the
|
||||
# user has something to edit and compose has values to substitute.
|
||||
env = target / ".env"
|
||||
env_example = target / ".env.example"
|
||||
if not env.exists() and env_example.exists():
|
||||
|
||||
if settings is not None:
|
||||
# Merge: start from existing .env (to preserve values the user didn't
|
||||
# change — e.g. when editing a single password), overlay the submitted
|
||||
# settings. Manifest-declared fields always appear in the final file.
|
||||
existing = _read_env(env) if env.exists() else {}
|
||||
merged: dict[str, str] = {}
|
||||
for s in m.settings:
|
||||
if s.name in settings:
|
||||
merged[s.name] = settings[s.name]
|
||||
elif s.name in existing:
|
||||
merged[s.name] = existing[s.name]
|
||||
elif s.default is not None:
|
||||
merged[s.name] = s.default
|
||||
else:
|
||||
merged[s.name] = ""
|
||||
# Preserve any non-manifest keys already in .env (forward-compat).
|
||||
for k, v in existing.items():
|
||||
if k not in merged:
|
||||
merged[k] = v
|
||||
# Required-field check runs on the merged view so that editing just
|
||||
# one field (e.g. password) doesn't trip on unsubmitted fields that
|
||||
# already have values in the existing .env.
|
||||
for s in m.settings:
|
||||
if s.required and not merged.get(s.name):
|
||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||
write_env(env, merged)
|
||||
elif not env.exists() and env_example.exists():
|
||||
# First install with no settings and no .env shipped: bootstrap from
|
||||
# .env.example so compose has values to substitute.
|
||||
shutil.copy2(env_example, env)
|
||||
|
||||
# .env carries app secrets — lock to root-only. Done before the placeholder
|
||||
|
|
@ -92,13 +154,84 @@ def install_from(src: Path) -> Path:
|
|||
if bad:
|
||||
raise InstallError(
|
||||
f"{m.name}: {env} still has placeholder values for {', '.join(bad)}. "
|
||||
f"Edit the file (set real values), then re-run "
|
||||
f"`furtka app install {m.name}`."
|
||||
f"Open the app in the Furtka UI to fill in real values, or edit the "
|
||||
f"file and re-run `furtka app install {m.name}`."
|
||||
)
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def _read_env(env_path: Path) -> dict[str, str]:
|
||||
"""Parse a simple KEY=VALUE .env into a dict. Unquotes quoted values."""
|
||||
out: dict[str, str] = {}
|
||||
for raw in env_path.read_text().splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
value = value.strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (
|
||||
value.startswith("'") and value.endswith("'")
|
||||
):
|
||||
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
out[key.strip()] = value
|
||||
return out
|
||||
|
||||
|
||||
def read_env_values(env_path: Path) -> dict[str, str]:
|
||||
"""Public wrapper — returns {} if the file doesn't exist."""
|
||||
if not env_path.exists():
|
||||
return {}
|
||||
return _read_env(env_path)
|
||||
|
||||
|
||||
def update_env(name: str, settings: dict[str, str]) -> Path:
|
||||
"""Merge `settings` into the installed app's .env.
|
||||
|
||||
Preserves values the user didn't submit. Validates required fields against
|
||||
the merged view. Leaves files/manifest untouched — for already-installed
|
||||
apps only. Returns the target folder; caller is expected to run
|
||||
reconcile to restart the containers.
|
||||
"""
|
||||
target = apps_dir() / name
|
||||
manifest_path = target / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
raise InstallError(f"{name!r} is not installed")
|
||||
try:
|
||||
m = load_manifest(manifest_path)
|
||||
except ManifestError as e:
|
||||
raise InstallError(str(e)) from e
|
||||
|
||||
declared = {s.name for s in m.settings}
|
||||
for key in settings:
|
||||
if key not in declared:
|
||||
raise InstallError(f"{m.name}: unknown setting {key!r}")
|
||||
|
||||
env = target / ".env"
|
||||
existing = _read_env(env) if env.exists() else {}
|
||||
merged: dict[str, str] = {}
|
||||
for s in m.settings:
|
||||
if s.name in settings:
|
||||
merged[s.name] = settings[s.name]
|
||||
elif s.name in existing:
|
||||
merged[s.name] = existing[s.name]
|
||||
elif s.default is not None:
|
||||
merged[s.name] = s.default
|
||||
else:
|
||||
merged[s.name] = ""
|
||||
for k, v in existing.items():
|
||||
if k not in merged:
|
||||
merged[k] = v
|
||||
for s in m.settings:
|
||||
if s.required and not merged.get(s.name):
|
||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||
write_env(env, merged)
|
||||
bad = _placeholder_keys(env)
|
||||
if bad:
|
||||
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
|
||||
return target
|
||||
|
||||
|
||||
def remove(name: str) -> Path:
|
||||
"""Delete /var/lib/furtka/apps/<name>/. Volumes are NOT touched.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REQUIRED_FIELDS = (
|
||||
|
|
@ -12,11 +13,24 @@ REQUIRED_FIELDS = (
|
|||
"icon",
|
||||
)
|
||||
|
||||
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
|
||||
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
|
||||
|
||||
|
||||
class ManifestError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Setting:
|
||||
name: str # env-var name, e.g. SMB_PASSWORD
|
||||
label: str # human label shown in the UI
|
||||
description: str # one-sentence help text under the input
|
||||
type: str # "text" | "password" | "number"
|
||||
required: bool
|
||||
default: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
name: str
|
||||
|
|
@ -26,6 +40,8 @@ class Manifest:
|
|||
volumes: tuple[str, ...]
|
||||
ports: tuple[int, ...]
|
||||
icon: str
|
||||
description_long: str = ""
|
||||
settings: tuple[Setting, ...] = field(default_factory=tuple)
|
||||
|
||||
def volume_name(self, short: str) -> str:
|
||||
# Namespace volume names so two apps can each declare e.g. "data"
|
||||
|
|
@ -35,6 +51,47 @@ class Manifest:
|
|||
return f"furtka_{self.name}_{short}"
|
||||
|
||||
|
||||
def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
|
||||
if raw is None:
|
||||
return ()
|
||||
if not isinstance(raw, list):
|
||||
raise ManifestError(f"{manifest_path}: settings must be a list")
|
||||
out: list[Setting] = []
|
||||
seen: set[str] = set()
|
||||
for i, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
raise ManifestError(f"{manifest_path}: settings[{i}] must be an object")
|
||||
name = item.get("name")
|
||||
if not isinstance(name, str) or not SETTING_NAME_RE.match(name):
|
||||
raise ManifestError(
|
||||
f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name"
|
||||
)
|
||||
if name in seen:
|
||||
raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}")
|
||||
seen.add(name)
|
||||
label = item.get("label", name)
|
||||
description = item.get("description", "")
|
||||
type_ = item.get("type", "text")
|
||||
if type_ not in VALID_SETTING_TYPES:
|
||||
valid = sorted(VALID_SETTING_TYPES)
|
||||
raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}")
|
||||
required = bool(item.get("required", False))
|
||||
default = item.get("default")
|
||||
if default is not None and not isinstance(default, str):
|
||||
default = str(default)
|
||||
out.append(
|
||||
Setting(
|
||||
name=name,
|
||||
label=str(label),
|
||||
description=str(description),
|
||||
type=type_,
|
||||
required=required,
|
||||
default=default,
|
||||
)
|
||||
)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
||||
"""Parse and validate a manifest.json.
|
||||
|
||||
|
|
@ -68,6 +125,8 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
|||
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
|
||||
raise ManifestError(f"{path}: ports must be a list of integers")
|
||||
|
||||
settings = _parse_settings(raw.get("settings"), path)
|
||||
|
||||
return Manifest(
|
||||
name=name,
|
||||
display_name=str(raw["display_name"]),
|
||||
|
|
@ -76,4 +135,6 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
|||
volumes=tuple(volumes),
|
||||
ports=tuple(ports),
|
||||
icon=str(raw["icon"]),
|
||||
description_long=str(raw.get("description_long", "")),
|
||||
settings=settings,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -153,3 +153,130 @@ def test_http_post_install_unknown_app(fake_dirs):
|
|||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
# --- Settings endpoints ------------------------------------------------------
|
||||
|
||||
SETTINGS_MANIFEST = dict(
|
||||
VALID_MANIFEST,
|
||||
description_long="Long help text.",
|
||||
settings=[
|
||||
{
|
||||
"name": "SMB_USER",
|
||||
"label": "User",
|
||||
"type": "text",
|
||||
"default": "furtka",
|
||||
"required": True,
|
||||
},
|
||||
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_get_settings_bundled(fake_dirs):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(
|
||||
bundled, "fileshare", manifest=SETTINGS_MANIFEST, env_example="SMB_USER=furtka\n"
|
||||
)
|
||||
status, body = api._do_get_settings("fileshare")
|
||||
assert status == 200
|
||||
assert body["installed"] is False
|
||||
assert body["description_long"] == "Long help text."
|
||||
names = [s["name"] for s in body["settings"]]
|
||||
assert names == ["SMB_USER", "SMB_PASSWORD"]
|
||||
# Password values never leak back.
|
||||
pwd = next(s for s in body["settings"] if s["name"] == "SMB_PASSWORD")
|
||||
assert pwd["value"] == ""
|
||||
# Text value comes from .env.example.
|
||||
user = next(s for s in body["settings"] if s["name"] == "SMB_USER")
|
||||
assert user["value"] == "furtka"
|
||||
|
||||
|
||||
def test_get_settings_not_found(fake_dirs):
|
||||
status, _ = api._do_get_settings("ghost")
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_install_with_settings_writes_env_via_api(fake_dirs, no_docker):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
status, body = api._do_install(
|
||||
"fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"}
|
||||
)
|
||||
assert status == 200, body
|
||||
apps, _ = fake_dirs
|
||||
env = (apps / "fileshare" / ".env").read_text()
|
||||
assert "SMB_USER=alice" in env
|
||||
assert "SMB_PASSWORD=s3cret" in env
|
||||
|
||||
|
||||
def test_install_with_settings_rejects_empty_required_via_api(fake_dirs, no_docker):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
status, body = api._do_install("fileshare", settings={"SMB_USER": "a", "SMB_PASSWORD": ""})
|
||||
assert status == 400
|
||||
assert "SMB_PASSWORD" in body["error"]
|
||||
|
||||
|
||||
def test_update_settings_merges(fake_dirs, no_docker):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
api._do_install("fileshare", settings={"SMB_USER": "alice", "SMB_PASSWORD": "original"})
|
||||
# Edit flow: submit only the changed password.
|
||||
status, body = api._do_update_settings("fileshare", {"SMB_PASSWORD": "newpass"})
|
||||
assert status == 200, body
|
||||
apps, _ = fake_dirs
|
||||
env = (apps / "fileshare" / ".env").read_text()
|
||||
assert "SMB_USER=alice" in env
|
||||
assert "SMB_PASSWORD=newpass" in env
|
||||
|
||||
|
||||
def test_update_settings_unknown_app(fake_dirs):
|
||||
status, _ = api._do_update_settings("ghost", {"SMB_USER": "x"})
|
||||
assert status == 404
|
||||
|
||||
|
||||
def test_http_get_settings_route(fake_dirs, no_docker):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps/fileshare/settings") as r:
|
||||
assert r.status == 200
|
||||
data = json.loads(r.read())
|
||||
assert data["name"] == "fileshare"
|
||||
assert len(data["settings"]) == 2
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_http_post_install_with_settings(fake_dirs, no_docker):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:{port}/api/apps/install",
|
||||
data=json.dumps(
|
||||
{
|
||||
"name": "fileshare",
|
||||
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
|
||||
}
|
||||
).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert r.status == 200
|
||||
apps, _ = fake_dirs
|
||||
assert "SMB_PASSWORD=s3cret" in (apps / "fileshare" / ".env").read_text()
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
|
|
|||
|
|
@ -189,3 +189,81 @@ def test_placeholder_check_handles_quoted_values(tmp_path, fake_dirs):
|
|||
)
|
||||
with pytest.raises(installer.InstallError, match="placeholder"):
|
||||
installer.install_from(src)
|
||||
|
||||
|
||||
# --- Settings-driven install -------------------------------------------------
|
||||
|
||||
SETTINGS_MANIFEST = dict(
|
||||
VALID_MANIFEST,
|
||||
settings=[
|
||||
{
|
||||
"name": "SMB_USER",
|
||||
"label": "User",
|
||||
"type": "text",
|
||||
"default": "furtka",
|
||||
"required": True,
|
||||
},
|
||||
{"name": "SMB_PASSWORD", "label": "Pass", "type": "password", "required": True},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_install_with_settings_writes_env(tmp_path, fake_dirs):
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
|
||||
target = apps_dir() / "fileshare"
|
||||
env = (target / ".env").read_text()
|
||||
assert "SMB_USER=daniel" in env
|
||||
assert "SMB_PASSWORD=hunter2" in env
|
||||
|
||||
|
||||
def test_install_with_settings_rejects_empty_required(tmp_path, fake_dirs):
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
# SMB_PASSWORD has no default and is required — submitting empty is rejected.
|
||||
with pytest.raises(installer.InstallError, match="'SMB_PASSWORD' is required"):
|
||||
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": ""})
|
||||
|
||||
|
||||
def test_install_with_settings_rejects_unknown_key(tmp_path, fake_dirs):
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
with pytest.raises(installer.InstallError, match="unknown setting 'FOO'"):
|
||||
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b", "FOO": "x"})
|
||||
|
||||
|
||||
def test_install_settings_merge_preserves_unchanged(tmp_path, fake_dirs):
|
||||
# First install with full settings.
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
installer.install_from(src, settings={"SMB_USER": "daniel", "SMB_PASSWORD": "hunter2"})
|
||||
# Second call with only password — user should keep existing user name.
|
||||
installer.install_from(src, settings={"SMB_PASSWORD": "newpass"})
|
||||
target = apps_dir() / "fileshare"
|
||||
env = (target / ".env").read_text()
|
||||
assert "SMB_USER=daniel" in env
|
||||
assert "SMB_PASSWORD=newpass" in env
|
||||
|
||||
|
||||
def test_install_settings_applies_defaults_on_first_install(tmp_path, fake_dirs):
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
# Only password submitted; SMB_USER falls through to its manifest default
|
||||
# ("furtka") and the required check passes because the merged view has it.
|
||||
installer.install_from(src, settings={"SMB_PASSWORD": "hunter2"})
|
||||
target = apps_dir() / "fileshare"
|
||||
env = (target / ".env").read_text()
|
||||
assert "SMB_USER=furtka" in env
|
||||
assert "SMB_PASSWORD=hunter2" in env
|
||||
|
||||
|
||||
def test_install_with_settings_writes_0600(tmp_path, fake_dirs):
|
||||
src = _write_app_source(tmp_path, "fileshare", SETTINGS_MANIFEST)
|
||||
installer.install_from(src, settings={"SMB_USER": "a", "SMB_PASSWORD": "b"})
|
||||
mode = (apps_dir() / "fileshare" / ".env").stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
|
||||
def test_read_env_values_roundtrip(tmp_path, fake_dirs):
|
||||
from furtka.installer import read_env_values, write_env
|
||||
|
||||
p = tmp_path / ".env"
|
||||
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
|
||||
values = read_env_values(p)
|
||||
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
|
||||
|
|
|
|||
|
|
@ -88,3 +88,70 @@ def test_ports_wrong_type(tmp_path):
|
|||
path = _write_app(tmp_path, "fileshare", bad)
|
||||
with pytest.raises(ManifestError, match="ports"):
|
||||
load_manifest(path)
|
||||
|
||||
|
||||
def test_settings_optional_default_empty(tmp_path):
|
||||
path = _write_app(tmp_path, "fileshare", VALID_MANIFEST)
|
||||
m = load_manifest(path)
|
||||
assert m.settings == ()
|
||||
assert m.description_long == ""
|
||||
|
||||
|
||||
def test_settings_parsed(tmp_path):
|
||||
payload = dict(
|
||||
VALID_MANIFEST,
|
||||
description_long="Long description with details.",
|
||||
settings=[
|
||||
{
|
||||
"name": "SMB_USER",
|
||||
"label": "Benutzername",
|
||||
"description": "Anmeldename",
|
||||
"type": "text",
|
||||
"default": "furtka",
|
||||
"required": True,
|
||||
},
|
||||
{"name": "SMB_PASSWORD", "label": "Passwort", "type": "password", "required": True},
|
||||
],
|
||||
)
|
||||
path = _write_app(tmp_path, "fileshare", payload)
|
||||
m = load_manifest(path)
|
||||
assert m.description_long == "Long description with details."
|
||||
assert len(m.settings) == 2
|
||||
assert m.settings[0].name == "SMB_USER"
|
||||
assert m.settings[0].label == "Benutzername"
|
||||
assert m.settings[0].default == "furtka"
|
||||
assert m.settings[0].type == "text"
|
||||
assert m.settings[0].required is True
|
||||
assert m.settings[1].type == "password"
|
||||
assert m.settings[1].default is None
|
||||
|
||||
|
||||
def test_settings_reject_lowercase_name(tmp_path):
|
||||
bad = dict(VALID_MANIFEST, settings=[{"name": "smb_user", "type": "text"}])
|
||||
path = _write_app(tmp_path, "fileshare", bad)
|
||||
with pytest.raises(ManifestError, match="UPPER_SNAKE_CASE"):
|
||||
load_manifest(path)
|
||||
|
||||
|
||||
def test_settings_reject_unknown_type(tmp_path):
|
||||
bad = dict(VALID_MANIFEST, settings=[{"name": "FOO", "type": "email"}])
|
||||
path = _write_app(tmp_path, "fileshare", bad)
|
||||
with pytest.raises(ManifestError, match="type must be one of"):
|
||||
load_manifest(path)
|
||||
|
||||
|
||||
def test_settings_reject_duplicate_name(tmp_path):
|
||||
bad = dict(
|
||||
VALID_MANIFEST,
|
||||
settings=[{"name": "FOO", "type": "text"}, {"name": "FOO", "type": "password"}],
|
||||
)
|
||||
path = _write_app(tmp_path, "fileshare", bad)
|
||||
with pytest.raises(ManifestError, match="duplicate"):
|
||||
load_manifest(path)
|
||||
|
||||
|
||||
def test_settings_non_list_rejected(tmp_path):
|
||||
bad = dict(VALID_MANIFEST, settings={"FOO": "bar"})
|
||||
path = _write_app(tmp_path, "fileshare", bad)
|
||||
with pytest.raises(ManifestError, match="settings must be a list"):
|
||||
load_manifest(path)
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ from flask import Flask, jsonify, redirect, render_template, request, url_for
|
|||
app = Flask(__name__)
|
||||
|
||||
LANGUAGES = {
|
||||
"en": {"locale": "en_US.UTF-8", "label": "English"},
|
||||
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch"},
|
||||
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski"},
|
||||
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
|
||||
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
|
||||
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski", "keyboard": "pl"},
|
||||
}
|
||||
|
||||
STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka"))
|
||||
|
|
@ -557,10 +557,18 @@ def build_archinstall_config(s):
|
|||
"packages": [
|
||||
"docker",
|
||||
"docker-compose",
|
||||
# Editors for console/SSH recovery — `nano` is the beginner-friendly
|
||||
# one, `vim` stays because it's muscle-memory for the dev team.
|
||||
"nano",
|
||||
"vim",
|
||||
"git",
|
||||
"htop",
|
||||
"curl",
|
||||
# Remote access — archinstall 4.x's `ssh: True` flag is flaky about
|
||||
# actually pulling in openssh, so list it explicitly and enable sshd
|
||||
# via `services` below. Without this, the documented recovery path
|
||||
# (SSH in → edit .env) doesn't work.
|
||||
"openssh",
|
||||
# Base OS post-install (landing page + mDNS on installed system).
|
||||
"caddy",
|
||||
"avahi",
|
||||
|
|
@ -578,6 +586,7 @@ def build_archinstall_config(s):
|
|||
# units (written in custom_commands) are enabled there instead.
|
||||
"caddy",
|
||||
"avahi-daemon",
|
||||
"sshd",
|
||||
],
|
||||
# `gpasswd -a <user> docker` has to stay first — adds the user to
|
||||
# the docker group once the group exists (archinstall creates users
|
||||
|
|
@ -592,7 +601,11 @@ def build_archinstall_config(s):
|
|||
"audio_config": None,
|
||||
"locale_config": {
|
||||
"locale": LANGUAGES[s["language"]]["locale"],
|
||||
"keyboard_layout": "us",
|
||||
# Keyboard layout follows the chosen language so a German user
|
||||
# doesn't get a US layout at the TTY console (where things like
|
||||
# `/`, `-`, `=` land on surprising keys and make even `sudo vim`
|
||||
# painful). `en` falls through to "us" which is what we want.
|
||||
"keyboard_layout": LANGUAGES[s["language"]]["keyboard"],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue