From 99bf0a7d1406aea5f9a1f637fd1ac59030d1b5a7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Sat, 13 Dec 2025 22:42:53 +0000 Subject: [PATCH 1/6] microshift-bootc: create a booc microshift based image Creates a bootc based image based on MicroShift for evaluation and small labs. --- deploy/microshift-bootc/Containerfile | 30 + deploy/microshift-bootc/Makefile | 93 + deploy/microshift-bootc/README.md | 420 +++ deploy/microshift-bootc/config-svc/app.py | 2302 +++++++++++++++++ .../config-svc/config-svc.service | 21 + .../config-svc/update-banner.service | 19 + .../config-svc/update-banner.sh | 15 + deploy/microshift-bootc/config.toml | 38 + deploy/microshift-bootc/kustomization.yaml | 11 + deploy/microshift-bootc/output/.gitkeep | 0 deploy/microshift-bootc/run-microshift.sh | 179 ++ 11 files changed, 3128 insertions(+) create mode 100644 deploy/microshift-bootc/Containerfile create mode 100644 deploy/microshift-bootc/Makefile create mode 100644 deploy/microshift-bootc/README.md create mode 100644 deploy/microshift-bootc/config-svc/app.py create mode 100644 deploy/microshift-bootc/config-svc/config-svc.service create mode 100644 deploy/microshift-bootc/config-svc/update-banner.service create mode 100644 deploy/microshift-bootc/config-svc/update-banner.sh create mode 100644 deploy/microshift-bootc/config.toml create mode 100644 deploy/microshift-bootc/kustomization.yaml create mode 100644 deploy/microshift-bootc/output/.gitkeep create mode 100755 deploy/microshift-bootc/run-microshift.sh diff --git a/deploy/microshift-bootc/Containerfile b/deploy/microshift-bootc/Containerfile new file mode 100644 index 00000000..f242ee69 --- /dev/null +++ b/deploy/microshift-bootc/Containerfile @@ -0,0 +1,30 @@ +FROM ghcr.io/microshift-io/microshift:release-4.20-4.20.0-okd-scos.9 + +# Install dependencies for config-svc +RUN dnf install -y epel-release && \ + dnf install -y python3 iproute python3-flask python3-pip && \ + pip3 install python-pam && \ + dnf clean all + +# Install MicroShift manifests +RUN mkdir -p /etc/microshift/manifests.d/002-jumpstarter +COPY deploy/microshift-bootc/kustomization.yaml /etc/microshift/manifests.d/002-jumpstarter/kustomization.yaml +COPY deploy/operator/dist/install.yaml /etc/microshift/manifests.d/002-jumpstarter/install-operator.yaml + +# Configure firewalld to open required ports +# Use firewall-offline-cmd since firewalld is not running during build +RUN firewall-offline-cmd --add-service=http && \ + firewall-offline-cmd --add-service=https && \ + firewall-offline-cmd --add-port=8880/tcp + +# Set root password +RUN echo "root:jumpstarter" | chpasswd + +# Install config-svc systemd service +COPY deploy/microshift-bootc/config-svc/app.py /usr/local/bin/config-svc +RUN chmod +x /usr/local/bin/config-svc +COPY deploy/microshift-bootc/config-svc/update-banner.sh /usr/local/bin/update-banner.sh +RUN chmod +x /usr/local/bin/update-banner.sh +COPY deploy/microshift-bootc/config-svc/config-svc.service /etc/systemd/system/config-svc.service +COPY deploy/microshift-bootc/config-svc/update-banner.service /etc/systemd/system/update-banner.service +RUN systemctl enable config-svc.service update-banner.service \ No newline at end of file diff --git a/deploy/microshift-bootc/Makefile b/deploy/microshift-bootc/Makefile new file mode 100644 index 00000000..604a370e --- /dev/null +++ b/deploy/microshift-bootc/Makefile @@ -0,0 +1,93 @@ +.PHONY: help build bootc-build push bootc-push bootc-run bootc-stop bootc-sh bootc-rm + +# Default image tags +BOOTC_IMG ?= quay.io/jumpstarter-dev/microshift/bootc:latest + + +help: ## Display this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Build + +build: bootc-build ## Build bootc image (default target) + +bootc-build: ## Build the bootc image with MicroShift + @echo "Building bootc image: $(BOOTC_IMG): building as root to be on the container storage from root" + sudo podman build -t $(BOOTC_IMG) -f Containerfile ../.. + +output/qcow2/disk.qcow2: ## Build a bootable QCOW2 image from the bootc image + @echo "Building QCOW2 image from: $(BOOTC_IMG)" + @echo "Running bootc-image-builder..." + @mkdir -p output + sudo podman run \ + --rm \ + -it \ + --privileged \ + --pull=newer \ + --security-opt label=type:unconfined_t \ + -v ./config.toml:/config.toml:ro \ + -v ./output:/output \ + -v /var/lib/containers/storage:/var/lib/containers/storage \ + quay.io/centos-bootc/bootc-image-builder:latest \ + --type qcow2 \ + -v \ + $(BOOTC_IMG) + @echo "QCOW2 image built successfully in ./output/" + +build-image: bootc-build ## Build the bootc based qcow2 image + @echo "Building image: output/qcow2/disk.qcow2" + @echo "Cleaning up any existing LVM resources to avoid conflicts..." + -sudo vgs --noheadings -o vg_name,vg_uuid | grep myvg1 | while read vg uuid; do sudo vgremove -f --select vg_uuid=$$uuid 2>/dev/null || true; done + -sudo losetup -D 2>/dev/null || true + sudo rm -f output/qcow2/disk.qcow2 + make output/qcow2/disk.qcow2 + @echo "Image built successfully in ./output/" + +##@ Push + +push: bootc-push ## Push bootc image to registry + +bootc-push: ## Push the bootc image to registry + @echo "Pushing bootc image: $(BOOTC_IMG)" + sudo podman push $(BOOTC_IMG) + +##@ Development + +build-all: bootc-build ## Build bootc image + +push-all: bootc-push ## Push bootc image to registry + +bootc-run: ## Run MicroShift in a bootc container + @echo "Running MicroShift container with image: $(BOOTC_IMG)" + @BOOTC_IMG=$(BOOTC_IMG) sudo -E ./run-microshift.sh + +bootc-stop: ## Stop the running MicroShift container + @echo "Stopping MicroShift container..." + -sudo podman stop jumpstarter-microshift-okd + +bootc-rm: bootc-stop ## Remove the MicroShift container + @echo "Removing MicroShift container..." + -sudo podman rm -f jumpstarter-microshift-okd + @echo "Cleaning up LVM resources..." + -sudo vgremove -f myvg1 2>/dev/null || true + -sudo losetup -d $$(sudo losetup -j /var/lib/microshift-okd/lvmdisk.image | cut -d: -f1) 2>/dev/null || true + @echo "LVM cleanup complete" + +bootc-sh: ## Open a shell in the running MicroShift container + @echo "Opening shell in MicroShift container..." + sudo podman exec -it jumpstarter-microshift-okd /bin/bash -l + +bootc-reload-app: ## Reload the config service app without rebuilding (dev mode) + @echo "Reloading config-svc app..." + sudo podman cp config-svc/app.py jumpstarter-microshift-okd:/usr/local/bin/config-svc + sudo podman exec jumpstarter-microshift-okd systemctl restart config-svc + @echo "Config service reloaded successfully!" + +clean: ## Clean up local images and build artifacts + @echo "Removing local images..." + -sudo podman rmi $(BOOTC_IMG) + @echo "Removing QCOW2 output..." + -sudo rm -rf output/qcow2/disk.qcow2 + @echo "Removing LVM disk image..." + -sudo rm -f /var/lib/microshift-okd/lvmdisk.image + diff --git a/deploy/microshift-bootc/README.md b/deploy/microshift-bootc/README.md new file mode 100644 index 00000000..b35d9234 --- /dev/null +++ b/deploy/microshift-bootc/README.md @@ -0,0 +1,420 @@ +# MicroShift Bootc Deployment + +This directory contains the configuration and scripts to build a bootable container (bootc) image with MicroShift and the Jumpstarter operator pre-installed. + +> **⚠️ Community Edition Disclaimer** +> +> This MicroShift-based deployment is a **community-supported edition** intended for development, testing, and evaluation scenarios. It is **not officially supported** for production use, although it can be OK for small labs. +> +> **For production deployments**, we strongly recommend using the official Jumpstarter Controller deployment on Kubernetes or OpenShift clusters with proper high availability, security, and support. See the [official installation documentation](https://jumpstarter.dev/main/getting-started/installation/service/index.html) for production deployment guides. + +## Overview + +This community edition deployment provides a lightweight, all-in-one solution ideal for: +- **Edge devices** with limited resources +- **Development and testing** environments +- **Proof-of-concept** deployments +- **Local experimentation** with Jumpstarter + +**Features:** +- **MicroShift 4.20 (OKD)** - Lightweight Kubernetes distribution +- **Jumpstarter Operator** - Pre-installed and ready to use +- **TopoLVM CSI** - Dynamic storage provisioning using LVM +- **Configuration Web UI** - Easy setup and management at port 8880 +- **Pod Monitoring** - Real-time pod status dashboard + +## Prerequisites + +- **Fedora/RHEL-based system** (tested on Fedora 42) +- **Podman** installed and configured +- **Root/sudo access** required for privileged operations +- **At least 4GB RAM** and 20GB disk space recommended + +## Quick Start + +### 1. Build the Bootc Image + +```bash +make bootc-build +``` + +This builds a container image with MicroShift and all dependencies. + +### 2. Run as Container (Development/Testing) + +```bash +make bootc-run +``` + +This will: +- Create a 1GB LVM disk image at `/var/lib/microshift-okd/lvmdisk.image` +- Start MicroShift in a privileged container +- Set up LVM volume groups inside the container for TopoLVM +- Wait for MicroShift to be ready + +**Output example:** +``` +MicroShift is running in a bootc container +Hostname: jumpstarter.10.0.2.2.nip.io +Container: jumpstarter-microshift-okd +LVM disk: /var/lib/microshift-okd/lvmdisk.image +VG name: myvg1 +Ports: HTTP:80, HTTPS:443, Config Service:8880 +``` + +### 3. Access the Services + +#### Configuration Web UI +- URL: `http://localhost:8880` +- Login: `root` / `jumpstarter` (default - you'll be required to change it) +- Features: + - Configure hostname and base domain + - Set controller image version + - Change root password (required on first use) + - Download kubeconfig + - Monitor pod status + +#### MicroShift API +- URL: `https://jumpstarter..nip.io:6443` +- Download kubeconfig from the web UI or extract from container + +#### Pod Monitoring Dashboard +- URL: `http://localhost:8880/pods` +- Auto-refreshes every 5 seconds +- Shows all pods across all namespaces + +## Container Management + +### View Running Pods + +```bash +sudo podman exec -it jumpstarter-microshift-okd oc get pods -A +``` + +### Open Shell in Container + +```bash +make bootc-sh +``` + +### Stop Container + +```bash +make bootc-stop +``` + +### Remove Container + +```bash +make bootc-rm +``` + +This will: +- Stop the container +- Remove the container +- Clean up LVM volume groups (myvg1) +- Detach loop devices + +**Note:** The LVM disk image (`/var/lib/microshift-okd/lvmdisk.image`) is preserved. To remove it completely, use `make clean`. + +### Complete Rebuild + +```bash +make bootc-rm bootc-build bootc-run +``` + +This stops, removes, rebuilds, and restarts the container with the latest changes. + +## Creating a Bootable QCOW2 Image + +For production deployments, you can create a bootable QCOW2 disk image that can be: +- Installed on bare metal +- Used in virtual machines (KVM/QEMU, OpenStack, etc.) +- Deployed to edge devices + +### Build QCOW2 Image + +```bash +make build-image +``` + +This will: +1. Clean up any existing LVM resources to avoid conflicts +2. Build the bootc container image (if not already built) +3. Use `bootc-image-builder` to create a bootable QCOW2 image +4. Output the image to `./output/qcow2/disk.qcow2` + +**Note:** This process takes several minutes and requires significant disk space (20GB+). + +**Important:** If you're running the container (`make bootc-run`) and want to build the image, stop the container first with `make bootc-rm` to avoid LVM conflicts. + +### Configuration + +The QCOW2 image is configured via `config.toml`: +- **LVM partitioning:** Creates `myvg1` volume group with 20GB minimum +- **Root filesystem:** XFS on LVM (10GB minimum) +- **Default password:** `root:jumpstarter` (change via web UI on first boot) + +### Using the QCOW2 Image + +#### In a Virtual Machine (KVM/QEMU) + +```bash +qemu-system-x86_64 \ + -m 4096 \ + -smp 2 \ + -drive file=output/qcow2/disk.qcow2,format=qcow2 \ + -net nic -net user,hostfwd=tcp::8880-:8880,hostfwd=tcp::443-:443 +``` + +#### Convert to Other Formats + +```bash +# Convert to raw disk image +qemu-img convert -f qcow2 -O raw output/qcow2/disk.qcow2 output/disk.raw + +# Convert to VirtualBox VDI +qemu-img convert -f qcow2 -O vdi output/qcow2/disk.qcow2 output/disk.vdi +``` + +## Architecture + +### Components + +``` +┌─────────────────────────────────────────────┐ +│ Bootc Container / Image │ +├─────────────────────────────────────────────┤ +│ • Fedora CoreOS 9 base │ +│ • MicroShift 4.20 (OKD) │ +│ • Jumpstarter Operator │ +│ • TopoLVM CSI (storage) │ +│ • Configuration Service (Python/Flask) │ +│ • Firewalld (ports 22, 80, 443, 8880) │ +└─────────────────────────────────────────────┘ +``` + +### Storage Setup + +When running as a container: +1. Script creates `/var/lib/microshift-okd/lvmdisk.image` (1GB) +2. Image is copied into the container +3. Loop device is created inside container +4. LVM volume group `myvg1` is created +5. TopoLVM uses `myvg1` for dynamic PV provisioning + +When deployed from QCOW2: +1. Bootc image builder creates proper disk partitioning +2. LVM volume group `myvg1` is set up on disk +3. Root filesystem uses part of the VG +4. Remaining space available for TopoLVM + +## Customization + +### Change Default Image + +```bash +BOOTC_IMG=quay.io/your-org/microshift-bootc:v1.0 make bootc-build +``` + +### Modify Manifests + +Add Kubernetes manifests to `/etc/microshift/manifests.d/002-jumpstarter/` by editing: +- `kustomization.yaml` - Kustomize configuration +- Additional YAML files will be automatically applied + +### Update Configuration Service + +Edit `config-svc/app.py` and rebuild: + +```bash +make bootc-build +``` + +For live testing without rebuild: + +```bash +make bootc-reload-app +``` + +## Troubleshooting + +### LVM/TopoLVM Issues + +Check if volume group exists in container: + +```bash +sudo podman exec jumpstarter-microshift-okd vgs +sudo podman exec jumpstarter-microshift-okd pvs +``` + +If TopoLVM pods are crashing, recreate the LVM setup: + +```bash +make bootc-rm # Automatically cleans up VG and loop devices +make clean # Remove the disk image for a fresh start +make bootc-run +``` + +### MicroShift Not Starting + +Check logs: + +```bash +sudo podman logs jumpstarter-microshift-okd +sudo podman exec jumpstarter-microshift-okd journalctl -u microshift -f +``` + +### Configuration Service Issues + +Check service status: + +```bash +sudo podman exec jumpstarter-microshift-okd systemctl status config-svc +sudo podman exec jumpstarter-microshift-okd journalctl -u config-svc -f +``` + +### Port Conflicts + +If ports 80, 443, or 8880 are in use, modify `run-microshift.sh`: + +```bash +HTTP_PORT=8080 +HTTPS_PORT=8443 +CONFIG_SVC_PORT=9880 +``` + +### Bootc Image Builder Fails + +Ensure sufficient disk space and clean up: + +```bash +sudo podman system prune -a +sudo rm -rf output/ +``` + +## Makefile Targets + +| Target | Description | +|--------|-------------| +| `make help` | Display all available targets | +| `make bootc-build` | Build the bootc container image | +| `make bootc-run` | Run MicroShift in a container | +| `make bootc-stop` | Stop the running container | +| `make bootc-rm` | Remove container and clean up LVM resources | +| `make bootc-sh` | Open shell in container | +| `make bootc-reload-app` | Reload config service without rebuild (dev mode) | +| `make build-image` | Create bootable QCOW2 image | +| `make bootc-push` | Push image to registry | +| `make clean` | Clean up images, artifacts, and LVM disk | + +## Files + +| File | Description | +|------|-------------| +| `Containerfile` | Container build definition | +| `config.toml` | Bootc image builder configuration | +| `run-microshift.sh` | Container startup script | +| `kustomization.yaml` | Kubernetes manifests configuration | +| `config-svc/app.py` | Configuration web UI service | +| `config-svc/config-svc.service` | Systemd service definition | + +## Network Configuration + +### Hostname Resolution + +The system uses `nip.io` for automatic DNS resolution: +- Default: `jumpstarter..nip.io` +- Example: `jumpstarter.10.0.2.2.nip.io` resolves to `10.0.2.2` + +### Firewall Ports + +| Port | Service | Description | +|------|---------|-------------| +| 80 | HTTP | MicroShift ingress | +| 443 | HTTPS | MicroShift API and ingress | +| 8880 | Config UI | Web configuration interface | +| 6443 | API Server | Kubernetes API (internal) | + +## Security Notes + +⚠️ **Important Security Considerations:** + +1. **Default Password:** The system ships with `root:jumpstarter` as the default password + - **Console login:** You will be forced to change the password on first SSH/console login + - **Web UI:** You must change the password before accessing the configuration interface +2. **TLS Certificates:** MicroShift uses self-signed certs by default +3. **Privileged Container:** Required for systemd, LVM, and networking +4. **Authentication:** Web UI uses PAM authentication with root credentials +5. **Production Use:** Consider additional hardening for production deployments + +## Development Workflow + +Typical development cycle: + +```bash +# 1. Make changes to code/configuration +vim config-svc/app.py + +# 2. Quick reload (no rebuild needed) +make bootc-reload-app + +# 3. Access and test +curl http://localhost:8880 + +# 4. Check logs if issues +make bootc-sh +journalctl -u config-svc -f + +# 5. For major changes, do full rebuild +make bootc-rm bootc-build bootc-run +``` + +## Production Deployment + +1. **Build QCOW2 image:** + ```bash + make build-image + ``` + +2. **Copy image to target system:** + ```bash + scp output/qcow2/disk.qcow2 target-host:/var/lib/libvirt/images/ + ``` + +3. **Create VM or write to disk:** + ```bash + # For VM + virt-install --name jumpstarter \ + --memory 4096 \ + --vcpus 2 \ + --disk path=/var/lib/libvirt/images/disk.qcow2 \ + --import \ + --os-variant fedora39 + + # For bare metal + dd if=output/qcow2/disk.qcow2 of=/dev/sdX bs=4M status=progress + ``` + +4. **First boot:** + - Console login will require password change from default `jumpstarter` + - Access web UI at `http://:8880` and set new password + +## Resources + +### Jumpstarter Documentation +- [Official Installation Guide](https://jumpstarter.dev/main/getting-started/installation/service/index.html) - **Recommended for production** +- [Jumpstarter Project](https://github.com/jumpstarter-dev/jumpstarter) + +### Technology Stack +- [MicroShift Documentation](https://microshift.io/) +- [Bootc Documentation](https://containers.github.io/bootc/) +- [TopoLVM Documentation](https://github.com/topolvm/topolvm) + +## Support + +For issues and questions: +- File issues on the Jumpstarter GitHub repository +- Check container logs: `sudo podman logs jumpstarter-microshift-okd` +- Review systemd journals: `make bootc-sh` then `journalctl -xe` + diff --git a/deploy/microshift-bootc/config-svc/app.py b/deploy/microshift-bootc/config-svc/app.py new file mode 100644 index 00000000..1d2825ab --- /dev/null +++ b/deploy/microshift-bootc/config-svc/app.py @@ -0,0 +1,2302 @@ +#!/usr/bin/env python3 +""" +Jumpstarter Configuration Web UI + +A simple web service for configuring Jumpstarter deployment settings: +- Hostname configuration with smart defaults +- Jumpstarter CR management (baseDomain + image version) +- MicroShift kubeconfig download +""" + +import json +import os +import re +import socket +import subprocess +import sys +import tempfile +from functools import wraps +from io import BytesIO +from pathlib import Path + +from flask import Flask, request, send_file, render_template_string, Response, jsonify + +app = Flask(__name__) + +# MicroShift kubeconfig path +KUBECONFIG_PATH = '/var/lib/microshift/resources/kubeadmin/kubeconfig' + + +def validate_hostname(hostname): + """ + Validate hostname according to RFC 1123 standards. + + Rules: + - Total length <= 253 characters + - Each label 1-63 characters + - Labels match /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i (case-insensitive) + - No leading/trailing hyphen in labels + - Reject empty or illegal characters + - Optionally reject trailing dot + + Returns: (is_valid: bool, error_message: str) + """ + if not hostname: + return False, "Hostname cannot be empty" + + # Remove trailing dot if present (optional rejection) + if hostname.endswith('.'): + hostname = hostname.rstrip('.') + + # Check total length + if len(hostname) > 253: + return False, f"Hostname too long: {len(hostname)} characters (maximum 253)" + + # Split into labels + labels = hostname.split('.') + + # Check each label + label_pattern = re.compile(r'^[a-z0-9]([a-z0-9-]*[a-z0-9])?$', re.IGNORECASE) + + for i, label in enumerate(labels): + if not label: + return False, f"Empty label at position {i+1} (consecutive dots not allowed)" + + if len(label) > 63: + return False, f"Label '{label}' too long: {len(label)} characters (maximum 63)" + + if not label_pattern.match(label): + return False, f"Label '{label}' contains invalid characters. Labels must start and end with alphanumeric characters and can contain hyphens in between" + + # Additional check: no leading/trailing hyphen (pattern should catch this, but be explicit) + if label.startswith('-') or label.endswith('-'): + return False, f"Label '{label}' cannot start or end with a hyphen" + + return True, "" + + +def validate_password(password): + """ + Validate password to prevent chpasswd injection and enforce security. + + Rules: + - Reject newline characters ('\n') + - Reject colon characters (':') + - Minimum length: 8 characters + - Maximum length: 128 characters (reasonable limit) + + Returns: (is_valid: bool, error_message: str) + """ + if not password: + return False, "Password cannot be empty" + + # Check for forbidden characters + if '\n' in password: + return False, "Password cannot contain newline characters" + + if ':' in password: + return False, "Password cannot contain colon characters" + + # Check length + if len(password) < 8: + return False, f"Password too short: {len(password)} characters (minimum 8)" + + if len(password) > 128: + return False, f"Password too long: {len(password)} characters (maximum 128)" + + return True, "" + + +def check_auth(username, password): + """Check if a username/password combination is valid using PAM.""" + if username != 'root': + return False + + try: + # Try using PAM authentication first + import pam + p = pam.pam() + return p.authenticate(username, password) + except ImportError: + # Fallback: use subprocess to authenticate via su + try: + result = subprocess.run( + ['su', username, '-c', 'true'], + input=password.encode(), + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except Exception as e: + print(f"Authentication error: {e}", file=sys.stderr) + return False + + +def is_default_password(): + """Check if the root password is still the default 'jumpstarter'.""" + return check_auth('root', 'jumpstarter') + + +def authenticate(): + """Send a 401 response that enables basic auth.""" + return Response( + 'Authentication required. Please login with root credentials.', + 401, + {'WWW-Authenticate': 'Basic realm="Jumpstarter Configuration"'} + ) + + +def requires_auth(f): + """Decorator to require HTTP Basic Authentication.""" + @wraps(f) + def decorated(*args, **kwargs): + auth = request.authorization + if not auth or not check_auth(auth.username, auth.password): + return authenticate() + return f(*args, **kwargs) + return decorated + + +# HTML template for forced password change +PASSWORD_REQUIRED_TEMPLATE = """ + + + + + Password Change Required - Jumpstarter + + + + +
+ + +
+

