Compare commits

..

2 Commits

4 changed files with 191 additions and 31 deletions

View File

@@ -335,9 +335,44 @@ def api_backups():
return jsonify({"backups": list_backups(), "backups_dir": str(BACKUPS_DIR)}) return jsonify({"backups": list_backups(), "backups_dir": str(BACKUPS_DIR)})
@app.route("/api/backups/upload", methods=["POST"])
def api_backups_upload():
"""Upload an image file from the dashboard (multipart form)."""
if "file" not in request.files and "image" not in request.files:
return jsonify({"ok": False, "error": "no file in request (use field 'file' or 'image')"}), 400
f = request.files.get("file") or request.files.get("image")
if not f or not f.filename:
return jsonify({"ok": False, "error": "no file selected"}), 400
base = (f.filename.rsplit(".", 1)[0] if "." in f.filename else f.filename).strip() or "upload"
safe_base = re.sub(r"[^\w\-.]", "_", base)[:80]
if not safe_base:
safe_base = "upload"
ext = ""
if f.filename.lower().endswith(".img.xz"):
ext = ".img.xz"
elif f.filename.lower().endswith(".img.gz"):
ext = ".img.gz"
elif f.filename.lower().endswith(".img"):
ext = ".img"
else:
ext = ".img"
name = f"{safe_base}-{int(time.time())}{ext}"
if not _safe_backup_name(name):
name = f"upload-{int(time.time())}.img"
path = BACKUPS_DIR / name
try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
f.save(str(path))
return jsonify({"ok": True, "name": name, "message": f"Uploaded {name}"})
except (OSError, IOError) as e:
if path.exists():
path.unlink(missing_ok=True)
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>/set-as-golden", methods=["POST"]) @app.route("/api/backups/<path:name>/set-as-golden", methods=["POST"])
def api_backup_set_as_golden(name): def api_backup_set_as_golden(name):
"""Copy this backup to golden.img so it becomes the image used for Deploy.""" """Use this backup as the golden image (symlink when in backups dir to avoid copying)."""
if not _safe_backup_name(name): if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400 return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name path = BACKUPS_DIR / name
@@ -345,6 +380,16 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": "backup not found"}), 404 return jsonify({"ok": False, "error": "backup not found"}), 404
try: try:
BACKUPS_DIR.mkdir(parents=True, exist_ok=True) BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
GOLDEN_IMAGE.parent.mkdir(parents=True, exist_ok=True)
if GOLDEN_IMAGE.exists():
GOLDEN_IMAGE.unlink()
# Symlink to the backup so we use the same file instead of duplicating
path_resolved = path.resolve()
try:
path_resolved.relative_to(BACKUPS_DIR.resolve())
os.symlink(path_resolved, GOLDEN_IMAGE)
except ValueError:
# Backup not under BACKUPS_DIR (shouldn't happen for list); fall back to copy
shutil.copy2(path, GOLDEN_IMAGE) shutil.copy2(path, GOLDEN_IMAGE)
return jsonify({"ok": True, "message": f"Golden image set from {name}"}) return jsonify({"ok": True, "message": f"Golden image set from {name}"})
except (OSError, IOError) as e: except (OSError, IOError) as e:
@@ -458,6 +503,30 @@ def api_backup_update(name):
return jsonify({"ok": True, "name": name}) return jsonify({"ok": True, "name": name})
@app.route("/api/backups/<path:name>", methods=["DELETE"])
def api_backup_delete(name):
"""Delete a backup file. If it is the current golden image (symlink), the golden link is removed."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "backup not found"}), 404
try:
if GOLDEN_IMAGE.exists() and GOLDEN_IMAGE.is_symlink():
try:
if (GOLDEN_IMAGE.resolve() == path.resolve()):
GOLDEN_IMAGE.unlink()
except OSError:
pass
path.unlink()
meta = _load_backups_meta()
meta.pop(name, None)
_save_backups_meta(meta)
return jsonify({"ok": True, "message": f"Deleted {name}"})
except (OSError, IOError) as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>", methods=["GET"]) @app.route("/api/backups/<path:name>", methods=["GET"])
def api_backup_download(name): def api_backup_download(name):
if not _safe_backup_name(name): if not _safe_backup_name(name):

View File

@@ -399,7 +399,11 @@
<!-- 3. Saved backups --> <!-- 3. Saved backups -->
<section class="section"> <section class="section">
<h2 class="section-title">Saved backups <button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn" title="Reload list">Refresh</button></h2> <h2 class="section-title">Saved backups
<button type="button" class="btn btn-outline btn-sm" id="refreshBackupsBtn" title="Reload list">Refresh</button>
<button type="button" class="btn btn-outline btn-sm" id="uploadImageBtn" title="Upload an image file">Upload image</button>
<input type="file" id="uploadImageInput" accept=".img,.img.gz,.img.xz,image/*" style="display:none;" />
</h2>
<p id="goldenHint" class="backups-mono" style="margin-bottom:0.25rem;font-size:0.8rem;"></p> <p id="goldenHint" class="backups-mono" style="margin-bottom:0.25rem;font-size:0.8rem;"></p>
<p id="backupsDirHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.75rem;color:var(--muted);"></p> <p id="backupsDirHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.75rem;color:var(--muted);"></p>
<table class="backups-table" id="backupsTable"> <table class="backups-table" id="backupsTable">
@@ -618,6 +622,7 @@
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' + '<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' + '<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a> ' + '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a> ' +
'<button type="button" class="btn btn-outline btn-sm delete-backup-btn" data-name="' + escapeHtml(b.name) + '" title="Delete this backup">Delete</button>' +
'</td>'; '</td>';
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -626,6 +631,25 @@
bindRenameFile(); bindRenameFile();
bindShrink(); bindShrink();
bindCompress(); bindCompress();
bindDeleteBackup();
}
function bindDeleteBackup() {
document.querySelectorAll('.delete-backup-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Delete this backup? This cannot be undone.\n\n' + name)) return;
btn.disabled = true;
fetch('/api/backups/' + encodeURIComponent(name), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); fetchGoldenInfo(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; });
};
});
} }
function bindRenameFile() { function bindRenameFile() {
@@ -901,6 +925,26 @@
}); });
var refreshBackupsBtn = document.getElementById('refreshBackupsBtn'); var refreshBackupsBtn = document.getElementById('refreshBackupsBtn');
if (refreshBackupsBtn) refreshBackupsBtn.onclick = function() { fetchBackups(); fetchGoldenInfo(); }; if (refreshBackupsBtn) refreshBackupsBtn.onclick = function() { fetchBackups(); fetchGoldenInfo(); };
var uploadImageBtn = document.getElementById('uploadImageBtn');
var uploadImageInput = document.getElementById('uploadImageInput');
if (uploadImageBtn && uploadImageInput) {
uploadImageBtn.onclick = function() { uploadImageInput.click(); };
uploadImageInput.onchange = function() {
var file = uploadImageInput.files && uploadImageInput.files[0];
if (!file) return;
var fd = new FormData();
fd.append('file', file);
uploadImageBtn.disabled = true;
fetch('/api/backups/upload', { method: 'POST', body: fd })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) { fetchBackups(); fetchGoldenInfo(); alert('Uploaded: ' + (d.name || file.name)); }
else alert(d.error || 'Upload failed');
})
.catch(function() { alert('Upload failed'); })
.finally(function() { uploadImageBtn.disabled = false; uploadImageInput.value = ''; });
};
}
fetchStatus(); fetchStatus();
fetchLog(); fetchLog();

View File

@@ -87,7 +87,8 @@ mkdir -p "$BACKUPS_DIR"
cp "$IMG_FILE" "$OUT_PATH" cp "$IMG_FILE" "$OUT_PATH"
if [[ "$SET_AS_GOLDEN" == "1" ]]; then if [[ "$SET_AS_GOLDEN" == "1" ]]; then
cp "$OUT_PATH" "$GOLDEN_IMAGE" rm -f "$GOLDEN_IMAGE"
ln -sf "$OUT_PATH" "$GOLDEN_IMAGE"
write_status "done" "Built $OUT_NAME and set as golden image." "$OUT_NAME" "" write_status "done" "Built $OUT_NAME and set as golden image." "$OUT_NAME" ""
else else
write_status "done" "Built $OUT_NAME" "$OUT_NAME" "" write_status "done" "Built $OUT_NAME" "$OUT_NAME" ""

View File

@@ -10,7 +10,7 @@
# Example: ./deploy-to-proxmox.sh root@10.20.30.152 # Example: ./deploy-to-proxmox.sh root@10.20.30.152
# #
# Optional env: # Optional env:
# DEPLOY_ROOTFS_STORAGE=name — LXC rootfs storage (default: auto-detect: local-lvm, local, local-zfs, or first active) # DEPLOY_ROOTFS_STORAGE=name — LXC rootfs storage (if set and valid, skips interactive choice; otherwise script lists storages and asks for number)
# CM4_BACKUPS_HOST_PATH=/path — host dir for backups; bind-mounted into LXC # CM4_BACKUPS_HOST_PATH=/path — host dir for backups; bind-mounted into LXC
# DEPLOY_LXC_ROOT_PASSWORD=secret — set root password in LXC and enable SSH # DEPLOY_LXC_ROOT_PASSWORD=secret — set root password in LXC and enable SSH
# DEPLOY_LXC_SSH_KEY=/path/to/pub — copy this key to LXC root (default: ~/.ssh/id_ed25519.pub or id_rsa.pub) # DEPLOY_LXC_SSH_KEY=/path/to/pub — copy this key to LXC root (default: ~/.ssh/id_ed25519.pub or id_rsa.pub)
@@ -20,7 +20,7 @@
set -e set -e
PROXMOX="${1:-root@10.130.60.224}" PROXMOX="${1:-root@10.130.60.224}"
ROOTFS_STORAGE="${DEPLOY_ROOTFS_STORAGE:-local-lvm}" # ROOTFS_STORAGE set later: from DEPLOY_ROOTFS_STORAGE (if in host list) or from interactive choice
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")" REPO_DIR="$(dirname "$SCRIPT_DIR")"
LOG_FILE="" LOG_FILE=""
@@ -33,10 +33,16 @@ fi
log() { echo "[$(date -Iseconds)] $*"; } log() { echo "[$(date -Iseconds)] $*"; }
# Optional: gather SSH key and LXC root password for setup inside deploy # Optional: gather SSH key and LXC root password for setup inside deploy
# Default LXC root password (base64); override with DEPLOY_LXC_ROOT_PASSWORD. Not secure if repo is shared.
DEFAULT_LXC_PWD_B64="c2gxcGIweDE="
DEPLOY_SSH_KEY_B64="" DEPLOY_SSH_KEY_B64=""
DEPLOY_LXC_PWD_B64="" DEPLOY_LXC_PWD_B64=""
if [[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" ]]; then if [[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" ]]; then
DEPLOY_LXC_PWD_B64=$(echo -n "$DEPLOY_LXC_ROOT_PASSWORD" | base64 -w 0 2>/dev/null || base64 2>/dev/null | tr -d '\n') DEPLOY_LXC_PWD_B64=$(echo -n "$DEPLOY_LXC_ROOT_PASSWORD" | base64 -w 0 2>/dev/null || base64 2>/dev/null | tr -d '\n')
log "Will set LXC root password (from DEPLOY_LXC_ROOT_PASSWORD)."
else
DEPLOY_LXC_PWD_B64="$DEFAULT_LXC_PWD_B64"
log "Will set LXC root password (default)."
fi fi
KEY_FILE="${DEPLOY_LXC_SSH_KEY:-}" KEY_FILE="${DEPLOY_LXC_SSH_KEY:-}"
if [[ -z "$KEY_FILE" ]]; then if [[ -z "$KEY_FILE" ]]; then
@@ -48,41 +54,73 @@ if [[ -n "$KEY_FILE" && -f "$KEY_FILE" ]]; then
DEPLOY_SSH_KEY_B64=$(base64 -w 0 < "$KEY_FILE" 2>/dev/null || base64 < "$KEY_FILE" 2>/dev/null | tr -d '\n') DEPLOY_SSH_KEY_B64=$(base64 -w 0 < "$KEY_FILE" 2>/dev/null || base64 < "$KEY_FILE" 2>/dev/null | tr -d '\n')
log "Will copy SSH key to LXC: $KEY_FILE" log "Will copy SSH key to LXC: $KEY_FILE"
fi fi
[[ -n "$DEPLOY_LXC_ROOT_PASSWORD" ]] && log "Will set LXC root password (DEPLOY_LXC_ROOT_PASSWORD)."
log "Deploying to $PROXMOX ..." log "Deploying to $PROXMOX ..."
log "[1/5] Cleaning remote staging dir ..." log "[1/5] Cleaning remote staging dir ..."
ssh "$PROXMOX" "rm -rf /tmp/emmc-provisioning-deploy" ssh "$PROXMOX" "rm -rf /tmp/emmc-provisioning-deploy"
log "[2/5] Rsync repo to $PROXMOX ..." log "[2/5] Rsync repo to $PROXMOX ..."
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh' --exclude='scripts/deploy-*.log' rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh' --exclude='scripts/deploy-*.log'
# --- Ask user to select LXC rootfs storage (from host; PBS excluded) ---
# Normalize: trim leading spaces so first column is storage name (pvesm can align columns)
STORAGE_LIST=$(ssh "$PROXMOX" "pvesm status 2>/dev/null | awk 'NR>1 { sub(/^[ \t]+/, \"\"); if (\$2!=\"pbs\" && \$3~/active/) print \$1 }'" 2>/dev/null || true)
storages=()
while IFS= read -r line; do [[ -n "$line" ]] && storages+=("$line"); done <<< "$STORAGE_LIST"
if [[ ${#storages[@]} -eq 0 ]]; then
log "Error: no Proxmox storage found on host (PBS excluded). Run on host: pvesm status"
exit 1
fi
# If DEPLOY_ROOTFS_STORAGE is set and in the list, use it without prompting
if [[ -n "${DEPLOY_ROOTFS_STORAGE:-}" ]]; then
for s in "${storages[@]}"; do
if [[ "$s" == "$DEPLOY_ROOTFS_STORAGE" ]]; then
ROOTFS_STORAGE="$DEPLOY_ROOTFS_STORAGE"
log "Using storage: $ROOTFS_STORAGE (from DEPLOY_ROOTFS_STORAGE)"
break
fi
done
fi
if [[ -z "${ROOTFS_STORAGE:-}" ]]; then
echo ""
echo "Available storage on $PROXMOX (PBS excluded):"
for i in "${!storages[@]}"; do echo " $((i+1))) ${storages[i]}"; done
echo ""
if [[ ${#storages[@]} -eq 1 ]]; then
ROOTFS_STORAGE="${storages[0]}"
log "Using only available storage: $ROOTFS_STORAGE"
elif [[ ! -t 0 ]]; then
ROOTFS_STORAGE="${storages[0]}"
log "No TTY: using first storage $ROOTFS_STORAGE"
else
while true; do
read -r -p "Select storage (1-${#storages[@]}): " num
if [[ "$num" =~ ^[0-9]+$ ]] && [[ "$num" -ge 1 ]] && [[ "$num" -le ${#storages[@]} ]]; then
ROOTFS_STORAGE="${storages[num-1]}"
log "Using storage: $ROOTFS_STORAGE"
break
fi
echo "Invalid choice. Enter a number from 1 to ${#storages[@]}."
done
fi
fi
log "[3/5] Running remote install (host + LXC) ..." log "[3/5] Running remote install (host + LXC) ..."
# Pass optional LXC SSH vars (base64) so remote can set password and add key # Pass optional LXC SSH vars (base64) and selected storage
ssh "$PROXMOX" "ROOTFS_STORAGE='$ROOTFS_STORAGE' CM4_BACKUPS_HOST_PATH='${CM4_BACKUPS_HOST_PATH:-}' DEPLOY_SSH_KEY_B64='${DEPLOY_SSH_KEY_B64:-}' DEPLOY_LXC_PWD_B64='${DEPLOY_LXC_PWD_B64:-}'" bash -s << 'REMOTE' ssh "$PROXMOX" "ROOTFS_STORAGE='$ROOTFS_STORAGE' CM4_BACKUPS_HOST_PATH='${CM4_BACKUPS_HOST_PATH:-}' DEPLOY_SSH_KEY_B64='${DEPLOY_SSH_KEY_B64:-}' DEPLOY_LXC_PWD_B64='${DEPLOY_LXC_PWD_B64:-}'" bash -s << 'REMOTE'
set -e set -e
DEPLOY=/tmp/emmc-provisioning-deploy DEPLOY=/tmp/emmc-provisioning-deploy
ROOTFS_STORAGE="${ROOTFS_STORAGE:?ROOTFS_STORAGE not set}"
BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}" BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}"
LXC_HOSTNAME="cm4-provisioning" LXC_HOSTNAME="cm4-provisioning"
log() { echo "[$(date -Iseconds)] $*"; } log() { echo "[$(date -Iseconds)] $*"; }
# --- Auto-select LXC rootfs storage if not set or not available --- # Storage already chosen interactively; validate it exists on host (match by name + active, robust to column alignment)
_storage_exists() { pvesm status 2>/dev/null | awk -v s="$1" 'NR>1 && $1==s && $3=="active" {exit(0)} END{exit(1)}'; } if ! pvesm status 2>/dev/null | grep -w "$ROOTFS_STORAGE" | grep -q active; then
if [[ -n "${ROOTFS_STORAGE:-}" ]] && _storage_exists "$ROOTFS_STORAGE"; then log "Error: storage $ROOTFS_STORAGE not found or not valid on host"
: # use provided and existing storage exit 1
else
ROOTFS_STORAGE=""
for cand in local-lvm local local-zfs; do
if _storage_exists "$cand"; then
ROOTFS_STORAGE="$cand"
break
fi fi
done
if [[ -z "$ROOTFS_STORAGE" ]]; then
ROOTFS_STORAGE=$(pvesm status 2>/dev/null | awk 'NR>1 && $3=="active" {print $1; exit}')
fi
[[ -z "$ROOTFS_STORAGE" ]] && { log "Error: no Proxmox storage found. Run: pvesm status"; exit 1; }
log "Using storage: $ROOTFS_STORAGE" log "Using storage: $ROOTFS_STORAGE"
fi
# --- Find existing LXC by hostname or use next available ID --- # --- Find existing LXC by hostname or use next available ID ---
CTID="" CTID=""
@@ -221,26 +259,34 @@ if [[ -n "${DEPLOY_SSH_KEY_B64:-}" ]] || [[ -n "${DEPLOY_LXC_PWD_B64:-}" ]]; the
echo "$DEPLOY_SSH_KEY_B64" | base64 -d 2>/dev/null | pct exec "$CTID" -- bash -c "mkdir -p /root/.ssh; chmod 700 /root/.ssh; cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys" 2>/dev/null && log "LXC: SSH key added to /root/.ssh/authorized_keys." || true echo "$DEPLOY_SSH_KEY_B64" | base64 -d 2>/dev/null | pct exec "$CTID" -- bash -c "mkdir -p /root/.ssh; chmod 700 /root/.ssh; cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys" 2>/dev/null && log "LXC: SSH key added to /root/.ssh/authorized_keys." || true
fi fi
LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}') LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
[[ -n "$LXC_IP" ]] && echo "LXC SSH: ssh root@$LXC_IP" [[ -n "$LXC_IP" ]] && log "LXC SSH: ssh root@$LXC_IP"
fi fi
# Always capture LXC IP for final summary (write so local script can read it)
LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}')
echo "${LXC_IP:-}" > "$DEPLOY/lxc_ip.txt"
log "Deploy done (remote). LXC ID: $CTID" log "Deploy done on remote. LXC ID: $CTID"
REMOTE REMOTE
# Read LXC IP written by remote (container hostname -I)
LXC_IP=$(ssh "$PROXMOX" "cat /tmp/emmc-provisioning-deploy/lxc_ip.txt 2>/dev/null" | tr -d '\n\r')
log "[5/5] Deploy finished." log "[5/5] Deploy finished."
echo "" echo ""
echo "=== Deploy complete ===" echo "=== Deploy complete ==="
echo "Host and LXC are fully set up: usbboot (rpiboot), PiShrink, dashboard, systemd, udev." echo "Host and LXC are fully set up: usbboot (rpiboot), PiShrink, dashboard, systemd, udev."
[[ -n "$LXC_IP" ]] && echo " LXC IP: $LXC_IP"
echo "" echo ""
echo "--- Only remaining step (manual) ---" echo "--- Only remaining step (manual) ---"
echo " Add a golden image for Deploy (writing image to device):" echo " Add a golden image for Deploy (writing image to device):"
echo " • Dashboard: open http://<LXC-IP>:5000 → Build cloud-init image → then Set as golden" echo " • Dashboard: open http://${LXC_IP:-<LXC-IP>}:5000 → Build cloud-init image → then Set as golden"
echo " • Or copy your image: scp your-image.img $PROXMOX:/var/lib/cm4-provisioning/golden.img" echo " • Or copy your image: scp your-image.img $PROXMOX:/var/lib/cm4-provisioning/golden.img"
echo " Backup (read from device) works without golden.img." echo " Backup (read from device) works without golden.img."
echo "" echo ""
echo "--- You have ---" echo "--- You have ---"
echo " - Dashboard: http://<LXC-IP>:5000 (LXC IP: from Proxmox UI, or on host: pct exec <CTID> -- hostname -I)" echo " - Dashboard: http://${LXC_IP:-<LXC-IP>}:5000"
[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && echo " - LXC SSH: ssh root@<LXC-IP> (password and/or key were set)" [[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -n "$LXC_IP" ]] && echo " - LXC SSH: ssh root@$LXC_IP (password and/or key were set)"
[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -z "$LXC_IP" ]] && echo " - LXC SSH: ssh root@<LXC-IP> (password and/or key were set)"
[[ -n "${CM4_BACKUPS_HOST_PATH:-}" ]] && echo " - Backups on host: $CM4_BACKUPS_HOST_PATH" [[ -n "${CM4_BACKUPS_HOST_PATH:-}" ]] && echo " - Backups on host: $CM4_BACKUPS_HOST_PATH"
if [[ -n "$LOG_FILE" ]]; then if [[ -n "$LOG_FILE" ]]; then
echo " - Log: $LOG_FILE" echo " - Log: $LOG_FILE"