跳转到正文
403F's Cafe
返回

ICPC 西安邀请赛 2026 总结

ICPC 西安邀请赛 2026 总结 封面图

头图: Xenomorph 招かれざる弈者

来源标注不代表获得使用授权,图片权利归原权利人所有。

目录

目录

优化脚本

Excel随机密码生成

修正了随机性问题,确保没有重复

VBA代码
Option Explicit

Private GeneratedPasswords As Collection
Private IsRandomized As Boolean

Function RandomPassword(length As Integer) As String
    Dim chars As String
    Dim i As Integer
    Dim result As String
    Dim attempts As Long
    
    ' 参数校验
    If length < 1 Or length > 128 Then
        RandomPassword = "ERROR: 密码长度必须在 1~128 之间"
        Exit Function
    End If
    
    ' 仅首次初始化随机种子
    If Not IsRandomized Then
        Randomize
        IsRandomized = True
    End If
    
    ' 初始化集合
    If GeneratedPasswords Is Nothing Then
        Set GeneratedPasswords = New Collection
    End If
    
    Const charsSet As String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
    chars = charsSet
    
    attempts = 0
    On Error Resume Next          ' 只设置一次
    
    Do
        attempts = attempts + 1
        
        result = ""
        For i = 1 To length
            result = result & Mid(chars, Int(Rnd() * Len(chars)) + 1, 1)
        Next i
        
        GeneratedPasswords.Add result, result
        
        If Err.Number = 0 Then
            Exit Do
        Else
            Err.Clear
        End If
        
        If attempts > 10000 Then
            On Error GoTo 0
            Err.Raise vbObjectError + 513, "RandomPassword", _
                "在10000次尝试内未能生成不重复的密码,请检查长度参数。"
        End If
    Loop
    
    On Error GoTo 0
    RandomPassword = result
End Function

' 重置函数(可选)
Sub ResetPasswordGenerator()
    Set GeneratedPasswords = Nothing
    IsRandomized = False
    MsgBox "密码生成器已重置。", vbInformation
End Sub

打印脚本 (DomJudge)

参考代码
#!/usr/bin/env python3
import subprocess
import sys
import tempfile
import os
import random
import re
from datetime import datetime

# 允许的文件 MIME 类型映射到 Typst 语法高亮名称
ALLOWED_MIME = {
    "text/x-c": "C",
    "text/x-c++": "Cpp",
    "text/x-java-source": "Java",
    "text/x-python": "Python",
    "text/x-script.python": "Python",
    "text/plain": "Python",  # 有些系统将 Python 标记为 text/plain
}

# 打印机 IP 地址列表
PRINTER_IPS = ['10.12.13.231','10.12.13.232','10.12.13.233','10.12.13.234']
#PRINTER_IPS = ['10.12.13.233']

OUTPUT_DIR = "/opt/domjudge/print_backup"

def escape_typst_string(s: str) -> str:
    if s is None:
        return ""
    return s.replace("\\", "\\\\").replace('"', '\\"')


def typst_text(s: str) -> str:
    return f'#text("{escape_typst_string(s)}")'


def raw_block(content: str, lang: str) -> str:
    longest = max((len(match.group(0)) for match in re.finditer(r"`+", content)), default=0)
    fence = "`" * max(3, longest + 1)
    return f"{fence}{lang}\n{content}\n{fence}"


