Compare commits
2 Commits
d12c7af664
...
01a9f61ca5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a9f61ca5 | ||
|
|
41b7e95c96 |
@@ -335,9 +335,44 @@ def api_backups():
|
||||
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"])
|
||||
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):
|
||||
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
||||
path = BACKUPS_DIR / name
|
||||
@@ -345,6 +380,16 @@ def api_backup_set_as_golden(name):
|
||||
return jsonify({"ok": False, "error": "backup not found"}), 404
|
||||
try:
|
||||
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)
|
||||
return jsonify({"ok": True, "message": f"Golden image set from {name}"})
|
||||
except (OSError, IOError) as e:
|
||||
@@ -458,6 +503,30 @@ def api_backup_update(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"])
|
||||
def api_backup_download(name):
|
||||
if not _safe_backup_name(name):
|
||||
|
||||
@@ -399,7 +399,11 @@
|
||||
|
||||
<!-- 3. Saved backups -->
|
||||
<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="backupsDirHint" class="backups-mono" style="margin-bottom:0.75rem;font-size:0.75rem;color:var(--muted);"></p>
|
||||
<table class="backups-table" id="backupsTable">
|
||||
@@ -617,7 +621,8 @@
|
||||
compressBtn +
|
||||
'<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> ' +
|
||||
'<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>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -626,6 +631,25 @@
|
||||
bindRenameFile();
|
||||
bindShrink();
|
||||
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() {
|
||||
@@ -901,6 +925,26 @@
|
||||
});
|
||||
var refreshBackupsBtn = document.getElementById('refreshBackupsBtn');
|
||||
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();
|
||||
fetchLog();
|
||||
|
||||
@@ -87,7 +87,8 @@ mkdir -p "$BACKUPS_DIR"
|
||||
cp "$IMG_FILE" "$OUT_PATH"
|
||||
|
||||
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" ""
|
||||
else
|
||||
write_status "done" "Built $OUT_NAME" "$OUT_NAME" ""
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
# Example: ./deploy-to-proxmox.sh root@10.20.30.152
|
||||
#
|
||||
# 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
|
||||
# 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)
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
set -e
|
||||
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)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
LOG_FILE=""
|
||||
@@ -33,10 +33,16 @@ fi
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
|
||||
# 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_LXC_PWD_B64=""
|
||||
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')
|
||||
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
|
||||
KEY_FILE="${DEPLOY_LXC_SSH_KEY:-}"
|
||||
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')
|
||||
log "Will copy SSH key to LXC: $KEY_FILE"
|
||||
fi
|
||||
[[ -n "$DEPLOY_LXC_ROOT_PASSWORD" ]] && log "Will set LXC root password (DEPLOY_LXC_ROOT_PASSWORD)."
|
||||
|
||||
log "Deploying to $PROXMOX ..."
|
||||
log "[1/5] Cleaning remote staging dir ..."
|
||||
ssh "$PROXMOX" "rm -rf /tmp/emmc-provisioning-deploy"
|
||||
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'
|
||||
|
||||
# --- 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) ..."
|
||||
|
||||
# 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'
|
||||
set -e
|
||||
DEPLOY=/tmp/emmc-provisioning-deploy
|
||||
ROOTFS_STORAGE="${ROOTFS_STORAGE:?ROOTFS_STORAGE not set}"
|
||||
BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}"
|
||||
LXC_HOSTNAME="cm4-provisioning"
|
||||
log() { echo "[$(date -Iseconds)] $*"; }
|
||||
|
||||
# --- Auto-select LXC rootfs storage if not set or not available ---
|
||||
_storage_exists() { pvesm status 2>/dev/null | awk -v s="$1" 'NR>1 && $1==s && $3=="active" {exit(0)} END{exit(1)}'; }
|
||||
if [[ -n "${ROOTFS_STORAGE:-}" ]] && _storage_exists "$ROOTFS_STORAGE"; then
|
||||
: # use provided and existing storage
|
||||
else
|
||||
ROOTFS_STORAGE=""
|
||||
for cand in local-lvm local local-zfs; do
|
||||
if _storage_exists "$cand"; then
|
||||
ROOTFS_STORAGE="$cand"
|
||||
break
|
||||
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"
|
||||
# Storage already chosen interactively; validate it exists on host (match by name + active, robust to column alignment)
|
||||
if ! pvesm status 2>/dev/null | grep -w "$ROOTFS_STORAGE" | grep -q active; then
|
||||
log "Error: storage $ROOTFS_STORAGE not found or not valid on host"
|
||||
exit 1
|
||||
fi
|
||||
log "Using storage: $ROOTFS_STORAGE"
|
||||
|
||||
# --- Find existing LXC by hostname or use next available ID ---
|
||||
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
|
||||
fi
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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."
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo "Host and LXC are fully set up: usbboot (rpiboot), PiShrink, dashboard, systemd, udev."
|
||||
[[ -n "$LXC_IP" ]] && echo " LXC IP: $LXC_IP"
|
||||
echo ""
|
||||
echo "--- Only remaining step (manual) ---"
|
||||
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 " Backup (read from device) works without golden.img."
|
||||
echo ""
|
||||
echo "--- You have ---"
|
||||
echo " - Dashboard: http://<LXC-IP>:5000 (LXC IP: from Proxmox UI, or on host: pct exec <CTID> -- hostname -I)"
|
||||
[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && echo " - LXC SSH: ssh root@<LXC-IP> (password and/or key were set)"
|
||||
echo " - Dashboard: http://${LXC_IP:-<LXC-IP>}:5000"
|
||||
[[ -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"
|
||||
if [[ -n "$LOG_FILE" ]]; then
|
||||
echo " - Log: $LOG_FILE"
|
||||
|
||||
Reference in New Issue
Block a user