Security Setup Required

+ + {% for msg in messages %} +
{{ msg.text }}
+ {% endfor %} + +
+

⚠️ Default Password Detected

+

You are using the default password. For security reasons, you must change the root password before accessing the configuration interface.

+
+ +
+
+ + +
Minimum 8 characters
+
+
+ + +
Re-enter your new password
+
+ +
+
+
+ +""" + +# HTML template for the main page +HTML_TEMPLATE = """ + + + + + Jumpstarter Configuration + + + + +
+ + + + +
+ {% for msg in messages %} +
{{ msg.text }}
+ {% endfor %} + +
+

Jumpstarter Deployment Configuration

+
+
+
+ + +
The base domain for Jumpstarter routes
+
+
+ + +
The Jumpstarter controller container image to use
+
+
+ + +
When to pull the container image
+
+ +
+ +

Hostname Configuration

+
+
+ + +
Set the system hostname
+
+ +
+
+ +
+

Change Root Password

+
+
+ + +
Minimum 8 characters
+
+
+ + +
Re-enter your new password
+
+ +
+
+ +
+

System Information

+
+
Loading system statistics...
+
+
+ +
+
+

Kubeconfig

+

+ Download the MicroShift kubeconfig file to access the Kubernetes cluster from your local machine. +

+ Download Kubeconfig +
+ +
+

Routes

+
+ +
+ + + + + + + + + + + + + + + + + + +
NamespaceNameHostServicePortTLSAdmittedAge
Loading routes...
+
+
+ +
+

Pod Status

+
+ +
+ + + + + + + + + + + + + + + + + + +
NamespaceNameReadyStatusRestartsAgeNodeActions
Loading pods...
+
+
+
+
+
+ + + +""" + + +@app.route('/static/styles.css') +def serve_css(): + """Serve the consolidated CSS stylesheet.""" + css = """ + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + html { + scroll-behavior: smooth; + } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background: linear-gradient(135deg, #4c4c4c 0%, #1a1a1a 100%); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + } + .container { + background: white; + border-radius: 12px; + box-shadow: 0 10px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255, 193, 7, 0.1); + max-width: 1000px; + width: 100%; + padding: 40px; + } + .banner { + margin: -40px -40px 30px -40px; + padding: 25px 40px; + background: linear-gradient(135deg, #757575 0%, #616161 100%); + border-radius: 12px 12px 0 0; + text-align: center; + } + .banner-text { + color: white; + font-size: 14px; + margin-bottom: 20px; + font-weight: 500; + } + .logos { + display: flex; + justify-content: center; + align-items: center; + gap: 40px; + flex-wrap: wrap; + } + .logo-link { + display: inline-block; + transition: opacity 0.3s; + } + .logo-link:hover { + opacity: 0.9; + } + .logo-link img { + height: 45px; + width: auto; + } + .microshift-logo { + height: 40px !important; + filter: brightness(0) invert(1); + } + .jumpstarter-logo { + height: 40px !important; + } + .nav-bar { + display: flex; + gap: 0; + margin: 0 -40px 30px -40px; + border-bottom: 1px solid #e0e0e0; + background: #fafafa; + } + .nav-link { + flex: 1; + text-align: center; + padding: 15px 20px; + text-decoration: none; + color: #666; + font-size: 14px; + font-weight: 500; + transition: all 0.3s; + border-bottom: 3px solid transparent; + } + .nav-link:hover { + background: #f5f5f5; + color: #333; + border-bottom-color: #ffc107; + } + .nav-link.active { + color: #000; + border-bottom-color: #ffc107; + background: white; + } + .content-area { + padding: 0 40px 40px 40px; + margin: 0 -40px -40px -40px; + } + h2 { + color: #333; + font-size: 20px; + margin-bottom: 15px; + } + .section { + display: none; + padding: 20px 0; + animation: fadeIn 0.3s ease-in; + } + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .info { + background: #f8f9fa; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 15px; + font-size: 14px; + color: #555; + } + .info strong { + color: #333; + } + .warning-box { + background: #fff3cd; + border: 1px solid #ffc107; + border-radius: 6px; + padding: 16px; + margin-bottom: 30px; + } + .warning-box h2 { + color: #856404; + font-size: 18px; + margin-bottom: 10px; + } + .warning-box p { + color: #856404; + font-size: 14px; + line-height: 1.5; + } + .form-group { + margin-bottom: 15px; + } + label { + display: block; + margin-bottom: 6px; + color: #555; + font-size: 14px; + font-weight: 500; + } + input[type="text"], + input[type="password"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.3s, opacity 0.3s; + } + input[type="text"]:focus, + input[type="password"]:focus { + outline: none; + border-color: #ffc107; + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2); + } + input[type="text"]:disabled, + input[type="password"]:disabled { + background-color: #f5f5f5; + cursor: not-allowed; + opacity: 0.6; + } + select { + width: 100%; + padding: 10px 12px; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 14px; + background-color: white; + cursor: pointer; + transition: border-color 0.3s; + } + select:focus { + outline: none; + border-color: #ffc107; + box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2); + } + .hint { + font-size: 12px; + color: #888; + margin-top: 4px; + } + button { + background: #ffc107; + color: #000; + border: none; + padding: 12px 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.3s, opacity 0.3s; + } + button:hover { + background: #ffb300; + } + button:disabled { + background: #666; + color: #999; + cursor: not-allowed; + opacity: 0.6; + } + button:disabled:hover { + background: #666; + } + button[type="submit"] { + width: 100%; + } + .download-btn { + background: #ffc107; + display: inline-block; + text-decoration: none; + color: #000; + padding: 12px 24px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + transition: background 0.3s; + } + .download-btn:hover { + background: #ffb300; + } + .message { + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + font-size: 14px; + } + .message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + } + .message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + } + /* MicroShift page specific styles */ + .status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + .status-running { + background: #d4edda; + color: #155724; + } + .status-pending { + background: #fff3cd; + color: #856404; + } + .status-failed { + background: #f8d7da; + color: #721c24; + } + .status-succeeded { + background: #d1ecf1; + color: #0c5460; + } + .status-crashloopbackoff { + background: #f8d7da; + color: #721c24; + } + .status-terminating { + background: #ffeaa7; + color: #856404; + } + .status-unknown { + background: #e2e3e5; + color: #383d41; + } + table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + font-size: 13px; + } + th { + background: #f8f9fa; + padding: 12px 8px; + text-align: left; + font-weight: 600; + color: #333; + border-bottom: 2px solid #dee2e6; + position: sticky; + top: 0; + z-index: 10; + } + td { + padding: 10px 8px; + border-bottom: 1px solid #eee; + color: #555; + } + tr:hover { + background: #f8f9fa; + } + .table-wrapper { + overflow-x: auto; + max-height: 70vh; + overflow-y: auto; + } + .loading { + text-align: center; + padding: 40px; + color: #666; + } + .error { + background: #f8d7da; + color: #721c24; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + } + .pod-count { + color: #666; + font-size: 14px; + margin-bottom: 10px; + } + .microshift-section { + margin-bottom: 30px; + padding-bottom: 30px; + border-bottom: 1px solid #eee; + } + .microshift-section:last-child { + border-bottom: none; + } + .action-icon { + text-decoration: none; + font-size: 18px; + padding: 4px 6px; + margin: 0 2px; + border-radius: 4px; + transition: all 0.3s; + display: inline-block; + cursor: pointer; + } + .action-icon:hover { + background: #fff3e0; + transform: scale(1.2); + } + """ + return Response(css, mimetype='text/css') + + +@app.route('/logout') +def logout(): + """Logout endpoint that forces re-authentication.""" + return Response( + 'Logged out. Please close this dialog to log in again.', + 401, + {'WWW-Authenticate': 'Basic realm="Jumpstarter Configuration"'} + ) + + +@app.route('/') +@requires_auth +def index(): + """Serve the main configuration page.""" + current_hostname = get_current_hostname() + jumpstarter_config = get_jumpstarter_config() + password_required = is_default_password() + + # Force password change if still using default + if password_required: + return render_template_string( + PASSWORD_REQUIRED_TEMPLATE, + messages=[], + current_hostname=current_hostname + ) + + return render_template_string( + HTML_TEMPLATE, + messages=[], + current_hostname=current_hostname, + jumpstarter_config=jumpstarter_config, + password_required=password_required + ) + + +@app.route('/change-password', methods=['POST']) +@requires_auth +def change_password(): + """Handle password change request.""" + new_password = request.form.get('newPassword', '').strip() + confirm_password = request.form.get('confirmPassword', '').strip() + + current_hostname = get_current_hostname() + jumpstarter_config = get_jumpstarter_config() + was_default = is_default_password() + + messages = [] + + # Validate password format and security + password_valid, password_error = validate_password(new_password) + if not password_valid: + messages.append({'type': 'error', 'text': password_error}) + elif new_password != confirm_password: + messages.append({'type': 'error', 'text': 'Passwords do not match'}) + else: + password_success, password_message = set_root_password(new_password) + if not password_success: + messages.append({'type': 'error', 'text': f'Failed to set password: {password_message}'}) + else: + if was_default: + # Update login banner on first password change + update_login_banner() + + # First time changing from default - show success and trigger re-auth + messages.append({'type': 'success', 'text': 'Password changed successfully! Redirecting to login with your new password...'}) + # Remove warning box and form, show only success with auto-redirect + success_template = PASSWORD_REQUIRED_TEMPLATE.replace( + '
', + ' - + +
- -
Minimum 8 characters
+ +
Minimum 8 characters (required to change from default password)
- +
Re-enter your new password
- +
+ + +
One SSH public key per line. Leave empty to clear existing keys.
+
+ +
@@ -286,18 +368,24 @@ def decorated(*args, **kwargs):

Change Root Password

-
+ +
- - -
Minimum 8 characters
+ + +
Leave empty to only update SSH keys. Minimum 8 characters if provided.
- - -
Re-enter your new password
+ + +
Re-enter your new password (required if password is provided)
+
+
+ + +
One SSH public key per line. Leave empty to clear existing keys.
- +
@@ -520,6 +608,69 @@ def decorated(*args, **kwargs): }); } + // Handle password change form submission via API + const mainPasswordForm = document.getElementById('main-password-change-form'); + if (mainPasswordForm) { + mainPasswordForm.addEventListener('submit', function(e) { + e.preventDefault(); + + const data = { + newPassword: document.getElementById('mainNewPassword').value, + confirmPassword: document.getElementById('mainConfirmPassword').value, + sshKeys: document.getElementById('mainSshKeys').value + }; + + const submitBtn = document.getElementById('main-password-submit-btn'); + const messagesContainer = document.getElementById('main-password-messages-container'); + const originalText = submitBtn.textContent; + submitBtn.disabled = true; + submitBtn.textContent = 'Processing...'; + + // Clear previous messages + messagesContainer.innerHTML = ''; + + fetch('/api/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(data) + }) + .then(response => response.json()) + .then(result => { + // Display messages + result.messages.forEach(msg => { + const messageDiv = document.createElement('div'); + messageDiv.className = `message ${msg.type}`; + messageDiv.textContent = msg.text; + messagesContainer.appendChild(messageDiv); + }); + + // Update SSH keys textarea if they were updated + if (result.ssh_updated && result.ssh_keys !== undefined) { + document.getElementById('mainSshKeys').value = result.ssh_keys; + } + + // Clear password fields if password was successfully updated + if (result.password_updated) { + document.getElementById('mainNewPassword').value = ''; + document.getElementById('mainConfirmPassword').value = ''; + } + + // Scroll to messages + messagesContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }) + .catch(error => { + messagesContainer.innerHTML = '
Failed to update: ' + error.message + '
'; + }) + .finally(() => { + submitBtn.disabled = false; + submitBtn.textContent = originalText; + }); + }); + } + function loadSystemStats() { fetch('/api/system-stats') .then(response => response.json()) @@ -946,22 +1097,30 @@ def serve_css(): font-weight: 500; } input[type="text"], - input[type="password"] { + input[type="password"], + textarea { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; transition: border-color 0.3s, opacity 0.3s; + font-family: inherit; + } + textarea { + font-family: monospace; + resize: vertical; } input[type="text"]:focus, - input[type="password"]:focus { + input[type="password"]:focus, + textarea:focus { outline: none; border-color: #ffc107; box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.2); } input[type="text"]:disabled, - input[type="password"]:disabled { + input[type="password"]:disabled, + textarea:disabled { background-color: #f5f5f5; cursor: not-allowed; opacity: 0.6; @@ -1042,6 +1201,11 @@ def serve_css(): color: #721c24; border: 1px solid #f5c6cb; } + .message.info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; + } /* MicroShift page specific styles */ .status-badge { display: inline-block; @@ -1169,13 +1333,15 @@ def index(): current_hostname = get_current_hostname() jumpstarter_config = get_jumpstarter_config() password_required = is_default_password() + ssh_keys = get_ssh_authorized_keys() # Force password change if still using default if password_required: return render_template_string( PASSWORD_REQUIRED_TEMPLATE, messages=[], - current_hostname=current_hostname + current_hostname=current_hostname, + ssh_keys=ssh_keys ) return render_template_string( @@ -1183,99 +1349,75 @@ def index(): messages=[], current_hostname=current_hostname, jumpstarter_config=jumpstarter_config, - password_required=password_required + password_required=password_required, + ssh_keys=ssh_keys ) -@app.route('/change-password', methods=['POST']) +@app.route('/api/change-password', methods=['POST']) @requires_auth -def change_password(): - """Handle password change request.""" - new_password = request.form.get('newPassword', '').strip() - confirm_password = request.form.get('confirmPassword', '').strip() +def api_change_password(): + """API endpoint to handle password change request (returns JSON).""" + data = request.get_json() if request.is_json else {} + new_password = data.get('newPassword', request.form.get('newPassword', '')).strip() + confirm_password = data.get('confirmPassword', request.form.get('confirmPassword', '')).strip() + ssh_keys_value = data.get('sshKeys', request.form.get('sshKeys', '')).strip() - current_hostname = get_current_hostname() - jumpstarter_config = get_jumpstarter_config() was_default = is_default_password() + existing_ssh_keys = get_ssh_authorized_keys() messages = [] + password_updated = False + ssh_updated = False + requires_redirect = False - # Validate password format and security - password_valid, password_error = validate_password(new_password) - if not password_valid: - messages.append({'type': 'error', 'text': password_error}) - elif new_password != confirm_password: - messages.append({'type': 'error', 'text': 'Passwords do not match'}) - else: - password_success, password_message = set_root_password(new_password) - if not password_success: - messages.append({'type': 'error', 'text': f'Failed to set password: {password_message}'}) + # If password is provided, validate and set it + if new_password: + # Validate password format and security + password_valid, password_error = validate_password(new_password) + if not password_valid: + messages.append({'type': 'error', 'text': password_error}) + elif new_password != confirm_password: + messages.append({'type': 'error', 'text': 'Passwords do not match'}) else: - if was_default: - # Update login banner on first password change - update_login_banner() - - # First time changing from default - show success and trigger re-auth - messages.append({'type': 'success', 'text': 'Password changed successfully! Redirecting to login with your new password...'}) - # Remove warning box and form, show only success with auto-redirect - success_template = PASSWORD_REQUIRED_TEMPLATE.replace( - '
', - '
@@ -518,11 +523,7 @@ def decorated(*args, **kwargs): form.style.opacity = '1'; } else { // Operator not ready - disable form and show status - statusContainer.innerHTML = - '
' + - '⏳ ' + data.message + '
' + - 'The configuration form will be available once the operator is ready. Checking status every 5 seconds...' + - '
'; + statusContainer.innerHTML = '
⏳ ' + data.message + '
The configuration form will be available once the operator is ready. Checking status every 5 seconds...
'; baseDomainInput.disabled = true; imageInput.disabled = true; submitBtn.disabled = true; @@ -672,84 +673,86 @@ def decorated(*args, **kwargs): } function loadSystemStats() { + const container = document.getElementById('system-stats-container'); + if (!container) { + console.error('system-stats-container not found'); + return; + } + fetch('/api/system-stats') - .then(response => response.json()) + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return response.json(); + }) .then(data => { - const container = document.getElementById('system-stats-container'); - if (data.error) { container.innerHTML = '
' + data.error + '
'; return; } - container.innerHTML = ` -
-
- 💾 Disk Usage
-
- Root: ${data.disk.used} / ${data.disk.total} (${data.disk.percent}%)
-
-
-
- Available: ${data.disk.available} -
-
- -
- 🧠 Memory
-
- Used: ${data.memory.used} / ${data.memory.total} (${data.memory.percent}%)
-
-
-
- Available: ${data.memory.available} -
-
- -
- ⚙️ CPU
-
- Cores: ${data.cpu.cores}
- Usage: ${data.cpu.usage}%
-
-
-
-
-
- -
- 🖥️ System
-
- Kernel: ${data.system.kernel}
- Uptime: ${data.system.uptime}
- Hostname: ${data.system.hostname} -
-
- -
- 🌐 Network
-
- ${data.network.interfaces.map(iface => - iface.name + ': ' + iface.ip - ).join('
')} -
-
- -
- 📊 Load Average
-
- 1 min: ${data.system.load_1}
- 5 min: ${data.system.load_5}
- 15 min: ${data.system.load_15} -
-
-
- `; + const diskColor = data.disk.percent > 80 ? '#f44336' : '#ffc107'; + const memoryColor = data.memory.percent > 80 ? '#f44336' : '#4caf50'; + const cpuColor = data.cpu.usage > 80 ? '#f44336' : '#2196f3'; + const networkInfo = data.network.interfaces.map(iface => iface.name + ': ' + iface.ip).join('
'); + + container.innerHTML = '
💾 Disk Usage
Root: ' + data.disk.used + ' / ' + data.disk.total + ' (' + data.disk.percent + '%)
Available: ' + data.disk.available + '
🧠 Memory
Used: ' + data.memory.used + ' / ' + data.memory.total + ' (' + data.memory.percent + '%)
Available: ' + data.memory.available + '
⚙️ CPU
Cores: ' + data.cpu.cores + '
Usage: ' + data.cpu.usage + '%
🖥️ System
Kernel: ' + data.system.kernel + '
Uptime: ' + data.system.uptime + '
Hostname: ' + data.system.hostname + '
🌐 Network
' + networkInfo + '
📊 Load Average
1 min: ' + data.system.load_1 + '
5 min: ' + data.system.load_5 + '
15 min: ' + data.system.load_15 + '
'; }) .catch(error => { console.error('Error fetching system stats:', error); - document.getElementById('system-stats-container').innerHTML = - '
Failed to fetch system statistics: ' + error.message + '
'; + if (container) { + container.innerHTML = '
Failed to fetch system statistics: ' + error.message + '
'; + } + }); + } + + function loadKernelLog() { + const container = document.getElementById('kernel-log-container'); + if (!container) { + console.error('kernel-log-container not found'); + return; + } + + fetch('/api/dmesg') + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return response.json(); + }) + .then(data => { + if (data.error) { + container.innerHTML = '
' + data.error + '
'; + return; + } + + if (!data.log) { + container.innerHTML = '
No log data received
'; + return; + } + + // Escape HTML and format the log + const logLines = data.log.split('\\n').map(line => { + // Escape HTML + const escaped = line.replace(/&/g, '&').replace(//g, '>'); + // Highlight error/warning lines + if (line.toLowerCase().includes('error') || line.toLowerCase().includes('fail')) { + return '' + escaped + ''; + } else if (line.toLowerCase().includes('warn')) { + return '' + escaped + ''; + } + return escaped; + }).join('
'); + + const lineCount = data.line_count || logLines.split('
').length; + container.innerHTML = '
Showing ' + lineCount + ' lines (last 10,000 if more)
' + logLines + '
'; + }) + .catch(error => { + console.error('Error fetching kernel log:', error); + if (container) { + container.innerHTML = '
Failed to fetch kernel log: ' + error.message + '
'; + } }); } @@ -919,6 +922,7 @@ def decorated(*args, **kwargs): if (sectionId === '#system') { loadSystemStats(); + loadKernelLog(); } else if (sectionId === '#microshift') { startMicroshiftUpdates(); } @@ -931,6 +935,7 @@ def decorated(*args, **kwargs): // Explicitly load content for initial section (showSection override is now active) if (initialSection === '#system') { loadSystemStats(); + loadKernelLog(); } else if (initialSection === '#microshift') { startMicroshiftUpdates(); } @@ -1677,6 +1682,38 @@ def get_system_stats(): return jsonify({'error': f'Error gathering system statistics: {str(e)}'}), 500 +@app.route('/api/dmesg') +@requires_auth +def get_dmesg(): + """API endpoint to get kernel log (dmesg).""" + try: + # Run dmesg command to get kernel log + result = subprocess.run( + ['dmesg'], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode != 0: + return jsonify({'error': f'Failed to get dmesg: {result.stderr.strip()}'}), 500 + + # Return the log (limit to last 10000 lines to avoid huge responses) + log_lines = result.stdout.strip().split('\n') + if len(log_lines) > 10000: + log_lines = log_lines[-10000:] + + return jsonify({ + 'log': '\n'.join(log_lines), + 'line_count': len(log_lines) + }) + + except subprocess.TimeoutExpired: + return jsonify({'error': 'Command timed out'}), 500 + except Exception as e: + return jsonify({'error': f'Error getting dmesg: {str(e)}'}), 500 + + @app.route('/api/operator-status') @requires_auth def get_operator_status(): diff --git a/deploy/microshift-bootc/config-svc/update-banner.sh b/deploy/microshift-bootc/config-svc/update-banner.sh index e46fcd46..57cba770 100644 --- a/deploy/microshift-bootc/config-svc/update-banner.sh +++ b/deploy/microshift-bootc/config-svc/update-banner.sh @@ -4,27 +4,35 @@ python3 << 'EOF' import sys import os -sys.path.insert(0, '/usr/local/bin') # Import and call the update function import importlib.util +import importlib.machinery config_svc_path = '/usr/local/bin/config-svc' if not os.path.exists(config_svc_path): print(f"Error: {config_svc_path} does not exist", file=sys.stderr) sys.exit(1) -spec = importlib.util.spec_from_file_location('config_svc', config_svc_path) -if spec is None: - print(f"Error: Failed to create spec for {config_svc_path}", file=sys.stderr) +# Try to create spec with explicit loader for files without .py extension +try: + # Use SourceFileLoader explicitly for files without .py extension + loader = importlib.machinery.SourceFileLoader('config_svc', config_svc_path) + spec = importlib.util.spec_from_loader('config_svc', loader) + + if spec is None: + print(f"Error: Failed to create spec for {config_svc_path}", file=sys.stderr) + sys.exit(1) + + if spec.loader is None: + print(f"Error: Failed to get loader for {config_svc_path}", file=sys.stderr) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.update_login_banner() +except Exception as e: + print(f"Error loading or executing {config_svc_path}: {e}", file=sys.stderr) sys.exit(1) - -if spec.loader is None: - print(f"Error: Failed to get loader for {config_svc_path}", file=sys.stderr) - sys.exit(1) - -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) -module.update_login_banner() EOF From 0ea2aa4b52cf04292607cf4b929a1e05cf9326f5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Mon, 15 Dec 2025 09:04:37 +0000 Subject: [PATCH 5/6] microshift-bootc: add support for bootc updates and better disk info --- deploy/microshift-bootc/config-svc/app.py | 569 +++++++++++++++++++++- 1 file changed, 565 insertions(+), 4 deletions(-) diff --git a/deploy/microshift-bootc/config-svc/app.py b/deploy/microshift-bootc/config-svc/app.py index 628b669e..7ad2515f 100644 --- a/deploy/microshift-bootc/config-svc/app.py +++ b/deploy/microshift-bootc/config-svc/app.py @@ -390,11 +390,29 @@ def decorated(*args, **kwargs):
-

System Information

+

BootC Operations

+
+
+ + +
Container image reference to switch to (e.g., quay.io/jumpstarter-dev/microshift/bootc:latest)
+
+
+ + + +
+ +

System Information

Loading system statistics...
+

BootC Status

+
+
Loading BootC status...
+
+

Kernel Log

Loading kernel log...
@@ -697,7 +715,22 @@ def decorated(*args, **kwargs): const cpuColor = data.cpu.usage > 80 ? '#f44336' : '#2196f3'; const networkInfo = data.network.interfaces.map(iface => iface.name + ': ' + iface.ip).join('
'); - container.innerHTML = '
💾 Disk Usage
Root: ' + data.disk.used + ' / ' + data.disk.total + ' (' + data.disk.percent + '%)
Available: ' + data.disk.available + '
🧠 Memory
Used: ' + data.memory.used + ' / ' + data.memory.total + ' (' + data.memory.percent + '%)
Available: ' + data.memory.available + '
⚙️ CPU
Cores: ' + data.cpu.cores + '
Usage: ' + data.cpu.usage + '%
🖥️ System
Kernel: ' + data.system.kernel + '
Uptime: ' + data.system.uptime + '
Hostname: ' + data.system.hostname + '
🌐 Network
' + networkInfo + '
📊 Load Average
1 min: ' + data.system.load_1 + '
5 min: ' + data.system.load_5 + '
15 min: ' + data.system.load_15 + '
'; + // Build info boxes + let infoBoxes = '
💾 Disk Usage
Root: ' + data.disk.used + ' / ' + data.disk.total + ' (' + data.disk.percent + '%)
Available: ' + data.disk.available + '
'; + + // Add LVM PV info if available + if (data.lvm) { + const lvmColor = data.lvm.percent > 80 ? '#f44336' : '#2196f3'; + infoBoxes += '
💿 LVM Physical Volume
PV: ' + data.lvm.pv_device + '
VG: ' + data.lvm.vg_name + '
Used: ' + data.lvm.used + ' / ' + data.lvm.total + ' (' + data.lvm.percent + '%)
Free: ' + data.lvm.free + '
'; + } + + infoBoxes += '
🧠 Memory
Used: ' + data.memory.used + ' / ' + data.memory.total + ' (' + data.memory.percent + '%)
Available: ' + data.memory.available + '
'; + infoBoxes += '
⚙️ CPU
Cores: ' + data.cpu.cores + '
Usage: ' + data.cpu.usage + '%
'; + infoBoxes += '
🖥️ System
Kernel: ' + data.system.kernel + '
Uptime: ' + data.system.uptime + '
Hostname: ' + data.system.hostname + '
'; + infoBoxes += '
🌐 Network
' + networkInfo + '
'; + infoBoxes += '
📊 Load Average
1 min: ' + data.system.load_1 + '
5 min: ' + data.system.load_5 + '
15 min: ' + data.system.load_15 + '
'; + + container.innerHTML = '
' + infoBoxes + '
'; }) .catch(error => { console.error('Error fetching system stats:', error); @@ -756,6 +789,177 @@ def decorated(*args, **kwargs): }); } + function loadBootcStatus() { + const container = document.getElementById('bootc-status-container'); + if (!container) { + console.error('bootc-status-container not found'); + return; + } + + fetch('/api/bootc-status') + .then(response => { + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return response.json(); + }) + .then(data => { + if (data.error) { + container.innerHTML = '
' + data.error + '
'; + return; + } + + let html = '
'; + if (data.status) { + html += '📦 BootC Status
'; + html += '
' + 
+                                data.status.replace(/&/g, '&').replace(//g, '>') + '
'; + html += '
'; + } + if (data.upgrade_check) { + html += '🔄 Upgrade Check
'; + html += '
' + 
+                                data.upgrade_check.replace(/&/g, '&').replace(//g, '>') + '
'; + html += '
'; + } + html += '
'; + container.innerHTML = html; + }) + .catch(error => { + console.error('Error fetching bootc status:', error); + if (container) { + container.innerHTML = '
Failed to fetch BootC status: ' + error.message + '
'; + } + }); + } + + // BootC operation handlers + document.addEventListener('DOMContentLoaded', function() { + const upgradeCheckBtn = document.getElementById('bootc-upgrade-btn'); + const upgradeApplyBtn = document.getElementById('bootc-upgrade-apply-btn'); + const switchBtn = document.getElementById('bootc-switch-btn'); + const messagesContainer = document.getElementById('bootc-messages-container'); + + if (upgradeCheckBtn) { + upgradeCheckBtn.addEventListener('click', function() { + const originalText = upgradeCheckBtn.textContent; + upgradeCheckBtn.disabled = true; + upgradeCheckBtn.textContent = 'Checking...'; + messagesContainer.innerHTML = ''; + + fetch('/api/bootc-upgrade-check', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(result => { + if (result.success) { + messagesContainer.innerHTML = '
Upgrade check completed. Status updated.
'; + loadBootcStatus(); // Refresh status + } else { + messagesContainer.innerHTML = '
' + (result.error || 'Failed to check for upgrades') + '
'; + } + }) + .catch(error => { + messagesContainer.innerHTML = '
Error: ' + error.message + '
'; + }) + .finally(() => { + upgradeCheckBtn.disabled = false; + upgradeCheckBtn.textContent = originalText; + }); + }); + } + + if (upgradeApplyBtn) { + upgradeApplyBtn.addEventListener('click', function() { + if (!confirm('Are you sure you want to apply the upgrade? This will download and install the new image.')) { + return; + } + + const originalText = upgradeApplyBtn.textContent; + upgradeApplyBtn.disabled = true; + upgradeApplyBtn.textContent = 'Upgrading...'; + messagesContainer.innerHTML = '
Upgrade in progress. This may take several minutes...
'; + + fetch('/api/bootc-upgrade', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin' + }) + .then(response => response.json()) + .then(result => { + if (result.success) { + messagesContainer.innerHTML = '
Upgrade completed successfully! ' + + (result.message || '') + '
'; + loadBootcStatus(); // Refresh status + } else { + messagesContainer.innerHTML = '
' + (result.error || 'Failed to apply upgrade') + '
'; + } + }) + .catch(error => { + messagesContainer.innerHTML = '
Error: ' + error.message + '
'; + }) + .finally(() => { + upgradeApplyBtn.disabled = false; + upgradeApplyBtn.textContent = originalText; + }); + }); + } + + if (switchBtn) { + switchBtn.addEventListener('click', function() { + const imageInput = document.getElementById('bootcSwitchImage'); + const image = imageInput ? imageInput.value.trim() : ''; + + if (!image) { + messagesContainer.innerHTML = '
Please enter an image reference to switch to.
'; + return; + } + + if (!confirm('Are you sure you want to switch to image: ' + image + '? This will download and install the new image.')) { + return; + } + + const originalText = switchBtn.textContent; + switchBtn.disabled = true; + switchBtn.textContent = 'Switching...'; + messagesContainer.innerHTML = '
Switching to new image. This may take several minutes...
'; + + fetch('/api/bootc-switch', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify({ image: image }) + }) + .then(response => response.json()) + .then(result => { + if (result.success) { + messagesContainer.innerHTML = '
Switch completed successfully! ' + + (result.message || '') + '
'; + if (imageInput) imageInput.value = ''; + loadBootcStatus(); // Refresh status + } else { + messagesContainer.innerHTML = '
' + (result.error || 'Failed to switch image') + '
'; + } + }) + .catch(error => { + messagesContainer.innerHTML = '
Error: ' + error.message + '
'; + }) + .finally(() => { + switchBtn.disabled = false; + switchBtn.textContent = originalText; + }); + }); + } + }); + // MicroShift pod and route functions let podsInterval = null; let routesInterval = null; @@ -922,6 +1126,7 @@ def decorated(*args, **kwargs): if (sectionId === '#system') { loadSystemStats(); + loadBootcStatus(); loadKernelLog(); } else if (sectionId === '#microshift') { startMicroshiftUpdates(); @@ -935,6 +1140,7 @@ def decorated(*args, **kwargs): // Explicitly load content for initial section (showSection override is now active) if (initialSection === '#system') { loadSystemStats(); + loadBootcStatus(); loadKernelLog(); } else if (initialSection === '#microshift') { startMicroshiftUpdates(); @@ -1557,6 +1763,191 @@ def configure_jumpstarter(): ) +def get_lvm_pv_info(): + """ + Parse pvscan output to get LVM physical volume information. + Returns dict with PV info or None if not available. + """ + try: + result = subprocess.run(['pvscan'], capture_output=True, text=True, timeout=5) + if result.returncode != 0: + return None + + # Parse output like: "PV /dev/sda3 VG myvg1 lvm2 [62.41 GiB / 52.41 GiB free]" + # or: "Total: 1 [62.41 GiB] / in use: 1 [62.41 GiB] / in no VG: 0 [0 ]" + output = result.stdout.strip() + if not output: + return None + + lines = output.split('\n') + + # Look for PV line + pv_device = None + vg_name = None + total_size = None + free_size = None + + for line in lines: + line = line.strip() + # Match: "PV /dev/sda3 VG myvg1 lvm2 [62.41 GiB / 52.41 GiB free]" + if line.startswith('PV '): + parts = line.split() + if len(parts) >= 2: + pv_device = parts[1] + # Find VG name + for i, part in enumerate(parts): + if part == 'VG' and i + 1 < len(parts): + vg_name = parts[i + 1] + break + # Find size info in brackets + bracket_match = re.search(r'\[([^\]]+)\]', line) + if bracket_match: + size_info = bracket_match.group(1) + # Parse "62.41 GiB / 52.41 GiB free" + size_parts = size_info.split('/') + if len(size_parts) >= 1: + total_size = size_parts[0].strip() + if len(size_parts) >= 2: + free_match = re.search(r'([\d.]+)\s*([KMGT]i?B)', size_parts[1]) + if free_match: + free_size = free_match.group(1) + ' ' + free_match.group(2) + + if not pv_device or not total_size: + return None + + # Calculate used space and percentage + # Parse sizes to calculate percentage + def parse_size(size_str): + """Parse size string like '62.41 GiB' to bytes.""" + match = re.match(r'([\d.]+)\s*([KMGT]i?)B?', size_str, re.IGNORECASE) + if not match: + return 0 + value = float(match.group(1)) + unit = match.group(2).upper() + multipliers = {'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4} + return int(value * multipliers.get(unit, 1)) + + total_bytes = parse_size(total_size) + free_bytes = parse_size(free_size) if free_size else 0 + used_bytes = total_bytes - free_bytes + percent = int((used_bytes / total_bytes * 100)) if total_bytes > 0 else 0 + + # Format used size + def format_size(bytes_val): + """Format bytes to human-readable size.""" + for unit, multiplier in [('TiB', 1024**4), ('GiB', 1024**3), ('MiB', 1024**2), ('KiB', 1024)]: + if bytes_val >= multiplier: + return f"{bytes_val / multiplier:.2f} {unit}" + return f"{bytes_val} B" + + used_size = format_size(used_bytes) + + return { + 'pv_device': pv_device, + 'vg_name': vg_name or 'N/A', + 'total': total_size, + 'free': free_size or '0 B', + 'used': used_size, + 'percent': percent + } + except Exception as e: + print(f"Error parsing LVM PV info: {e}", file=sys.stderr) + return None + + +def get_root_filesystem(): + """ + Detect the real root filesystem mount point. + On bootc systems, /sysroot is the real root filesystem. + Otherwise, find the largest real block device filesystem. + """ + # Check if /sysroot exists and is a mount point (bootc systems) + try: + result = subprocess.run(['findmnt', '-n', '-o', 'TARGET', '/sysroot'], + capture_output=True, text=True, timeout=5) + if result.returncode == 0 and result.stdout.strip(): + return '/sysroot' + except Exception: + pass + + # Fallback: parse df output to find the real root filesystem + try: + df_result = subprocess.run(['df', '-h'], capture_output=True, text=True, timeout=5) + if df_result.returncode != 0: + return '/' # Fallback to root + + lines = df_result.stdout.strip().split('\n') + if len(lines) < 2: + return '/' # Fallback to root + + # Virtual filesystem types to skip + virtual_fs = ('tmpfs', 'overlay', 'composefs', 'devtmpfs', 'proc', 'sysfs', + 'devpts', 'cgroup', 'pstore', 'bpf', 'tracefs', 'debugfs', + 'configfs', 'fusectl', 'mqueue', 'hugetlbfs', 'efivarfs', 'ramfs', + 'nsfs', 'shm', 'vfat') + + # Boot partitions to skip + boot_paths = ('/boot', '/boot/efi') + + best_fs = None + best_size = 0 + + for line in lines[1:]: # Skip header + parts = line.split() + if len(parts) < 6: + continue + + filesystem = parts[0] + mount_point = parts[5] + size_str = parts[1] + + # Skip virtual filesystems + fs_type = filesystem.split('/')[-1] if '/' in filesystem else filesystem + if any(vfs in fs_type.lower() for vfs in virtual_fs): + continue + + # Skip boot partitions + if mount_point in boot_paths: + continue + + # Skip if not a block device (doesn't start with /dev) + if not filesystem.startswith('/dev'): + continue + + # Prefer LVM root volumes + if '/mapper/' in filesystem and 'root' in filesystem.lower(): + return mount_point + + # Calculate size for comparison (convert to bytes for comparison) + try: + # Parse size like "10G", "500M", etc. + size_val = float(size_str[:-1]) + size_unit = size_str[-1].upper() + if size_unit == 'G': + size_bytes = size_val * 1024 * 1024 * 1024 + elif size_unit == 'M': + size_bytes = size_val * 1024 * 1024 + elif size_unit == 'K': + size_bytes = size_val * 1024 + else: + size_bytes = size_val + + if size_bytes > best_size: + best_size = size_bytes + best_fs = mount_point + except (ValueError, IndexError): + continue + + if best_fs: + return best_fs + + except Exception: + pass + + # Final fallback + return '/' + + @app.route('/api/system-stats') @requires_auth def get_system_stats(): @@ -1564,8 +1955,9 @@ def get_system_stats(): try: stats = {} - # Disk usage - disk_result = subprocess.run(['df', '-h', '/'], capture_output=True, text=True) + # Disk usage - use detected root filesystem + root_fs = get_root_filesystem() + disk_result = subprocess.run(['df', '-h', root_fs], capture_output=True, text=True) disk_lines = disk_result.stdout.strip().split('\n') if len(disk_lines) > 1: disk_parts = disk_lines[1].split() @@ -1676,12 +2068,181 @@ def get_system_stats(): 'interfaces': interfaces } + # LVM Physical Volume information + lvm_info = get_lvm_pv_info() + if lvm_info: + stats['lvm'] = lvm_info + return jsonify(stats) except Exception as e: return jsonify({'error': f'Error gathering system statistics: {str(e)}'}), 500 +@app.route('/api/bootc-status') +@requires_auth +def get_bootc_status(): + """API endpoint to get BootC status and upgrade check information.""" + try: + status_output = '' + upgrade_check_output = '' + + # Get bootc status + try: + status_result = subprocess.run( + ['bootc', 'status'], + capture_output=True, + text=True, + timeout=10 + ) + if status_result.returncode == 0: + status_output = status_result.stdout.strip() + else: + status_output = f"Error: {status_result.stderr.strip()}" + except FileNotFoundError: + status_output = "bootc command not found" + except subprocess.TimeoutExpired: + status_output = "Command timed out" + except Exception as e: + status_output = f"Error: {str(e)}" + + # Get upgrade check + try: + upgrade_result = subprocess.run( + ['bootc', 'upgrade', '--check'], + capture_output=True, + text=True, + timeout=30 + ) + if upgrade_result.returncode == 0: + upgrade_check_output = upgrade_result.stdout.strip() + else: + upgrade_check_output = f"Error: {upgrade_result.stderr.strip()}" + except FileNotFoundError: + upgrade_check_output = "bootc command not found" + except subprocess.TimeoutExpired: + upgrade_check_output = "Command timed out" + except Exception as e: + upgrade_check_output = f"Error: {str(e)}" + + return jsonify({ + 'status': status_output, + 'upgrade_check': upgrade_check_output + }) + + except Exception as e: + return jsonify({'error': f'Error getting BootC status: {str(e)}'}), 500 + + +@app.route('/api/bootc-upgrade-check', methods=['POST']) +@requires_auth +def bootc_upgrade_check(): + """API endpoint to check for BootC upgrades.""" + try: + result = subprocess.run( + ['bootc', 'upgrade', '--check'], + capture_output=True, + text=True, + timeout=30 + ) + + if result.returncode == 0: + return jsonify({ + 'success': True, + 'output': result.stdout.strip(), + 'message': 'Upgrade check completed' + }) + else: + return jsonify({ + 'success': False, + 'error': result.stderr.strip() or 'Upgrade check failed' + }), 400 + + except FileNotFoundError: + return jsonify({'success': False, 'error': 'bootc command not found'}), 404 + except subprocess.TimeoutExpired: + return jsonify({'success': False, 'error': 'Command timed out'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': f'Error: {str(e)}'}), 500 + + +@app.route('/api/bootc-upgrade', methods=['POST']) +@requires_auth +def bootc_upgrade(): + """API endpoint to apply BootC upgrade.""" + try: + # Run bootc upgrade (this may take a while) + result = subprocess.run( + ['bootc', 'upgrade'], + capture_output=True, + text=True, + timeout=600 # 10 minutes timeout for upgrade + ) + + if result.returncode == 0: + return jsonify({ + 'success': True, + 'output': result.stdout.strip(), + 'message': 'Upgrade completed successfully. Reboot may be required.' + }) + else: + return jsonify({ + 'success': False, + 'error': result.stderr.strip() or 'Upgrade failed' + }), 400 + + except FileNotFoundError: + return jsonify({'success': False, 'error': 'bootc command not found'}), 404 + except subprocess.TimeoutExpired: + return jsonify({'success': False, 'error': 'Command timed out (upgrade may still be in progress)'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': f'Error: {str(e)}'}), 500 + + +@app.route('/api/bootc-switch', methods=['POST']) +@requires_auth +def bootc_switch(): + """API endpoint to switch BootC to a different image.""" + try: + data = request.get_json() if request.is_json else {} + image = data.get('image', '').strip() + + if not image: + return jsonify({'success': False, 'error': 'Image reference is required'}), 400 + + # Validate image format (basic check) + if not (image.startswith('quay.io/') or image.startswith('docker.io/') or + ':' in image or '/' in image): + return jsonify({'success': False, 'error': 'Invalid image reference format'}), 400 + + # Run bootc switch (this may take a while) + result = subprocess.run( + ['bootc', 'switch', image], + capture_output=True, + text=True, + timeout=600 # 10 minutes timeout for switch + ) + + if result.returncode == 0: + return jsonify({ + 'success': True, + 'output': result.stdout.strip(), + 'message': f'Switched to {image} successfully. Reboot may be required.' + }) + else: + return jsonify({ + 'success': False, + 'error': result.stderr.strip() or 'Switch failed' + }), 400 + + except FileNotFoundError: + return jsonify({'success': False, 'error': 'bootc command not found'}), 404 + except subprocess.TimeoutExpired: + return jsonify({'success': False, 'error': 'Command timed out (switch may still be in progress)'}), 500 + except Exception as e: + return jsonify({'success': False, 'error': f'Error: {str(e)}'}), 500 + + @app.route('/api/dmesg') @requires_auth def get_dmesg(): From 7a9a93c95b60d89230b6b9f973bce6ff110c2d33 Mon Sep 17 00:00:00 2001 From: Miguel Angel Ajo Pelayo Date: Tue, 16 Dec 2025 21:05:01 +0000 Subject: [PATCH 6/6] microshif-bootc: switch to cs10 and add multi-arch builds --- deploy/microshift-bootc/Containerfile | 3 +-- deploy/microshift-bootc/Makefile | 35 +++++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/deploy/microshift-bootc/Containerfile b/deploy/microshift-bootc/Containerfile index f242ee69..7226c784 100644 --- a/deploy/microshift-bootc/Containerfile +++ b/deploy/microshift-bootc/Containerfile @@ -1,5 +1,4 @@ -FROM ghcr.io/microshift-io/microshift:release-4.20-4.20.0-okd-scos.9 - +FROM ghcr.io/microshift-io/microshift:4.21.0_gbc8e20c07_4.21.0_okd_scos.ec.14 # Install dependencies for config-svc RUN dnf install -y epel-release && \ dnf install -y python3 iproute python3-flask python3-pip && \ diff --git a/deploy/microshift-bootc/Makefile b/deploy/microshift-bootc/Makefile index cc09935b..4db7157c 100644 --- a/deploy/microshift-bootc/Makefile +++ b/deploy/microshift-bootc/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build bootc-build push bootc-push bootc-run bootc-stop bootc-sh bootc-rm build-image build-iso +.PHONY: help build bootc-build bootc-build-multi push bootc-push bootc-push-multi bootc-run bootc-stop bootc-sh bootc-rm build-image build-iso build-all build-all-multi push-all push-all-multi # Default image tags BOOTC_IMG ?= quay.io/jumpstarter-dev/microshift/bootc:latest @@ -15,8 +15,28 @@ bootc-build: ## Build the bootc image with MicroShift @echo "Building bootc image: $(BOOTC_IMG): building as root to be on the container storage from root" sudo podman build -t $(BOOTC_IMG) -f Containerfile ../.. +bootc-build-multi: ## Build the bootc image for multiple architectures (amd64, arm64) + @echo "Building multiarch bootc image: $(BOOTC_IMG)" + @echo "This will build for linux/amd64 and linux/arm64" + @# Remove existing manifest if it exists + -podman manifest rm $(BOOTC_IMG) 2>/dev/null || true + @# Create a new manifest + podman manifest create $(BOOTC_IMG) + @# Build for amd64 + @echo "Building for linux/amd64..." + podman build --platform linux/amd64 -t $(BOOTC_IMG)-amd64 -f Containerfile ../.. + @# Build for arm64 + @echo "Building for linux/arm64..." + podman build --platform linux/arm64 -t $(BOOTC_IMG)-arm64 -f Containerfile ../.. + @# Add both images to the manifest + podman manifest add $(BOOTC_IMG) $(BOOTC_IMG)-amd64 + podman manifest add $(BOOTC_IMG) $(BOOTC_IMG)-arm64 + @echo "Multiarch manifest created successfully!" + @echo "To inspect: podman manifest inspect $(BOOTC_IMG)" + @echo "To push: make bootc-push-multi" + output/qcow2/disk.qcow2: ## Build a bootable QCOW2 image from the bootc image - @echo "Building QCOW2 image from: $(BOOTC_IMG)" + @echo "Building QCOW2 image from: $(BOOTC_IMG)"a @echo "Running bootc-image-builder..." @mkdir -p output sudo podman run \ @@ -79,12 +99,23 @@ bootc-push: ## Push the bootc image to registry @echo "Pushing bootc image: $(BOOTC_IMG)" sudo podman push $(BOOTC_IMG) +bootc-push-multi: ## Push the multiarch manifest to registry + @echo "Pushing multiarch manifest: $(BOOTC_IMG)" + @echo "This will push the manifest list with amd64 and arm64 images" + podman manifest push $(BOOTC_IMG) $(BOOTC_IMG) + @echo "Multiarch manifest pushed successfully!" + @echo "Images available for linux/amd64 and linux/arm64" + ##@ Development build-all: bootc-build ## Build bootc image +build-all-multi: bootc-build-multi ## Build multiarch bootc image + push-all: bootc-push ## Push bootc image to registry +push-all-multi: bootc-push-multi ## Push multiarch bootc image to registry + bootc-run: ## Run MicroShift in a bootc container @echo "Running MicroShift container with image: $(BOOTC_IMG)" @BOOTC_IMG=$(BOOTC_IMG) sudo -E ./run-microshift.sh