def main():
    # 检查输入参数数量。仅在参数不足时输出简短错误信息。
    if len(sys.argv) < 8:
        print("Error: Missing required script arguments.")
        sys.exit(1)

    file_path, original, language, username, teamname, teamid, location = sys.argv[1:8]
    teamname_text = typst_text(f"Team Name: {teamname}")
    location_text = typst_text(f"Location: {location}")
    original_text = typst_text(f"Source Code: {original}")

    # --- 步骤 1: 检查 MIME 类型 ---
    try:
        # 检测文件 MIME 类型
        mime_type = subprocess.check_output(["file", "-b", "--mime-type", file_path], text=True).strip()
    except subprocess.CalledProcessError:
        print("Error: Failed to detect file type.")
        sys.exit(1)
    except FileNotFoundError:
        print("Error: Required 'file' command not found.")
        sys.exit(1)

    if mime_type not in ALLOWED_MIME:
        print("Error: Unsupported file type. Printing denied.")
        sys.exit(1)

    lang_detected = ALLOWED_MIME[mime_type]

    # --- 步骤 2: 确保输出目录存在 ---
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    # --- 步骤 3: 生成 Typst 模板并编译 PDF ---
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    pdf_filename = f"team{teamid}_{timestamp}.pdf"
    pdf_file = os.path.join(OUTPUT_DIR, pdf_filename)
    with tempfile.TemporaryDirectory() as tmpdir:
        typst_file = os.path.join(tmpdir, "print.typ")
        # 读取源代码内容
        try:
            with open(file_path, "r", encoding="utf-8", errors="ignore") as src:
                code = src.read()
        except IOError:
            print("Error: Failed to read source file.")
            sys.exit(1)

        code_block = raw_block(code, lang_detected.lower())

        # 构建 Typst 内容
        typst_content = f"""
#set text(font: "Noto Sans CJK SC", size: 9pt)
#set page(
    header: [
        #text(8pt, [{teamname_text}])
        #h(1fr)
        #text(8pt, [{location_text}])
        #line(length: 100%)
    ],
    margin: (x: 1cm, y: 1.5cm)
)

= {original_text}

{code_block}
"""
        # 写入临时文件供编译
        with open(typst_file, "w", encoding="utf-8") as f:
            f.write(typst_content)
        # 编译 Typst 到 PDF
        try:
            # 确保 'typst-cli' 已安装并配置在 PATH 中
            # 成功时不输出任何信息
            typst_cmd = ["/usr/local/bin/typst", "compile", typst_file, pdf_file]
            subprocess.run(typst_cmd, check=True, capture_output=True, text=True)
        except subprocess.CalledProcessError as e:
            print("Error: Source code compilation failed.")
            print(f"Temp directory: {tmpdir}")
            print(f"Typst file: {typst_file}")
            print(f"Typst file exists: {os.path.exists(typst_file)}")
            if os.path.exists(typst_file):
                print(f"Typst file size: {os.path.getsize(typst_file)}")
            print(f"Temp directory entries: {os.listdir(tmpdir)}")
            print(f"Output PDF: {pdf_file}")
            print(f"Command: {' '.join(typst_cmd)}")
            print(f"Return code: {e.returncode}")
            if e.stdout:
                print(f"Typst stdout:\n{e.stdout}")
            if e.stderr:
                print(f"Typst stderr:\n{e.stderr}")
            sys.exit(1)
        except FileNotFoundError:
            print("Error: Required 'typst' command not found.")
            sys.exit(1)

    # --- 步骤 4: 随机选择打印机并推送打印任务 ---
    
    if not PRINTER_IPS:
        print("Error: No printer addresses configured.")
        sys.exit(1)

    chosen_ip = random.choice(PRINTER_IPS)
    curl_url = f'http://{chosen_ip}:12306/'
    # 移除:print(f"Selected printer IP: {chosen_ip}")

    # 构建 curl 命令
    curl_cmd = [
        'curl',
        '-X', 'POST',
        '--fail-with-body', 
        '--connect-timeout', '10', 
        '--max-time', '30',        
        '-H', 'Content-Type: application/octet-stream',
        '--data-binary', f'@{pdf_file}', 
        curl_url
    ]

    try:
        # 执行 curl 命令
        print_result = subprocess.run(
            curl_cmd, 
            check=True, 
            capture_output=True, 
            text=True
        )
        # --- 步骤 5: 输出最终结果 ---
        # 成功时,仅输出主要的成功信息
        print(f"✅ Print job successfully dispatched")
        # 移除:打印机响应的详细 stdout/stderr
        
    except subprocess.CalledProcessError as e:
        # 打印任务失败,输出简洁的错误信息和服务器响应(如果存在)
        server_response = e.stdout.strip()
        if server_response:
            print(f"❌ Print job failed to dispatch. Server responded: {server_response}")
        else:
            print(f"❌ Print job failed to dispatch. Check network connection or printer status.")
        sys.exit(1)
        
    except FileNotFoundError:
        print("Error: Required 'curl' command not found.")
        sys.exit(1)

if __name__ == "__main__":
    main()

打印脚本 (Client)

需要下一个SumatraPDF.exe放入对应的位置,这样可以避免打印机不支持直接打印raw PDF stream的时候卡死在任务队列中。

参考代码
import secrets
import shutil
import subprocess
import time
from pathlib import Path
from wsgiref.simple_server import make_server

BASE_DIR = Path(__file__).resolve().parent
SUMATRA_PATH = BASE_DIR / "SumatraPDF" / "SumatraPDF.exe"
LOG_FILE = BASE_DIR / "Print.log"
SUCCESS_DIR = BASE_DIR / "Success"
ERROR_DIR = BASE_DIR / "Error"
TEMP_DIR = BASE_DIR / "Temp"
CHARSET = "23456789qwertyupasdfghjkzxcvbnm"
PORT = 12306


def random_filename(length=10):
    return "".join(secrets.choice(CHARSET) for _ in range(length)) + ".pdf"


def print_target_file(filename):
    try:
        filename = filename.resolve()
        print(f"PDF path before print: {filename}")
        print(f"PDF exists before print: {filename.exists()}")
        print(f"PDF size before print: {filename.stat().st_size if filename.exists() else 'missing'}")
        print(f"SumatraPDF path: {SUMATRA_PATH}")
        print(f"SumatraPDF exists: {SUMATRA_PATH.exists()}")

        print_cmd = [
            str(SUMATRA_PATH),
            "-print-to-default",
            "-silent",
            str(filename),
        ]
        result = subprocess.run(
            print_cmd,
            cwd=BASE_DIR,
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            print(f"Print failed for {filename.name} with return code {result.returncode}")
            print(f"Print command: {print_cmd!r}")
            if result.stdout:
                print(f"SumatraPDF stdout:\n{result.stdout}")
            if result.stderr:
                print(f"SumatraPDF stderr:\n{result.stderr}")
            return False

        print(f"Submitted print job for {filename.name}")
        return True
    except Exception as exc:
        print(f"Print failed for {filename.name}: {exc}")
        return False


def log_status(filename, success):
    target_dir = SUCCESS_DIR if success else ERROR_DIR
    target_dir.mkdir(exist_ok=True)
    current_date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
    status = "Success" if success else "Failed"

    with LOG_FILE.open("a", encoding="utf-8") as log_file:
        log_file.write(f"{current_date} {status}: {filename.name}\n")

    try:
        shutil.copy2(filename, target_dir / filename.name)
    except OSError as exc:
        with LOG_FILE.open("a", encoding="utf-8") as log_file:
            log_file.write(f"{current_date} Copy failed: {filename.name}: {exc}\n")


def read_content_length(environ):
    try:
        return max(0, int(environ.get("CONTENT_LENGTH") or 0))
    except ValueError:
        return 0


def application(environ, start_response):
    request_length = read_content_length(environ)
    TEMP_DIR.mkdir(exist_ok=True)
    filename = TEMP_DIR / random_filename()

    with filename.open("wb") as output_file:
        output_file.write(environ["wsgi.input"].read(request_length))

    success = print_target_file(filename)
    log_status(filename, success)

    start_response("200 OK", [("Content-Type", "text/plain; charset=utf-8")])
    return [b"Successful."]


if __name__ == "__main__":
    httpd = make_server("0.0.0.0", PORT, application)
    print(f"serving http on port {PORT}...")
    httpd.serve_forever()

压测

此次正赛前用新写的Onyx压测工具对全流程做了压力测试,效果不错能很显著的预先发现问题。
DomJudge官方的压测工具都好几年没更新了,而且还是scala写的,很难绷。

滚榜

此次滚榜首次在生产环境测试了新写的Pyrite滚榜,目前来看效果还可以,详细的使用教程可以见GitHub上的README。
目前现在残余的问题有如下几个:

选手机相关

由于这次完全切换到了2026新版镜像,Natsume以及配套的client configure脚本等都有较多的修正,以及为了适配Brotli压缩进而搞得本地TLS终结。
详细的可见后续更新过的Natsume README,或者看最近的commit log。

Contest Live Deploy - 完整制作流程

从 raw disk image 制作 Debian Live USB 部署盘,支持空盘和双系统(Windows+Linux)场景,关键步骤需人工确认。

前置条件

Step 0: 安装依赖

sudo apt update  
sudo apt install -y \
  live-build \
  squashfs-tools \
  debootstrap \
  kpartx \
  dosfstools \
  parted

Step 1: 从 raw image 中提取 rootfs 并打包为 squashfs

提取rootfs并打包squashfs
# 创建工作目录
mkdir -p ~/contest-work && cd ~/contest-work

# 挂载 raw image
# kpartx 会自动解析分区表并创建 /dev/mapper/loopXpN 设备
sudo kpartx -av /path/to/contest.img

# 查看分区映射,找到 Linux root 分区(通常是最大的 ext4 分区)
lsblk -f /dev/loop0    # 确认哪个分区是 rootfs

# 挂载 rootfs 分区(loop0p4 是 ext4 rootfs)
sudo mkdir -p /mnt/img-root
sudo mount /dev/mapper/loop0p4 /mnt/img-root

# 确认挂载内容正确
ls /mnt/img-root/
# 应该能看到 bin/ etc/ home/ usr/ var/ 等标准 Linux 目录结构

# 打包为 squashfs(排除不需要的目录)
sudo mksquashfs /mnt/img-root filesystem.squashfs \
  -comp zstd \
  -Xcompression-level 19 \
  -e boot/efi \
  -e proc \
  -e sys \
  -e dev \
  -e run \
  -e tmp \
  -e lost+found \
  -e swap.img \
  -progress

# 完成后取消挂载
sudo umount /mnt/img-root
sudo kpartx -dv /path/to/contest.img

echo "squashfs 大小:"
ls -lh filesystem.squashfs

注意:如果 image 中的 rootfs 不是 loop0p4,请根据 lsblk -f /dev/loop0 的输出调整。 如果压缩速度太慢或目标机器解压慢,可以换用 -comp lz4 牺牲压缩率换速度。

Step 2: 初始化 live-build 项目

初始化live-build项目
mkdir -p ~/contest-live && cd ~/contest-live

# 如果是全新开始,清理旧状态(保留 cache)
sudo lb clean 2>/dev/null

lb config \
  --distribution bookworm \
  --archive-areas "main contrib non-free non-free-firmware" \
  --mirror-bootstrap "https://mirrors.tuna.tsinghua.edu.cn/debian/" \
  --mirror-chroot "https://mirrors.tuna.tsinghua.edu.cn/debian/" \
  --mirror-binary "https://mirrors.tuna.tsinghua.edu.cn/debian/" \
  --mirror-chroot-security "https://mirrors.tuna.tsinghua.edu.cn/debian-security/" \
  --mirror-binary-security "https://mirrors.tuna.tsinghua.edu.cn/debian-security/" \
  --debian-installer none \
  --memtest none \
  --binary-images iso-hybrid \
  --bootloaders grub-efi \
  --architectures amd64 \
  --linux-flavours amd64 \
  --apt-indices false \
  --cache true

Step 3: 添加需要的软件包

cat > config/package-lists/contest.list.chroot <<'EOF'
parted
dosfstools
e2fsprogs
squashfs-tools
grub-efi-amd64
grub-efi-amd64-bin
arch-install-scripts
os-prober
pciutils
usbutils
EOF

Step 4: 放入 squashfs 镜像

mkdir -p config/includes.chroot/usr/local/share/icpc/

ln ~/contest-work/filesystem.squashfs \
   config/includes.chroot/usr/local/share/icpc/filesystem.squashfs

Step 5: 创建安装脚本

由于需要人工交互确认,不适合放在 live-config hook(hook 在后台执行,无 TTY)。 改为:脚本放入 PATH,通过 autologin + .bash_profile 在登录时自动启动。

5a: 安装脚本

contest-install安装脚本
mkdir -p config/includes.chroot/usr/local/bin/

cat > config/includes.chroot/usr/local/bin/contest-install <<'SCRIPTEOF'
#!/bin/bash
set -euo pipefail

# ============================================================
# Contest Image Installer
# Supports:
#   - empty disk:  create full GPT (EFI + root)
#   - dual-boot:   preserve Windows, overwrite Linux partition
# Interactive: key steps require manual confirmation
# ============================================================

SQUASHFS="/usr/local/share/icpc/filesystem.squashfs"
MOUNT_TARGET="/mnt/target"

# ── Colors ──────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

log()  { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err()  { echo -e "${RED}[ERROR]${NC} $*"; }
banner() {
    echo ""
    echo -e "${CYAN}${BOLD}══════════════════════════════════════════${NC}"
    echo -e "${CYAN}${BOLD}  $*${NC}"
    echo -e "${CYAN}${BOLD}══════════════════════════════════════════${NC}"
    echo ""
}

# ── Confirm prompt ──────────────────────────────
confirm() {
    local msg="$1"
    echo ""
    echo -e "${YELLOW}${BOLD}WARNING: $msg${NC}"
    read -rp "  Type 'yes' to continue, anything else to abort: " answer
    if [[ "$answer" != "yes" ]]; then
        err "Aborted by user."
        exit 1
    fi
    echo ""
}

# ── Preflight ───────────────────────────────────
[ "$EUID" -eq 0 ] || { err "Must run as root"; exit 1; }
[ -f "$SQUASHFS" ] || { err "squashfs not found: $SQUASHFS"; exit 1; }

banner "Contest Image Installer"

# ════════════════════════════════════════════════
# PHASE 1: 选择目标磁盘
# ════════════════════════════════════════════════

# 检测 Live 介质
LIVE_DEV=$(lsblk -ndo PKNAME "$(findmnt -no SOURCE /run/live/medium 2>/dev/null)" 2>/dev/null || true)
[ -n "$LIVE_DEV" ] && LIVE_DEV="/dev/$LIVE_DEV"

log "Available disks:"
echo ""
echo "  ┌──────────────────────────────────────────────────────┐"
lsblk -dpo NAME,SIZE,MODEL,TRAN | while IFS= read -r line; do
    dev=$(echo "$line" | awk '{print $1}')
    if [[ "$dev" == "$LIVE_DEV" ]]; then
        echo -e "  │ ${line}  ${YELLOW}← Live USB${NC}"
    else
        echo "  │ $line"
    fi
done
echo "  └──────────────────────────────────────────────────────┘"
echo ""

while true; do
    read -rp "Enter target disk (e.g. sda, nvme0n1): " TARGET_DISK
    if [[ ! -b "/dev/$TARGET_DISK" ]]; then
        err "Device /dev/$TARGET_DISK does not exist, try again"
        continue
    fi
    if [[ "/dev/$TARGET_DISK" == "$LIVE_DEV" ]]; then
        err "Cannot select the Live USB medium, try again"
        continue
    fi
    break
done

log "Target disk: /dev/$TARGET_DISK"

# ════════════════════════════════════════════════
# PHASE 2: 分析分区 — 判断空盘 or 双系统
# ════════════════════════════════════════════════

banner "Analyzing Partitions"

EFI_PART=""
LINUX_ROOT_PART=""
WINDOWS_PARTS=()
DISK_MODE=""  # "dual-boot" | "empty"

PART_COUNT=$(lsblk -lno NAME "/dev/$TARGET_DISK" | tail -n +2 | wc -l)

if [[ "$PART_COUNT" -eq 0 ]]; then
    DISK_MODE="empty"
    warn "Disk is empty (no partition table)"
else
    log "Current partition layout:"
    echo ""
    lsblk -o NAME,SIZE,FSTYPE,PARTLABEL,PARTTYPE "/dev/$TARGET_DISK"
    echo ""

    while IFS= read -r part; do
        devpath="/dev/$part"
        part_type=$(blkid -s PART_ENTRY_TYPE -o value "$devpath" 2>/dev/null || true)
        fs_type=$(blkid -s TYPE -o value "$devpath" 2>/dev/null || true)

        # EFI System Partition
        if [[ "${part_type^^}" == "C12A7328-F81F-11D2-BA4B-00A0C93EC93B" ]]; then
            EFI_PART="$devpath"
            log "  EFI partition: $EFI_PART ($fs_type)"
            continue
        fi

        # Microsoft 分区 (by GUID) — but check actual filesystem first
        case "${part_type^^}" in
            EBD0A0A2-B9E5-4433-87C0-68B6B72699C7|\
            E3C9E316-0B5C-4DB8-817D-F92DF00215AE|\
            DE94BBA4-06D1-4D40-A16A-BFD50179D6AC)
                # ext4/ext3/xfs/btrfs with MS GUID = Linux target (OEM quirk)
                if [[ "$fs_type" =~ ^(ext[234]|xfs|btrfs)$ && -z "$LINUX_ROOT_PART" ]]; then
                    LINUX_ROOT_PART="$devpath"
                    log "  Linux partition (MS GUID): $LINUX_ROOT_PART ($fs_type) — ${RED}WILL FORMAT${NC}"
                else
                    WINDOWS_PARTS+=("$devpath")
                    log "  Windows partition: $devpath ($fs_type) — ${GREEN}KEEP${NC}"
                fi
                continue
                ;;
        esac

        # NTFS/exFAT — 视为 Windows
        if [[ "$fs_type" == "ntfs" || "$fs_type" == "exfat" ]]; then
            WINDOWS_PARTS+=("$devpath")
            log "  NTFS/exFAT partition: $devpath — ${GREEN}KEEP${NC}"
            continue
        fi

        # Linux 分区 (by standard Linux GUID) — 取第一个作为目标
        if [[ -z "$LINUX_ROOT_PART" ]]; then
            case "${part_type^^}" in
                0FC63DAF-8483-4772-8E79-3D69D8477DE4|\
                4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709)
                    LINUX_ROOT_PART="$devpath"
                    log "  Linux partition: $LINUX_ROOT_PART ($fs_type) — ${RED}WILL FORMAT${NC}"
                    ;;
            esac
        fi

        # Other partitions (swap, OEM recovery, etc.) — skip silently
        if [[ -n "$fs_type" ]]; then
            log "  Other partition: $devpath ($fs_type) — ${GREEN}KEEP${NC}"
        fi
    done < <(lsblk -lno NAME "/dev/$TARGET_DISK" | tail -n +2)

    # 判断模式: 有 EFI + 有 Linux 分区 → dual-boot (preserve other partitions)
    if [[ -n "$LINUX_ROOT_PART" && -n "$EFI_PART" ]]; then
        DISK_MODE="dual-boot"
    else
        DISK_MODE="empty"
        warn "No EFI + Linux partition found, treating as empty disk"
    fi
fi

log "Disk mode: $DISK_MODE"

# ════════════════════════════════════════════════
# PHASE 3: 确定分区策略 + 人工确认
# ════════════════════════════════════════════════

banner "Partition Strategy"

case "$DISK_MODE" in
    dual-boot)
        log "Strategy: Preserve Windows, overwrite Linux partition only"
        log "  EFI  : $EFI_PART (keep, no format)"
        log "  Root : $LINUX_ROOT_PART (will format as ext4)"
        log "  Windows partitions (${#WINDOWS_PARTS[@]}): all preserved"
        confirm "Format $LINUX_ROOT_PART ? (Windows partitions untouched)"
        ;;

    empty)
        log "Strategy: Create new GPT partition table"
        log "  Partition 1: EFI System Partition (512 MiB, FAT32)"
        log "  Partition 2: Linux root (all remaining space, ext4)"
        confirm "Create new partition table on /dev/$TARGET_DISK ? (ALL data will be lost)"
        ;;
esac

# ════════════════════════════════════════════════
# PHASE 4: 执行分区
# ════════════════════════════════════════════════

banner "Partitioning"

if [[ "$DISK_MODE" == "empty" ]]; then
    log "Creating GPT partition table..."
    parted -s "/dev/$TARGET_DISK" mklabel gpt
    parted -s "/dev/$TARGET_DISK" mkpart ESP fat32 1MiB 513MiB
    parted -s "/dev/$TARGET_DISK" set 1 esp on
    parted -s "/dev/$TARGET_DISK" mkpart contest-root ext4 513MiB 100%
    sfdisk --part-type "/dev/$TARGET_DISK" 2 4F68BCE3-E8CD-4DB1-96E7-FBCAF984B709
    partprobe "/dev/$TARGET_DISK"
    udevadm settle

    if [[ "$TARGET_DISK" == nvme* ]]; then
        EFI_PART="/dev/${TARGET_DISK}p1"
        LINUX_ROOT_PART="/dev/${TARGET_DISK}p2"
    else
        EFI_PART="/dev/${TARGET_DISK}1"
        LINUX_ROOT_PART="/dev/${TARGET_DISK}2"
    fi

    log "Formatting EFI partition..."
    mkfs.fat -F32 "$EFI_PART"
fi

# 格式化 Linux root 分区
log "Formatting $LINUX_ROOT_PART as ext4..."
umount "$LINUX_ROOT_PART" 2>/dev/null || true
mkfs.ext4 -F -L "contest-root" "$LINUX_ROOT_PART"

# 显示最终分区布局
log "Final partition layout:"
lsblk -o NAME,SIZE,FSTYPE,PARTLABEL "/dev/$TARGET_DISK"

confirm "Partitioning done. Continue with system installation?"

# ════════════════════════════════════════════════
# PHASE 5: 解压系统
# ════════════════════════════════════════════════

banner "Extracting System Files"

mkdir -p "$MOUNT_TARGET"
mount "$LINUX_ROOT_PART" "$MOUNT_TARGET"

log "Extracting squashfs (this may take a few minutes)..."
unsquashfs -f -d "$MOUNT_TARGET" "$SQUASHFS"

# 确保关键目录存在
mkdir -p "$MOUNT_TARGET"/{proc,sys,dev,run,tmp,boot/efi}

log "Extraction complete"
log "Disk usage:"
df -h "$MOUNT_TARGET" | tail -1

# ════════════════════════════════════════════════
# PHASE 6: 安装 bootloader
# ════════════════════════════════════════════════

banner "Installing GRUB Bootloader"

mount "$EFI_PART" "$MOUNT_TARGET/boot/efi"

# 生成 fstab
log "Generating fstab..."
cat > "$MOUNT_TARGET/etc/fstab" <<EOF
# Auto-generated by contest-image-install
EOF
genfstab -U "$MOUNT_TARGET" >> "$MOUNT_TARGET/etc/fstab"

log "fstab contents:"
cat "$MOUNT_TARGET/etc/fstab"
echo ""

log "Installing GRUB..."
PATH=/usr/sbin:$PATH arch-chroot "$MOUNT_TARGET" \
    grub-install --target=x86_64-efi \
                 --efi-directory=/boot/efi \
                 --bootloader-id=GRUB \
                 --no-nvram

PATH=/usr/sbin:$PATH arch-chroot "$MOUNT_TARGET" update-grub

log "GRUB installed"

# ════════════════════════════════════════════════
# PHASE 7: Cleanup
# ════════════════════════════════════════════════

banner "Installation Complete"

umount "$MOUNT_TARGET/boot/efi" 2>/dev/null || true
umount "$MOUNT_TARGET" 2>/dev/null || true
sync

echo -e "${GREEN}${BOLD}"
echo "  Installation complete"
echo "  Disk: /dev/$TARGET_DISK"
echo "  EFI:  $EFI_PART"
echo "  Root: $LINUX_ROOT_PART"
if [[ ${#WINDOWS_PARTS[@]} -gt 0 ]]; then
    echo "  Windows partitions preserved (${#WINDOWS_PARTS[@]})"
fi
echo -e "${NC}"
echo ""
read -rp "Press Enter to reboot, or Ctrl+C to stay in Live environment: "
reboot
SCRIPTEOF
chmod +x config/includes.chroot/usr/local/bin/contest-install

5b: 自动登录并启动脚本

Debian Live 的 live-config 会创建默认用户 user 并自动登录,因此:

# 默认用户的 .bash_profile — 登录时自动运行安装脚本
mkdir -p config/includes.chroot/etc/skel/

cat > config/includes.chroot/etc/skel/.bash_profile <<'EOF'
# Auto-launch contest installer on first login
if [ -z "$CONTEST_INSTALL_LAUNCHED" ]; then
    export CONTEST_INSTALL_LAUNCHED=1
    echo ""
    echo "======================================"
    echo "  Contest Image Installer"
    echo "  wait for auto-start..."
    echo "======================================"
    echo ""
    sudo contest-install
fi
EOF

配置免密 sudo(让默认用户可以无密码执行安装脚本):

mkdir -p config/includes.chroot/etc/sudoers.d/

cat > config/includes.chroot/etc/sudoers.d/contest-install <<'EOF'
# Allow live user to run contest-install without password
user ALL=(root) NOPASSWD: /usr/local/bin/contest-install
EOF

chmod 440 config/includes.chroot/etc/sudoers.d/contest-install

Step 6: 配置 GRUB 启动菜单

mkdir -p config/includes.binary/boot/grub/

cat > config/includes.binary/boot/grub/grub.cfg <<'EOF'
set default=0
set timeout=5

menuentry "Contest Installer" {
    linux  /live/vmlinuz-* boot=live components quiet
    initrd /live/initrd.img-*
}

menuentry "Contest Installer (Debug - verbose)" {
    linux  /live/vmlinuz-* boot=live components
    initrd /live/initrd.img-*
}
EOF

Step 7: 构建 ISO

cd ~/contest-live
sudo lb build 2>&1 | tee build.log

构建时间取决于网络速度和 squashfs 大小,通常 10-30 分钟。 产出文件:live-image-amd64.hybrid.iso

Step 8: 写入 U 盘

# 确认 U 盘设备(请根据实际情况替换 sdX)
lsblk

# 写入(iso-hybrid 格式可以直接 dd)
sudo dd if=live-image-amd64.hybrid.iso of=/dev/sdX bs=4M status=progress oflag=sync

需要批量部署时,准备多个 U 盘,每个都 dd 一份即可。

完整目录结构参考

~/contest-live/
├── config/
│   ├── package-lists/
│   │   └── contest.list.chroot                           # 额外安装的包
│   ├── includes.chroot/
│   │   ├── usr/local/bin/
│   │   │   └── contest-install                           # 安装脚本(交互式)
│   │   ├── usr/local/share/icpc/
│   │   │   └── filesystem.squashfs                       # 目标系统 rootfs
│   │   ├── etc/skel/
│   │   │   └── .bash_profile                             # 自动启动安装脚本
│   │   └── etc/sudoers.d/
│   │       └── contest-install                           # 免密 sudo
│   └── includes.binary/
│       └── boot/grub/
│           └── grub.cfg                                  # GRUB 启动菜单
└── live-image-amd64.hybrid.iso                           # 最终产物

部署流程(现场操作)

1. U 盘插入目标机器 → UEFI 选择 USB 启动
2. GRUB 菜单 5 秒倒计时 → "Contest Installer"
3. live-config autologin as user → 10s auto-start installer
4. 脚本交互流程:
   ┌─────────────────────────────────────────────────┐
   │ 显示所有磁盘 → [人工] 输入目标磁盘名           │
   │ 分析分区 → 自动判断: 空盘 or 双系统            │
   │   空盘:   创建 GPT + EFI + root                │
   │   双系统: 保留 Windows,仅覆盖 Linux 分区      │
   │ → [人工] 确认分区操作                           │
   │ 执行分区 + 格式化                               │
   │ → [人工] 确认继续安装                           │
   │ 解压 squashfs + 安装 GRUB                       │
   │ → [人工] 按回车重启                             │
   └─────────────────────────────────────────────────┘
5. 拔掉 U 盘,从硬盘启动

约 5-10 分钟/台,含人工确认时间。


(Optional) DOMjudge Judgehost Setup Script

Reference: https://www.domjudge.org/docs/manual/9.0/install-judgehost.html

One-shot script to configure a deployed machine as a DOMjudge judgehost. Can be run manually after deployment, or baked into the image before Step 1 (mksquashfs).

Create the script:

JudgeHost配置脚本
cat > setup-judgehost.sh <<'JUDGEEOF'
#!/bin/bash
set -euo pipefail

# ╔════════════════════════════════════════════════════════════╗
# ║  DOMjudge Judgehost Setup Script                          ║
# ║  Edit the variables below before running                  ║
# ╚════════════════════════════════════════════════════════════╝

# ── Configuration (EDIT THESE) ─────────────────────────────
DJVER="9.0.0"
DOMSERVER_URL="http://10.12.13.20/api/"
JUDGEHOST_USER="judgehost"
JUDGEHOST_PASS="passw0rd"
INSTALL_PREFIX="/opt/domjudge"
# CPU cores to use for judging (space-separated), e.g. "0 1 2 3"
JUDGE_CORES="0"
# ───────────────────────────────────────────────────────────

echo "[1/8] Switching to BFSU mirror and installing dependencies..."
sudo sed -i 's/cn.archive.ubuntu.com/mirrors.bfsu.edu.cn/g' /etc/apt/sources.list
sudo sed -i 's/archive.ubuntu.com/mirrors.bfsu.edu.cn/g' /etc/apt/sources.list
sudo sed -i 's/security.ubuntu.com/mirrors.bfsu.edu.cn/g' /etc/apt/sources.list
sudo apt-get update -qq
sudo apt-get install -y -qq make pkg-config sudo debootstrap libcgroup-dev \
    php-cli php-curl php-json php-xml php-zip lsof procps gcc g++ wget
sudo apt-get remove -y apport 2>/dev/null || true

echo "[2/8] Configuring kernel boot parameters (cgroup)..."
sudo sed -i 's/^GRUB_CMDLINE_LINUX_DEFAULT=.*/GRUB_CMDLINE_LINUX_DEFAULT="quiet cgroup_enable=memory swapaccount=1"/' /etc/default/grub
sudo update-grub

echo "[3/8] Downloading and building DOMjudge ${DJVER}..."
cd /tmp
if [ ! -d "domjudge-${DJVER}" ]; then
    wget --no-check-certificate -q "https://10.12.13.20:2333/static/domjudge-${DJVER}.tar.gz"
    tar xzf "domjudge-${DJVER}.tar.gz"
fi
cd "domjudge-${DJVER}"
./configure --prefix="${INSTALL_PREFIX}" --quiet
make judgehost -j"$(nproc)"
sudo make install-judgehost

echo "[4/8] Creating users and groups..."
sudo groupadd -f domjudge-run
for core in $JUDGE_CORES; do
    id "domjudge-run-${core}" &>/dev/null || \
        sudo useradd -d /nonexistent -g domjudge-run -M -s /bin/false "domjudge-run-${core}"
done

echo "[5/8] Installing sudoers rules..."
sudo cp "${INSTALL_PREFIX}/judgehost/etc/sudoers-domjudge" /etc/sudoers.d/
sudo chmod 440 /etc/sudoers.d/sudoers-domjudge

echo "[6/8] Installing systemd services..."
sudo cp judge/domjudge-judgedaemon@.service /etc/systemd/system/
sudo cp judge/create-cgroups.service /etc/systemd/system/

# Add [Install] section so systemctl enable works
if ! grep -q '\[Install\]' /etc/systemd/system/domjudge-judgedaemon@.service; then
    sudo tee -a /etc/systemd/system/domjudge-judgedaemon@.service > /dev/null <<'UNIT'

[Install]
WantedBy=multi-user.target
UNIT
fi

sudo systemctl daemon-reload
sudo systemctl enable create-cgroups --now

echo "[7/8] Building chroot environment (this may take a few minutes)..."
sudo DEBMIRROR="https://mirrors.bfsu.edu.cn/ubuntu" "${INSTALL_PREFIX}/judgehost/bin/dj_make_chroot"

echo "[8/8] Configuring REST API credentials and enabling services..."
sudo tee "${INSTALL_PREFIX}/judgehost/etc/restapi.secret" > /dev/null <<EOF
# id  URL  username  password
default ${DOMSERVER_URL} ${JUDGEHOST_USER} ${JUDGEHOST_PASS}
EOF
sudo chmod 640 "${INSTALL_PREFIX}/judgehost/etc/restapi.secret"

for core in $JUDGE_CORES; do
    sudo systemctl enable "domjudge-judgedaemon@${core}"
done

echo "[9/9] Configuring autologin for icpc user (no password)..."
# Remove password for icpc user
sudo passwd -d icpc

# Configure GDM autologin (Ubuntu with GNOME)
if [ -f /etc/gdm3/custom.conf ]; then
    sudo sed -i '/^\[daemon\]/a AutomaticLoginEnable=True\nAutomaticLogin=icpc' /etc/gdm3/custom.conf
    # Avoid duplicate entries if run multiple times
    sudo awk '!seen[$0]++' /etc/gdm3/custom.conf | sudo tee /etc/gdm3/custom.conf.tmp > /dev/null
    sudo mv /etc/gdm3/custom.conf.tmp /etc/gdm3/custom.conf
fi

# Configure LightDM autologin (Ubuntu with LightDM)
if [ -d /etc/lightdm ]; then
    sudo tee /etc/lightdm/lightdm.conf.d/50-autologin.conf > /dev/null <<LIGHTDM
[Seat:*]
autologin-user=icpc
autologin-user-timeout=0
LIGHTDM
fi

echo ""
echo "=========================================="
echo "  Judgehost setup complete!"
echo "  DOMserver: ${DOMSERVER_URL}"
echo "  Judge cores: ${JUDGE_CORES}"
echo "  NOTE: Reboot required for cgroup changes"
echo "=========================================="
JUDGEEOF

chmod +x setup-judgehost.sh

Usage:

# Edit the variables at the top of the script, then:
~/setup-judgehost.sh
sudo reboot

To bake into the image: run this script inside the mounted raw image (chroot) before Step 1, then every deployed machine will have judgehost pre-installed. Only the REST API credentials (/opt/domjudge/judgehost/etc/restapi.secret) may need per-machine adjustment.


分享文章:

上一篇
超像素算法比较
下一篇
ICPC 比赛赛前部署与性能优化检查清单