feat: add initial implementation of the frontend panel with app management features
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
8cb90be7cd
commit
e15298fd3d
5 changed files with 1547 additions and 557 deletions
312
panelctl.sh
312
panelctl.sh
|
|
@ -7,6 +7,7 @@ VOLUMES_DIR="${BASE_DIR}/volumes"
|
|||
ROUTES_DIR="${BASE_DIR}/routes"
|
||||
STATE_DIR="${BASE_DIR}/state"
|
||||
APPS_DIR="${STATE_DIR}/apps"
|
||||
BACKUPS_DIR="${BASE_DIR}/backups"
|
||||
|
||||
FORWARD_AUTH_BLOCK=' forward_auth 127.0.0.1:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
|
|
@ -19,17 +20,36 @@ usage() {
|
|||
panelctl - minimal app panel helper
|
||||
|
||||
Usage:
|
||||
panelctl init <name> <domain> <port> [auth]
|
||||
panelctl init <name> <domains> <port> [auth]
|
||||
panelctl render-route <name>
|
||||
panelctl deploy <name>
|
||||
panelctl restart <name>
|
||||
panelctl stop <name>
|
||||
panelctl status <name>
|
||||
panelctl logs <name> [--tail N]
|
||||
panelctl remove <name> [--keep-volumes]
|
||||
panelctl backup <name>
|
||||
panelctl list-backups <name>
|
||||
panelctl restore <name> <backup-file>
|
||||
panelctl validate-compose <name>
|
||||
panelctl list
|
||||
panelctl show <name>
|
||||
|
||||
Domains can be comma-separated for multiple domains:
|
||||
panelctl init myapp "app.example.com,www.example.com" 18080 true
|
||||
|
||||
Wildcard domains are supported (requires DNS challenge in Caddy):
|
||||
panelctl init myapp "*.example.com" 18080 true
|
||||
|
||||
Examples:
|
||||
panelctl init whoami whoami.srazka.com 18080 true
|
||||
panelctl deploy whoami
|
||||
panelctl restart whoami
|
||||
panelctl status whoami
|
||||
panelctl logs whoami --tail 50
|
||||
panelctl backup whoami
|
||||
panelctl list-backups whoami
|
||||
panelctl restore whoami whoami-20260101-120000.zip
|
||||
EOF
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +59,7 @@ fail() {
|
|||
}
|
||||
|
||||
ensure_base_dirs() {
|
||||
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}"
|
||||
mkdir -p "${STACKS_DIR}" "${VOLUMES_DIR}" "${ROUTES_DIR}" "${APPS_DIR}" "${BACKUPS_DIR}"
|
||||
}
|
||||
|
||||
validate_name() {
|
||||
|
|
@ -47,10 +67,25 @@ validate_name() {
|
|||
[[ "${name}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "invalid name '${name}' (use lowercase slug)"
|
||||
}
|
||||
|
||||
validate_domain() {
|
||||
validate_single_domain() {
|
||||
local domain="$1"
|
||||
[[ "${domain}" =~ ^[A-Za-z0-9.-]+$ ]] || fail "invalid domain '${domain}'"
|
||||
[[ "${domain}" == *.* ]] || fail "domain must include a dot"
|
||||
# Allow wildcard prefix *.
|
||||
local check="${domain}"
|
||||
if [[ "${check}" == \*.* ]]; then
|
||||
check="${check#\*.}"
|
||||
fi
|
||||
[[ "${check}" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || fail "invalid domain '${domain}'"
|
||||
[[ "${domain}" == *.* ]] || fail "domain '${domain}' must include a dot"
|
||||
}
|
||||
|
||||
validate_domains() {
|
||||
local domains_str="$1"
|
||||
IFS=',' read -ra domains <<< "${domains_str}"
|
||||
[[ ${#domains[@]} -ge 1 ]] || fail "at least one domain is required"
|
||||
for d in "${domains[@]}"; do
|
||||
d="$(echo "${d}" | xargs)" # trim whitespace
|
||||
validate_single_domain "${d}"
|
||||
done
|
||||
}
|
||||
|
||||
validate_port() {
|
||||
|
|
@ -74,8 +109,8 @@ app_volume_dir() {
|
|||
echo "${VOLUMES_DIR}/${name}"
|
||||
}
|
||||
|
||||
# All routes go into a single aggregate file that Caddy imports.
|
||||
app_route_file() {
|
||||
local name="$1"
|
||||
echo "${ROUTES_DIR}/routes.caddy"
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +175,6 @@ ensure_podman_runtime_env() {
|
|||
export DBUS_SESSION_BUS_ADDRESS
|
||||
fi
|
||||
|
||||
# Avoid inherited docker/podman remote host env from external callers.
|
||||
unset DOCKER_HOST
|
||||
unset CONTAINER_HOST
|
||||
}
|
||||
|
|
@ -182,7 +216,7 @@ EOF
|
|||
|
||||
write_manifest() {
|
||||
local name="$1"
|
||||
local domain="$2"
|
||||
local domains="$2"
|
||||
local port="$3"
|
||||
local auth="$4"
|
||||
local manifest
|
||||
|
|
@ -193,11 +227,17 @@ write_manifest() {
|
|||
manifest="$(app_manifest "${name}")"
|
||||
stack_dir="$(app_stack_dir "${name}")"
|
||||
volume_dir="$(app_volume_dir "${name}")"
|
||||
route_file="$(app_route_file "${name}")"
|
||||
route_file="$(app_route_file)"
|
||||
|
||||
# First domain is the primary (used for APP_DOMAIN backward compat)
|
||||
local primary_domain
|
||||
IFS=',' read -ra domain_arr <<< "${domains}"
|
||||
primary_domain="$(echo "${domain_arr[0]}" | xargs)"
|
||||
|
||||
cat >"${manifest}" <<EOF
|
||||
APP_NAME="${name}"
|
||||
APP_DOMAIN="${domain}"
|
||||
APP_DOMAIN="${primary_domain}"
|
||||
APP_DOMAINS="${domains}"
|
||||
APP_PORT="${port}"
|
||||
APP_UPSTREAM="127.0.0.1:${port}"
|
||||
APP_AUTH_PROTECTED="${auth}"
|
||||
|
|
@ -210,12 +250,12 @@ EOF
|
|||
|
||||
cmd_init() {
|
||||
local name="$1"
|
||||
local domain="$2"
|
||||
local domains="$2"
|
||||
local port="$3"
|
||||
local auth="${4:-true}"
|
||||
|
||||
validate_name "${name}"
|
||||
validate_domain "${domain}"
|
||||
validate_domains "${domains}"
|
||||
validate_port "${port}"
|
||||
[[ "${auth}" == "true" || "${auth}" == "false" ]] || fail "auth must be true or false"
|
||||
|
||||
|
|
@ -232,7 +272,7 @@ cmd_init() {
|
|||
|
||||
mkdir -p "${stack_dir}" "${volume_dir}/data"
|
||||
write_default_compose "${name}" "${port}"
|
||||
write_manifest "${name}" "${domain}" "${port}" "${auth}"
|
||||
write_manifest "${name}" "${domains}" "${port}" "${auth}"
|
||||
cmd_render_route "${name}"
|
||||
|
||||
echo "initialized app '${name}'"
|
||||
|
|
@ -248,21 +288,35 @@ cmd_render_route() {
|
|||
auth_block="${FORWARD_AUTH_BLOCK}"
|
||||
fi
|
||||
|
||||
# Append to aggregate routes file (idempotent: remove stale block first).
|
||||
# Build domain list for Caddy block header.
|
||||
local caddy_domains=""
|
||||
local domains_str="${APP_DOMAINS:-${APP_DOMAIN}}"
|
||||
IFS=',' read -ra domain_arr <<< "${domains_str}"
|
||||
for d in "${domain_arr[@]}"; do
|
||||
d="$(echo "${d}" | xargs)"
|
||||
if [[ -n "${caddy_domains}" ]]; then
|
||||
caddy_domains="${caddy_domains}, ${d}"
|
||||
else
|
||||
caddy_domains="${d}"
|
||||
fi
|
||||
done
|
||||
|
||||
local route_file
|
||||
route_file="$(app_route_file)"
|
||||
|
||||
# Strip any existing block for this app from the aggregate file.
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
|
||||
# Strip any existing block for this app from the aggregate file.
|
||||
if [[ -f "${APP_ROUTE_FILE}" ]]; then
|
||||
# Use sed to remove the block between # route:name:start and # route:name:end
|
||||
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true
|
||||
if [[ -f "${route_file}" ]]; then
|
||||
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
|
||||
else
|
||||
printf "" >"${tmp}"
|
||||
fi
|
||||
|
||||
{
|
||||
printf "# route:%s:start\n" "${name}"
|
||||
printf "%s {\n" "${APP_DOMAIN}"
|
||||
printf "%s {\n" "${caddy_domains}"
|
||||
if [[ -n "${auth_block}" ]]; then
|
||||
printf "%s\n" "${auth_block}"
|
||||
fi
|
||||
|
|
@ -271,8 +325,8 @@ cmd_render_route() {
|
|||
printf "# route:%s:end\n" "${name}"
|
||||
} >>"${tmp}"
|
||||
|
||||
mv "${tmp}" "${APP_ROUTE_FILE}"
|
||||
echo "rendered route ${APP_ROUTE_FILE}"
|
||||
mv "${tmp}" "${route_file}"
|
||||
echo "rendered route ${route_file}"
|
||||
}
|
||||
|
||||
maybe_reload_caddy() {
|
||||
|
|
@ -300,21 +354,37 @@ cmd_deploy() {
|
|||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info
|
||||
echo "Starting deployment for app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
|
||||
|
||||
cmd_render_route "${name}"
|
||||
|
||||
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info; then
|
||||
echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err
|
||||
|
||||
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1 | systemd-cat -t panelctl -p info 2>/dev/null; then
|
||||
echo "Deployment failed for app '${name}'" | systemd-cat -t panelctl -p err 2>/dev/null || true
|
||||
fail "compose up failed"
|
||||
fi
|
||||
|
||||
|
||||
maybe_reload_caddy
|
||||
|
||||
echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info
|
||||
echo "Successfully deployed app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
|
||||
echo "deployed app '${name}'"
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
echo "Restarting app '${name}'" | systemd-cat -t panelctl -p info 2>/dev/null || true
|
||||
|
||||
run_compose -f "${APP_COMPOSE_FILE}" down || fail "compose down failed"
|
||||
|
||||
if ! run_compose -f "${APP_COMPOSE_FILE}" up -d 2>&1; then
|
||||
fail "compose up failed during restart"
|
||||
fi
|
||||
|
||||
echo "restarted app '${name}'"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
|
|
@ -324,6 +394,52 @@ cmd_stop() {
|
|||
echo "stopped app '${name}'"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null || \
|
||||
run_compose -f "${APP_COMPOSE_FILE}" ps 2>/dev/null || \
|
||||
echo "no containers running"
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local name="$1"
|
||||
shift
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
local tail_lines="100"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tail)
|
||||
tail_lines="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
run_compose -f "${APP_COMPOSE_FILE}" logs --tail "${tail_lines}" 2>&1 || echo "no logs available"
|
||||
}
|
||||
|
||||
cmd_validate_compose() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
if run_compose -f "${APP_COMPOSE_FILE}" config >/dev/null 2>&1; then
|
||||
echo "compose file is valid"
|
||||
else
|
||||
local output
|
||||
output="$(run_compose -f "${APP_COMPOSE_FILE}" config 2>&1 || true)"
|
||||
fail "compose validation failed: ${output}"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
local name="$1"
|
||||
local keep_volumes="${2:-}"
|
||||
|
|
@ -333,11 +449,13 @@ cmd_remove() {
|
|||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
||||
|
||||
# Remove this app's block from the aggregate routes file.
|
||||
if [[ -f "${APP_ROUTE_FILE}" ]]; then
|
||||
local route_file
|
||||
route_file="$(app_route_file)"
|
||||
if [[ -f "${route_file}" ]]; then
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${APP_ROUTE_FILE}" >"${tmp}" || true
|
||||
mv "${tmp}" "${APP_ROUTE_FILE}"
|
||||
sed "/^# route:${name}:start$/,/^# route:${name}:end$/d" "${route_file}" >"${tmp}" || true
|
||||
mv "${tmp}" "${route_file}"
|
||||
fi
|
||||
|
||||
rm -f "$(app_manifest "${name}")"
|
||||
|
|
@ -351,6 +469,107 @@ cmd_remove() {
|
|||
echo "removed app '${name}'"
|
||||
}
|
||||
|
||||
cmd_backup() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
ensure_base_dirs
|
||||
|
||||
local volume_dir
|
||||
volume_dir="$(app_volume_dir "${name}")"
|
||||
[[ -d "${volume_dir}" ]] || fail "volume directory '${volume_dir}' does not exist"
|
||||
|
||||
local timestamp
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
local backup_file="${BACKUPS_DIR}/${name}-${timestamp}.zip"
|
||||
|
||||
# Stop containers before backup for consistency
|
||||
local was_running=false
|
||||
if run_compose -f "${APP_COMPOSE_FILE}" ps --format json 2>/dev/null | grep -q '"running"' 2>/dev/null; then
|
||||
was_running=true
|
||||
echo "stopping containers for consistent backup..."
|
||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
||||
fi
|
||||
|
||||
(cd "${volume_dir}" && zip -r "${backup_file}" .) || fail "zip failed"
|
||||
|
||||
# Also include the compose file in the backup
|
||||
local stack_dir
|
||||
stack_dir="$(app_stack_dir "${name}")"
|
||||
if [[ -f "${stack_dir}/compose.yaml" ]]; then
|
||||
(cd "${stack_dir}" && zip -j "${backup_file}" compose.yaml) || true
|
||||
fi
|
||||
|
||||
# Restart if it was running
|
||||
if [[ "${was_running}" == "true" ]]; then
|
||||
echo "restarting containers after backup..."
|
||||
run_compose -f "${APP_COMPOSE_FILE}" up -d 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local size
|
||||
size="$(du -h "${backup_file}" | cut -f1)"
|
||||
echo "backup created: ${backup_file} (${size})"
|
||||
}
|
||||
|
||||
cmd_list_backups() {
|
||||
local name="$1"
|
||||
validate_name "${name}"
|
||||
|
||||
ensure_base_dirs
|
||||
|
||||
local found=0
|
||||
for bf in "${BACKUPS_DIR}/${name}"-*.zip; do
|
||||
[[ -e "${bf}" ]] || continue
|
||||
found=1
|
||||
local fname size mtime
|
||||
fname="$(basename "${bf}")"
|
||||
size="$(du -h "${bf}" | cut -f1)"
|
||||
mtime="$(stat -c '%Y' "${bf}" 2>/dev/null || stat -f '%m' "${bf}" 2>/dev/null || echo "0")"
|
||||
echo "${fname} ${size} ${mtime}"
|
||||
done
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
echo "no backups found for '${name}'"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_restore() {
|
||||
local name="$1"
|
||||
local backup_file="$2"
|
||||
validate_name "${name}"
|
||||
load_app "${name}"
|
||||
|
||||
# Resolve backup file path
|
||||
local full_path="${backup_file}"
|
||||
if [[ ! -f "${full_path}" ]]; then
|
||||
full_path="${BACKUPS_DIR}/${backup_file}"
|
||||
fi
|
||||
[[ -f "${full_path}" ]] || fail "backup file '${backup_file}' not found"
|
||||
|
||||
# Ensure it's a zip file within the backups directory
|
||||
local norm_path
|
||||
norm_path="$(realpath "${full_path}")"
|
||||
local norm_backups
|
||||
norm_backups="$(realpath "${BACKUPS_DIR}")"
|
||||
[[ "${norm_path}" == "${norm_backups}"/* ]] || fail "backup file must be in the backups directory"
|
||||
|
||||
local volume_dir
|
||||
volume_dir="$(app_volume_dir "${name}")"
|
||||
|
||||
# Stop containers before restore
|
||||
echo "stopping containers for restore..."
|
||||
run_compose -f "${APP_COMPOSE_FILE}" down 2>/dev/null || true
|
||||
|
||||
# Clear existing volume data and extract backup
|
||||
rm -rf "${volume_dir:?}"/*
|
||||
mkdir -p "${volume_dir}"
|
||||
(cd "${volume_dir}" && unzip -o "${norm_path}") || fail "unzip failed"
|
||||
|
||||
echo "restored '${name}' from $(basename "${norm_path}")"
|
||||
echo "run 'panelctl deploy ${name}' to start the app"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
ensure_base_dirs
|
||||
local found=0
|
||||
|
|
@ -359,7 +578,8 @@ cmd_list() {
|
|||
found=1
|
||||
# shellcheck disable=SC1090
|
||||
source "${mf}"
|
||||
echo "${APP_NAME} ${APP_DOMAIN} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
|
||||
local domains="${APP_DOMAINS:-${APP_DOMAIN}}"
|
||||
echo "${APP_NAME} ${domains} ${APP_UPSTREAM} auth=${APP_AUTH_PROTECTED}"
|
||||
done
|
||||
|
||||
if [[ "${found}" -eq 0 ]]; then
|
||||
|
|
@ -381,7 +601,7 @@ main() {
|
|||
|
||||
case "${cmd}" in
|
||||
init)
|
||||
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domain> <port> [auth]"
|
||||
[[ $# -ge 4 ]] || fail "usage: panelctl init <name> <domains> <port> [auth]"
|
||||
cmd_init "$2" "$3" "$4" "${5:-true}"
|
||||
;;
|
||||
render-route)
|
||||
|
|
@ -392,14 +612,42 @@ main() {
|
|||
[[ $# -eq 2 ]] || fail "usage: panelctl deploy <name>"
|
||||
cmd_deploy "$2"
|
||||
;;
|
||||
restart)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl restart <name>"
|
||||
cmd_restart "$2"
|
||||
;;
|
||||
stop)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl stop <name>"
|
||||
cmd_stop "$2"
|
||||
;;
|
||||
status)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl status <name>"
|
||||
cmd_status "$2"
|
||||
;;
|
||||
logs)
|
||||
[[ $# -ge 2 ]] || fail "usage: panelctl logs <name> [--tail N]"
|
||||
cmd_logs "$2" "${@:3}"
|
||||
;;
|
||||
validate-compose)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl validate-compose <name>"
|
||||
cmd_validate_compose "$2"
|
||||
;;
|
||||
remove)
|
||||
[[ $# -ge 2 ]] || fail "usage: panelctl remove <name> [--keep-volumes]"
|
||||
cmd_remove "$2" "${3:-}"
|
||||
;;
|
||||
backup)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl backup <name>"
|
||||
cmd_backup "$2"
|
||||
;;
|
||||
list-backups)
|
||||
[[ $# -eq 2 ]] || fail "usage: panelctl list-backups <name>"
|
||||
cmd_list_backups "$2"
|
||||
;;
|
||||
restore)
|
||||
[[ $# -eq 3 ]] || fail "usage: panelctl restore <name> <backup-file>"
|
||||
cmd_restore "$2" "$3"
|
||||
;;
|
||||
list)
|
||||
[[ $# -eq 1 ]] || fail "usage: panelctl list"
|
||||
cmd_list
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue