From 61137b837d84b63225644cede9ebcdf5efe838d9 Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:52:15 +0800 Subject: [PATCH] release: v1.1.22 --- .github/workflows/build.yml | 48 ++- .gitignore | 2 + backend/internal/api/host.go | 372 +++++++++++++++++- backend/internal/api/host_test.go | 34 ++ backend/internal/api/images.go | 28 +- backend/internal/cli/cli.go | 90 +++-- backend/internal/cli/cli_test.go | 20 + backend/internal/kvm/kvm.go | 78 +++- backend/internal/kvm/templates.go | 57 +++ backend/internal/lxc/templates.go | 30 +- backend/internal/server/web/.gitkeep | 1 - backend/internal/version/version.go | 2 +- build.sh | 44 ++- docs/developer/build.md | 19 + docs/developer/release.md | 4 +- docs/en/developer/build.md | 19 + docs/en/developer/release.md | 4 +- docs/en/guide/installation.md | 4 +- docs/en/guide/introduction.md | 2 +- docs/en/operations/faq.md | 2 +- docs/guide/installation.md | 4 +- docs/guide/introduction.md | 2 +- docs/operations/faq.md | 2 +- frontend/package.json | 2 +- .../src/components/CreateContainerModal.tsx | 38 +- frontend/src/pages/Containers.tsx | 4 +- frontend/src/pages/HostReport.tsx | 5 +- frontend/src/pages/ImageManagement.tsx | 24 +- frontend/src/pages/Login.tsx | 2 +- frontend/src/services/api.ts | 8 + install.sh | 216 +++++++--- 31 files changed, 996 insertions(+), 171 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2293ca6..c7c6f70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,9 +14,15 @@ permissions: contents: write jobs: - linux-amd64: - name: Linux amd64 + linux: + name: Linux ${{ matrix.goarch }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + goarch: + - amd64 + - arm64 steps: - name: Checkout @@ -55,16 +61,21 @@ jobs: fi - name: Build CLICD + env: + CLICD_GOARCH: ${{ matrix.goarch }} run: bash build.sh - name: Package CLICD + env: + CLICD_GOARCH: ${{ matrix.goarch }} run: | - mkdir -p dist package/clicd-linux-amd64 - cp build/clicd package/clicd-linux-amd64/clicd - cp build/install.sh package/clicd-linux-amd64/install.sh - chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh - tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64 - cp build/clicd dist/clicd-linux-amd64 + asset_dir="clicd-linux-${CLICD_GOARCH}" + mkdir -p "dist" "package/${asset_dir}" + cp build/clicd "package/${asset_dir}/clicd" + cp build/install.sh "package/${asset_dir}/install.sh" + chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh" + tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}" + cp build/clicd "dist/${asset_dir}" - name: Package Mofang module run: | @@ -87,11 +98,28 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@v4 with: - name: clicd-linux-amd64 + name: clicd-linux-${{ matrix.goarch }} path: dist/* + release: + name: Publish GitHub Release + needs: linux + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: dist-artifacts + + - name: Prepare release assets + run: | + mkdir -p dist + find dist-artifacts -maxdepth 2 -type f ! -name SHA256SUMS -print -exec cp -f {} dist/ \; + sha256sum dist/* > dist/SHA256SUMS + - name: Publish GitHub Release - if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} run: | diff --git a/.gitignore b/.gitignore index 5d6d8ba..2493b83 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ backend/internal/server/web/* # Build artifacts /build/ +/dist/ Mofang/*.zip *.exe *.dll @@ -69,3 +70,4 @@ push-release.ps1 deploy.ps1 backend/clicd api.md +deploy-arm.ps1 diff --git a/backend/internal/api/host.go b/backend/internal/api/host.go index 430f647..1c5dc05 100644 --- a/backend/internal/api/host.go +++ b/backend/internal/api/host.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net" "net/http" "os" @@ -219,6 +220,9 @@ var hostCPUMu sync.Mutex var lastHostCPU cpuTimes var hostIOMu sync.Mutex var lastHostIO hostIOSample +var egressIPv4Mu sync.Mutex +var cachedEgressIPv4 lxc.PublicIPInfo +var cachedEgressIPv4At time.Time type cpuTimes struct { Total uint64 @@ -397,7 +401,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) { now := unixNano() network := NetworkInfo{RXBytes: rx, TXBytes: tx} - publicIPv4 := lxc.DetectPublicIPv4() + publicIPv4 := detectDisplayPublicIPv4() network.PublicIPv4 = publicIPv4.Address network.PublicIPv4Interface = publicIPv4.Interface network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0) @@ -437,23 +441,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) { } func readHostNetworkBytes() (uint64, uint64) { - entries, err := os.ReadDir("/sys/class/net") - if err != nil { - return 0, 0 + ifaces := detectHostTrafficInterfaces() + if len(ifaces) == 0 { + ifaces = fallbackHostTrafficInterfaces() } var rx, tx uint64 - for _, entry := range entries { - name := entry.Name() - if name == "lo" { - continue - } + for _, name := range ifaces { rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes") tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes") } return rx, tx } +func detectHostTrafficInterfaces() []string { + seen := map[string]bool{} + result := make([]string, 0, 2) + add := func(name string) { + name = strings.TrimSpace(name) + if !isHostTrafficInterface(name) || seen[name] { + return + } + seen[name] = true + result = append(result, name) + } + + if iface, _ := detectDefaultIPv4Route(); iface != "" { + add(iface) + } + if iface, _ := detectDefaultIPv6Route(); iface != "" { + add(iface) + } + if pub := lxc.DetectPublicIPv4(); pub.Interface != "" { + add(pub.Interface) + } + for _, prefix := range lxc.DetectHostPublicIPv6Prefixes() { + add(prefix.Interface) + } + return result +} + +func fallbackHostTrafficInterfaces() []string { + entries, err := os.ReadDir("/sys/class/net") + if err != nil { + return nil + } + + result := make([]string, 0) + for _, entry := range entries { + name := entry.Name() + if !isHostTrafficInterface(name) { + continue + } + state := strings.TrimSpace(readFirstExistingFile(filepath.Join("/sys/class/net", name, "operstate"))) + if state == "down" { + continue + } + result = append(result, name) + } + sort.Strings(result) + return result +} + +func isHostTrafficInterface(name string) bool { + name = strings.TrimSpace(name) + if name == "" || name == "lo" { + return false + } + if isContainerLikeInterfaceName(name) { + return false + } + return true +} + func readHostDiskBytes() (uint64, uint64) { f, err := os.Open("/proc/diskstats") if err != nil { @@ -574,6 +634,8 @@ func trimOSReleaseValue(value string) string { func detectHostCPUProbe() HostCPUProbe { probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH} + armImplementer := "" + armPart := "" if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { seenFlags := map[string]bool{} for _, line := range strings.Split(string(data), "\n") { @@ -582,19 +644,28 @@ func detectHostCPUProbe() HostCPUProbe { continue } key := strings.TrimSpace(fields[0]) + keyLower := strings.ToLower(key) value := strings.TrimSpace(fields[1]) - switch key { - case "model name", "Hardware", "Processor": - if probe.Model == "" { + switch keyLower { + case "model name", "hardware", "processor": + if probe.Model == "" && meaningfulCPUModel(value) { probe.Model = value } case "cpu cores": if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores { probe.Cores = cores } - case "flags", "Features": + case "cpu implementer": + if armImplementer == "" { + armImplementer = strings.ToLower(value) + } + case "cpu part": + if armPart == "" { + armPart = strings.ToLower(value) + } + case "flags", "features": for _, flag := range strings.Fields(value) { - if flag == "vmx" || flag == "svm" { + if flag == "vmx" || flag == "svm" || flag == "virt" { probe.Virtualization = true probe.VirtualizationKey = flag } @@ -607,12 +678,132 @@ func detectHostCPUProbe() HostCPUProbe { } sort.Strings(probe.Flags) } + enrichCPUProbeFromLscpu(&probe, &armImplementer, &armPart) + if probe.Model == "" { + probe.Model = armCPUModelName(armImplementer, armPart) + } + if probe.Model == "" && runtime.GOARCH == "arm64" { + probe.Model = "ARM64 CPU" + } if probe.Model == "" { probe.Model = "Unknown" } return probe } +func meaningfulCPUModel(value string) bool { + value = strings.TrimSpace(value) + if value == "" { + return false + } + if _, err := strconv.Atoi(value); err == nil { + return false + } + lower := strings.ToLower(value) + return lower != "unknown" && lower != "not specified" +} + +func enrichCPUProbeFromLscpu(probe *HostCPUProbe, armImplementer *string, armPart *string) { + out := runCommandOutput(2*time.Second, "lscpu") + if out == "" { + return + } + for _, line := range strings.Split(out, "\n") { + fields := strings.SplitN(line, ":", 2) + if len(fields) != 2 { + continue + } + key := strings.ToLower(strings.TrimSpace(fields[0])) + value := strings.TrimSpace(fields[1]) + switch key { + case "model name": + if probe.Model == "" && meaningfulCPUModel(value) { + probe.Model = value + } + case "cpu(s)": + if threads, err := strconv.Atoi(value); err == nil && threads > probe.Threads { + probe.Threads = threads + } + case "core(s) per socket": + if cores, err := strconv.Atoi(value); err == nil && cores > 0 { + probe.Cores = cores + } + case "socket(s)": + if sockets, err := strconv.Atoi(value); err == nil && sockets > 1 && probe.Cores > 0 { + probe.Cores *= sockets + } + case "virtualization": + lower := strings.ToLower(value) + if value != "" && lower != "none" && lower != "n/a" { + probe.Virtualization = true + probe.VirtualizationKey = value + } + case "flags": + seen := map[string]bool{} + for _, flag := range probe.Flags { + seen[flag] = true + } + for _, flag := range strings.Fields(value) { + if flag == "vmx" || flag == "svm" || flag == "virt" { + probe.Virtualization = true + probe.VirtualizationKey = flag + } + if !seen[flag] { + probe.Flags = append(probe.Flags, flag) + seen[flag] = true + } + } + sort.Strings(probe.Flags) + case "cpu implementer": + if *armImplementer == "" { + *armImplementer = strings.ToLower(value) + } + case "cpu part": + if *armPart == "" { + *armPart = strings.ToLower(value) + } + } + } +} + +func armCPUModelName(implementer, part string) string { + implementer = normalizeHexID(implementer) + part = normalizeHexID(part) + if implementer == "" || part == "" { + return "" + } + armParts := map[string]string{ + "0x41:0xd03": "ARM Cortex-A53", + "0x41:0xd05": "ARM Cortex-A55", + "0x41:0xd07": "ARM Cortex-A57", + "0x41:0xd08": "ARM Cortex-A72", + "0x41:0xd09": "ARM Cortex-A73", + "0x41:0xd0a": "ARM Cortex-A75", + "0x41:0xd0b": "ARM Cortex-A76", + "0x41:0xd0c": "ARM Neoverse N1", + "0x41:0xd0d": "ARM Cortex-A77", + "0x41:0xd40": "ARM Neoverse V1", + "0x41:0xd41": "ARM Cortex-A78", + "0x41:0xd49": "ARM Neoverse N2", + "0x41:0xd4f": "ARM Neoverse V2", + } + if model := armParts[implementer+":"+part]; model != "" { + return model + } + return strings.ToUpper(strings.TrimPrefix(implementer, "0x")) + " ARM CPU part " + part +} + +func normalizeHexID(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + if strings.HasPrefix(value, "0x") { + return value + } + return "0x" + value +} + func detectMemoryModules() []HostMemoryModule { if !commandExists("dmidecode") { return nil @@ -724,7 +915,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool { } for _, token := range []string{ "qemu", "virtio", "virtual", "vmware", "vbox", "xen", - "amazon elastic block store", "google persistentdisk", "microsoft", + "amazon elastic block store", "google persistentdisk", "microsoft", "blockvolume", } { if strings.Contains(lower, token) { return true @@ -1153,9 +1344,126 @@ func detectAllPublicIPv4() []string { result = append(result, value) } } + if egress := detectEgressPublicIPv4(); egress.Address != "" { + if !seen[egress.Address] { + seen[egress.Address] = true + result = append(result, egress.Address) + } + } return result } +func detectDisplayPublicIPv4() lxc.PublicIPInfo { + if pub := lxc.DetectPublicIPv4(); pub.Address != "" { + return pub + } + return detectEgressPublicIPv4() +} + +func detectEgressPublicIPv4() lxc.PublicIPInfo { + egressIPv4Mu.Lock() + defer egressIPv4Mu.Unlock() + + if cachedEgressIPv4.Address != "" && time.Since(cachedEgressIPv4At) < 5*time.Minute { + return cachedEgressIPv4 + } + + client := &http.Client{Timeout: 1200 * time.Millisecond} + for _, endpoint := range []string{ + "https://api.ipify.org", + "https://ifconfig.me/ip", + "https://icanhazip.com", + } { + ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + cancel() + continue + } + resp, err := client.Do(req) + if err != nil { + cancel() + continue + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 128)) + _ = resp.Body.Close() + cancel() + if readErr != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + continue + } + address := strings.TrimSpace(string(body)) + ip := net.ParseIP(address) + if !isPublicIPv4(ip) { + continue + } + iface, gateway := detectDefaultIPv4Route() + cachedEgressIPv4 = lxc.PublicIPInfo{ + Address: ip.String(), + Interface: iface, + Prefix: ip.String() + "/32", + PrefixLen: 32, + SubnetMask: "255.255.255.255", + Gateway: gateway, + IsTunnel: isTunnelLikeInterfaceName(iface), + Source: "egress", + } + cachedEgressIPv4At = time.Now() + return cachedEgressIPv4 + } + + cachedEgressIPv4 = lxc.PublicIPInfo{} + cachedEgressIPv4At = time.Now() + return cachedEgressIPv4 +} + +func detectDefaultIPv4Route() (string, string) { + out := runCommandOutput(2*time.Second, "ip", "-4", "route", "show", "default") + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + iface := "" + gateway := "" + for i, field := range fields { + if field == "dev" && i+1 < len(fields) { + iface = fields[i+1] + } + if field == "via" && i+1 < len(fields) { + gateway = fields[i+1] + } + } + if iface != "" || gateway != "" { + return iface, gateway + } + } + return "", "" +} + +func detectDefaultIPv6Route() (string, string) { + out := runCommandOutput(2*time.Second, "ip", "-6", "route", "show", "default") + for _, line := range strings.Split(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + iface := "" + gateway := "" + for i, field := range fields { + if field == "dev" && i+1 < len(fields) { + iface = fields[i+1] + } + if field == "via" && i+1 < len(fields) { + gateway = fields[i+1] + } + } + if iface != "" || gateway != "" { + return iface, gateway + } + } + return "", "" +} + func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe { result := make([]HostIPProbe, 0) for _, nic := range nics { @@ -1301,6 +1609,16 @@ func isContainerLikeInterfaceName(iface string) bool { return false } +func isTunnelLikeInterfaceName(iface string) bool { + lower := strings.ToLower(strings.TrimSpace(iface)) + for _, prefix := range []string{"tun", "tap", "wg", "gre", "gretap", "sit", "ip6tnl", "he-", "zt", "tailscale"} { + if lower == prefix || strings.HasPrefix(lower, prefix) { + return true + } + } + return false +} + func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe { result := make([]HostIPProbe, 0) for _, nic := range nics { @@ -1368,6 +1686,8 @@ func detectGPUVendor(value string) string { return "NVIDIA" case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"): return "AMD" + case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"): + return "Virtio" default: return "Unknown" } @@ -1375,6 +1695,9 @@ func detectGPUVendor(value string) string { func detectGPUType(value string) string { lower := strings.ToLower(value) + if strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu") { + return "virtual" + } if strings.Contains(lower, "intel") { return "integrated" } @@ -1394,9 +1717,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe { devKVM := fileExists("/dev/kvm") nested, detail := detectNestedVirtualization() lxcOK := envCheckOK(env, "lxc-create") + kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" probe := HostRuntimeProbe{ LXCAvailable: lxcOK, - KVMAvailable: devKVM && envCheckOK(env, "virsh"), + KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()), DevKVM: devKVM, NestedVirtualization: nested, NestedDetail: detail, @@ -1446,6 +1770,7 @@ func detectSystemProbe() HostSystemProbe { } func detectHostEnvironment() []HostEnvCheck { + qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "") checks := []HostEnvCheck{ commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"), commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""), @@ -1454,7 +1779,7 @@ func detectHostEnvironment() []HostEnvCheck { commandCheck("ip", "iproute2 网络工具", true, "ip", ""), commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""), commandCheck("virsh", "libvirt virsh", false, "virsh", ""), - commandCheck("qemu-system-x86_64", "QEMU/KVM 虚拟机", false, "qemu-system-x86_64", ""), + qemuCheck, commandCheck("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"), commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""), commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""), @@ -1467,6 +1792,19 @@ func detectHostEnvironment() []HostEnvCheck { return checks } +func kvmQEMUCheckKey() string { + switch runtime.GOARCH { + case "arm64": + return "qemu-system-aarch64" + default: + return "qemu-system-x86_64" + } +} + +func kvmQEMUCommand() string { + return kvmQEMUCheckKey() +} + func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck { ok := commandExists(cmd) detail := "missing" diff --git a/backend/internal/api/host_test.go b/backend/internal/api/host_test.go index 6d2e185..f0274f4 100644 --- a/backend/internal/api/host_test.go +++ b/backend/internal/api/host_test.go @@ -41,3 +41,37 @@ func TestCertbotVersionAtLeast54(t *testing.T) { } } } + +func TestARMCPUModelName(t *testing.T) { + if got := armCPUModelName("0x41", "0xd0c"); got != "ARM Neoverse N1" { + t.Fatalf("armCPUModelName() = %q, want ARM Neoverse N1", got) + } + if got := armCPUModelName("41", "d0c"); got != "ARM Neoverse N1" { + t.Fatalf("armCPUModelName() without hex prefix = %q, want ARM Neoverse N1", got) + } +} + +func TestMeaningfulCPUModel(t *testing.T) { + if meaningfulCPUModel("0") { + t.Fatal("numeric ARM processor index should not be treated as a CPU model") + } + if !meaningfulCPUModel("Neoverse-N1") { + t.Fatal("expected Neoverse-N1 to be treated as a CPU model") + } +} + +func TestHostTrafficInterfaceFilter(t *testing.T) { + accepted := []string{"eth0", "ens3", "enp0s6", "bond0", "wg0"} + for _, name := range accepted { + if !isHostTrafficInterface(name) { + t.Fatalf("expected %s to be accepted as a host traffic interface", name) + } + } + + rejected := []string{"", "lo", "docker0", "br-3024b78640ee", "lxcbr0", "virbr0", "vethaaa9e44", "cni0"} + for _, name := range rejected { + if isHostTrafficInterface(name) { + t.Fatalf("expected %s to be rejected as an internal/container interface", name) + } + } +} diff --git a/backend/internal/api/images.go b/backend/internal/api/images.go index 99335a0..8f8f0b5 100644 --- a/backend/internal/api/images.go +++ b/backend/internal/api/images.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sync" "time" @@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { enabledSet := getEnabledImageSet() cleanupOldImageDownloadErrors() + kvmAvailable := hostKVMAvailable() templates := lxc.GetTemplates() - images := make([]ImageInfo, 0, len(templates)+len(kvm.GetImages())) + kvmImages := []kvm.Image{} + if kvmAvailable { + kvmImages = kvm.GetImages() + } + images := make([]ImageInfo, 0, len(templates)+len(kvmImages)) for _, t := range templates { dl := imageDownloadInfo(t.ID) downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch) @@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) { SizeBytes: size, }) } - for _, t := range kvm.GetImages() { + for _, t := range kvmImages { dl := imageDownloadInfo(t.ID) downloaded, size := kvm.ImageDownloadedInfo(t.ID) manualPath := "" @@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"}) return } + if !hostKVMAvailable() { + jsonResponse(w, http.StatusForbidden, APIResponse{Success: false, Message: "KVM is not available on this host"}) + return + } if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok { ensureImageEnabled(image.ID) clearImageDownload(image.ID) @@ -534,6 +544,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) { result := make([]map[string]string, 0) if runtime == config.VirtualizationKVM { + if !hostKVMAvailable() { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result}) + return + } for _, t := range kvm.GetImages() { if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded { result = append(result, map[string]string{ @@ -563,6 +577,9 @@ func isTemplateEnabledAndDownloaded(templateID string) bool { func isImageEnabledAndDownloaded(templateID string, runtime string) bool { runtime = runtimeFromRequest(runtime) if runtime == config.VirtualizationKVM { + if !hostKVMAvailable() { + return false + } image := kvm.FindImage(templateID) if image == nil { return false @@ -579,6 +596,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool { return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch) } +func hostKVMAvailable() bool { + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + return false + } + return fileExists("/dev/kvm") && commandExists("virsh") && commandExists(kvmQEMUCheckKey()) +} + func ensureImageEnabled(id string) { // If the enabled list is empty, all templates are currently enabled by default. // We must populate the list with all template IDs first so that explicit toggles stick. diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 5bead1e..f583455 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "sort" "strconv" "strings" @@ -125,32 +126,34 @@ var cliTranslations = map[string]string{ "检查仓库": "Checking repository", "检查 GitHub 最新版本失败": "Failed to check the latest GitHub version", "GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.", - "最新版本": "Latest version", - "发布页面": "Release page", - "最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.", - "当前已经是最新版本。": "The current version is already the latest.", - "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue", - "输入 upgrade 开始升级": "Type upgrade to start upgrade", - "已取消。": "Cancelled.", - "升级失败": "Upgrade failed", - "升级完成": "Upgrade completed", - "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.", - "GitHub API 返回": "GitHub API returned", - "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.", - "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.", - "GitHub releases/latest 返回": "GitHub releases/latest returned", - "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect", - "正在下载升级包...": "Downloading upgrade package...", - "正在解压升级包...": "Extracting upgrade package...", - "解压失败": "Extraction failed", - "备份旧二进制失败": "Failed to back up old binary", - "旧版本已备份": "Old version backed up", - "正在替换二进制...": "Replacing binary...", - "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt", - "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed", - "下载失败,HTTP": "Download failed, HTTP", - "升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package", - "将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.", + "最新版本": "Latest version", + "发布页面": "Release page", + "当前架构不支持自动升级": "Automatic upgrade is not supported on the current architecture", + "最新 Release 没有找到": "The latest release does not contain", + "无法自动升级。": "automatic upgrade is unavailable.", + "当前已经是最新版本。": "The current version is already the latest.", + "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue", + "输入 upgrade 开始升级": "Type upgrade to start upgrade", + "已取消。": "Cancelled.", + "升级失败": "Upgrade failed", + "升级完成": "Upgrade completed", + "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.", + "GitHub API 返回": "GitHub API returned", + "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.", + "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.", + "GitHub releases/latest 返回": "GitHub releases/latest returned", + "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect", + "正在下载升级包...": "Downloading upgrade package...", + "正在解压升级包...": "Extracting upgrade package...", + "解压失败": "Extraction failed", + "备份旧二进制失败": "Failed to back up old binary", + "旧版本已备份": "Old version backed up", + "正在替换二进制...": "Replacing binary...", + "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt", + "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed", + "下载失败,HTTP": "Download failed, HTTP", + "升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package", + "将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.", "导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.", "导入失败": "Import failed", "没有发现新的 ct-* 容器。": "No new ct-* containers found.", @@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) { if repo == "" { repo = version.Repo } + assetName, err := releaseArchiveAssetName(runtime.GOARCH) + if err != nil { + cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH) + return + } current := version.Current() cliPrintf("当前版本: %s\n", current) cliPrintf("检查仓库: https://github.com/%s\n", repo) - release, err := fetchLatestRelease(repo) + release, err := fetchLatestRelease(repo, assetName) if err != nil { cliPrintf("检查 GitHub 最新版本失败: %v\n", err) return @@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) { cliPrintf("发布页面: %s\n", release.HTMLURL) } - assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz") + assetURL := findReleaseAsset(release, assetName) if assetURL == "" { - cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。") + cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName) return } @@ -597,7 +605,7 @@ func cliUpgradeSystem(reader *bufio.Reader) { } } - if err := upgradeFromReleaseAsset(assetURL, latest); err != nil { + if err := upgradeFromReleaseAsset(assetURL, latest, assetName); err != nil { cliPrintf("升级失败: %v\n", err) return } @@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) { cliPrintln("原有数据已保留,Web 服务已重启。") } -func fetchLatestRelease(repo string) (*githubRelease, error) { +func fetchLatestRelease(repo, assetName string) (*githubRelease, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { @@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) { client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { - if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { + if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil { return fallback, nil } return nil, err @@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body))) - if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { + if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { cliPrintln("GitHub API 被限流,已切换到备用检查方式。") } else { @@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) { return &release, nil } -func fetchLatestReleaseFallback(repo string) (*githubRelease, error) { +func fetchLatestReleaseFallback(repo, assetName string) (*githubRelease, error) { req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil) if err != nil { return nil, err @@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) { return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本") } - const assetName = "clicd-linux-amd64.tar.gz" return &githubRelease{ TagName: tag, Name: tag, @@ -708,6 +715,15 @@ func setGitHubRequestHeaders(req *http.Request) { } } +func releaseArchiveAssetName(goarch string) (string, error) { + switch goarch { + case "amd64", "arm64": + return fmt.Sprintf("clicd-linux-%s.tar.gz", goarch), nil + default: + return "", fmt.Errorf("unsupported architecture: %s", goarch) + } +} + func findReleaseAsset(release *githubRelease, name string) string { for _, asset := range release.Assets { if asset.Name == name && asset.BrowserDownloadURL != "" { @@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string { return "" } -func upgradeFromReleaseAsset(assetURL, latest string) error { +func upgradeFromReleaseAsset(assetURL, latest, assetName string) error { tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*") if err != nil { return err } defer os.RemoveAll(tmpDir) - archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz") + archivePath := filepath.Join(tmpDir, assetName) cliPrintln("正在下载升级包...") if err := downloadFile(assetURL, archivePath); err != nil { return err diff --git a/backend/internal/cli/cli_test.go b/backend/internal/cli/cli_test.go index 3c0cffa..1739ca1 100644 --- a/backend/internal/cli/cli_test.go +++ b/backend/internal/cli/cli_test.go @@ -19,6 +19,26 @@ func TestSafeReleaseBackupComponent(t *testing.T) { } } +func TestReleaseArchiveAssetName(t *testing.T) { + tests := map[string]string{ + "amd64": "clicd-linux-amd64.tar.gz", + "arm64": "clicd-linux-arm64.tar.gz", + } + for goarch, want := range tests { + got, err := releaseArchiveAssetName(goarch) + if err != nil { + t.Fatalf("releaseArchiveAssetName(%q) error = %v", goarch, err) + } + if got != want { + t.Fatalf("releaseArchiveAssetName(%q) = %q, want %q", goarch, got, want) + } + } + + if _, err := releaseArchiveAssetName("386"); err == nil { + t.Fatal("releaseArchiveAssetName(386) error = nil, want unsupported architecture") + } +} + func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) { unsafeNames := []string{ "../clicd", diff --git a/backend/internal/kvm/kvm.go b/backend/internal/kvm/kvm.go index 74c7d06..21aa5a9 100644 --- a/backend/internal/kvm/kvm.go +++ b/backend/internal/kvm/kvm.go @@ -20,6 +20,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "sort" "strconv" "strings" @@ -1538,7 +1539,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error { return err } } + if err := requireCommand(kvmEmulatorCommand()); err != nil { + return err + } if skipCloudInit { + if runtime.GOARCH != "amd64" { + return fmt.Errorf("Windows KVM is currently supported only on x86_64/amd64 hosts") + } if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil { return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err) } @@ -1572,6 +1579,40 @@ func requireAnyCommand(names ...string) error { return fmt.Errorf("one of %s is required for KVM support", strings.Join(names, ", ")) } +func kvmLibvirtArch() string { + switch runtime.GOARCH { + case "arm64": + return "aarch64" + default: + return "x86_64" + } +} + +func kvmMachineType() string { + switch runtime.GOARCH { + case "arm64": + return "virt" + default: + return "pc" + } +} + +func kvmEmulatorCommand() string { + switch runtime.GOARCH { + case "arm64": + return "qemu-system-aarch64" + default: + return "qemu-system-x86_64" + } +} + +func kvmEmulatorPath() string { + if path, err := exec.LookPath(kvmEmulatorCommand()); err == nil { + return path + } + return "/usr/bin/" + kvmEmulatorCommand() +} + func ensureDefaultNetwork() error { // Ensure libvirtd is running if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil { @@ -2186,6 +2227,26 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, video = "" input = "\n\t " } + osAttrs := "" + features := "" + if runtime.GOARCH == "arm64" { + osAttrs = " firmware='efi'" + features = "" + } + seedDisk := fmt.Sprintf(` + + + + + `, xmlEscape(seedPath)) + if runtime.GOARCH == "arm64" { + seedDisk = fmt.Sprintf(` + + + + + `, xmlEscape(seedPath)) + } return fmt.Sprintf(` %s %s @@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, %d %d 2048 - - hvm + + hvm - + %s destroy restart restart - /usr/bin/qemu-system-x86_64 + %s %s - - - - - - + %s @@ -2232,7 +2288,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string, %s %s -`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video) +`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, osAttrs, kvmLibvirtArch(), kvmMachineType(), features, xmlEscape(kvmEmulatorPath()), xmlEscape(diskPath), iotune, seedDisk, xmlEscape(mac), bandwidth, input, video) } func windowsDomainXML(name string, vcpu int, ramMB int, diskPath, winISOPath, unattendISOPath, mac string, ioReadMBps int, ioWriteMBps int, networkDownMbps int, networkUpMbps int) string { diff --git a/backend/internal/kvm/templates.go b/backend/internal/kvm/templates.go index 04f971e..0f85bf9 100644 --- a/backend/internal/kvm/templates.go +++ b/backend/internal/kvm/templates.go @@ -2,6 +2,7 @@ package kvm import ( "path/filepath" + "runtime" ) type Image struct { @@ -16,6 +17,15 @@ type Image struct { } func GetImages() []Image { + switch runtime.GOARCH { + case "arm64": + return arm64Images() + default: + return amd64Images() + } +} + +func amd64Images() []Image { return []Image{ { ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM", @@ -94,6 +104,53 @@ func GetImages() []Image { } } +func arm64Images() []Image { + return []Image{ + { + ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM", + Distro: "ubuntu", Release: "noble", Arch: "arm64", + Description: "Ubuntu 24.04 LTS cloud image for ARM64 KVM", + URL: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img", + }, + { + ID: "kvm-ubuntu-jammy", Name: "Ubuntu 22.04 KVM", + Distro: "ubuntu", Release: "jammy", Arch: "arm64", + Description: "Ubuntu 22.04 LTS cloud image for ARM64 KVM", + URL: "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-arm64.img", + }, + { + ID: "kvm-debian-bookworm", Name: "Debian 12 KVM", + Distro: "debian", Release: "bookworm", Arch: "arm64", + Description: "Debian 12 generic cloud image for ARM64 KVM", + URL: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2", + }, + { + ID: "kvm-debian-bullseye", Name: "Debian 11 KVM", + Distro: "debian", Release: "bullseye", Arch: "arm64", + Description: "Debian 11 generic cloud image for ARM64 KVM", + URL: "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-genericcloud-arm64.qcow2", + }, + { + ID: "kvm-centos-9-stream", Name: "CentOS Stream 9 KVM", + Distro: "centos", Release: "9-stream", Arch: "arm64", + Description: "CentOS Stream 9 GenericCloud image for ARM64 KVM", + URL: "https://cloud.centos.org/centos/9-stream/aarch64/images/CentOS-Stream-GenericCloud-9-latest.aarch64.qcow2", + }, + { + ID: "kvm-fedora-44", Name: "Fedora 44 KVM", + Distro: "fedora", Release: "44", Arch: "arm64", + Description: "Fedora 44 GenericCloud image for ARM64 KVM", + URL: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2", + }, + { + ID: "kvm-rockylinux-9", Name: "Rocky Linux 9 KVM", + Distro: "rockylinux", Release: "9", Arch: "arm64", + Description: "Rocky Linux 9 GenericCloud image for ARM64 KVM", + URL: "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud-Base.latest.aarch64.qcow2", + }, + } +} + func FindImage(id string) *Image { for _, image := range GetImages() { if image.ID == id { diff --git a/backend/internal/lxc/templates.go b/backend/internal/lxc/templates.go index c608a95..ae0cb03 100644 --- a/backend/internal/lxc/templates.go +++ b/backend/internal/lxc/templates.go @@ -1,5 +1,7 @@ package lxc +import "runtime" + // Template represents an LXC image template type Template struct { ID string `json:"id"` @@ -13,55 +15,65 @@ type Template struct { // GetTemplates returns available LXC image templates (only verified working ones) func GetTemplates() []Template { + arch := defaultTemplateArch() return []Template{ { ID: "ubuntu-noble", Name: "Ubuntu 24.04", - Distro: "ubuntu", Release: "noble", Arch: "amd64", + Distro: "ubuntu", Release: "noble", Arch: arch, Description: "Ubuntu 24.04 LTS", }, { ID: "ubuntu-jammy", Name: "Ubuntu 22.04", - Distro: "ubuntu", Release: "jammy", Arch: "amd64", + Distro: "ubuntu", Release: "jammy", Arch: arch, Description: "Ubuntu 22.04 LTS", }, { ID: "debian-bookworm", Name: "Debian 12", - Distro: "debian", Release: "bookworm", Arch: "amd64", + Distro: "debian", Release: "bookworm", Arch: arch, Description: "Debian 12 (Bookworm)", }, { ID: "debian-bullseye", Name: "Debian 11", - Distro: "debian", Release: "bullseye", Arch: "amd64", + Distro: "debian", Release: "bullseye", Arch: arch, Description: "Debian 11 (Bullseye)", }, { ID: "alpine-3.21", Name: "Alpine 3.21", - Distro: "alpine", Release: "3.21", Arch: "amd64", + Distro: "alpine", Release: "3.21", Arch: arch, Description: "Alpine Linux 3.21", }, { ID: "centos-9-stream", Name: "CentOS 9 Stream", - Distro: "centos", Release: "9-Stream", Arch: "amd64", + Distro: "centos", Release: "9-Stream", Arch: arch, Description: "CentOS 9 Stream", }, { ID: "archlinux-current", Name: "Arch Linux", - Distro: "archlinux", Release: "current", Arch: "amd64", + Distro: "archlinux", Release: "current", Arch: arch, Description: "Arch Linux (Rolling)", }, { ID: "fedora-44", Name: "Fedora 44", - Distro: "fedora", Release: "44", Arch: "amd64", + Distro: "fedora", Release: "44", Arch: arch, Description: "Fedora 44", }, { ID: "rockylinux-10", Name: "Rocky Linux 10", - Distro: "rockylinux", Release: "10", Arch: "amd64", + Distro: "rockylinux", Release: "10", Arch: arch, Description: "Rocky Linux 10", }, } } +func defaultTemplateArch() string { + switch runtime.GOARCH { + case "arm64": + return "arm64" + default: + return "amd64" + } +} + // FindTemplate finds a template by ID func FindTemplate(id string) *Template { templates := GetTemplates() diff --git a/backend/internal/server/web/.gitkeep b/backend/internal/server/web/.gitkeep index 30259b2..e69de29 100644 --- a/backend/internal/server/web/.gitkeep +++ b/backend/internal/server/web/.gitkeep @@ -1 +0,0 @@ - diff --git a/backend/internal/version/version.go b/backend/internal/version/version.go index 2465c5f..bc37028 100644 --- a/backend/internal/version/version.go +++ b/backend/internal/version/version.go @@ -1,7 +1,7 @@ package version var ( - Version = "1.1.21" + Version = "1.1.22" Repo = "MengMengCode/CLICD" ) diff --git a/build.sh b/build.sh index 381bd7e..46236c8 100644 --- a/build.sh +++ b/build.sh @@ -6,6 +6,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BUILD_DIR="$SCRIPT_DIR/build" +DIST_DIR="$SCRIPT_DIR/dist" FRONTEND_DIR="$SCRIPT_DIR/frontend" BACKEND_DIR="$SCRIPT_DIR/backend" WEB_DIR="$SCRIPT_DIR/web" @@ -17,9 +18,11 @@ echo "=====================================" # Clean previous build rm -rf "$BUILD_DIR" +rm -rf "$DIST_DIR" rm -rf "$WEB_DIR" rm -rf "$EMBED_WEB_DIR" mkdir -p "$BUILD_DIR" +mkdir -p "$DIST_DIR" mkdir -p "$WEB_DIR" mkdir -p "$EMBED_WEB_DIR" touch "$EMBED_WEB_DIR/.gitkeep" @@ -51,9 +54,26 @@ cd "$BACKEND_DIR" go mod tidy go mod download -# Build for Linux amd64 BUILD_VERSION="${CLICD_VERSION:-dev}" -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd" . +TARGET_GOOS="${CLICD_GOOS:-linux}" +TARGET_GOARCH="${CLICD_GOARCH:-amd64}" + +case "$TARGET_GOARCH" in + all) TARGET_GOARCH_LIST="amd64 arm64" ;; + amd64|arm64) TARGET_GOARCH_LIST="$TARGET_GOARCH" ;; + *) + echo "Unsupported CLICD_GOARCH: $TARGET_GOARCH (expected amd64, arm64, or all)" >&2 + exit 2 + ;; +esac + +for arch in $TARGET_GOARCH_LIST; do + echo "Target: ${TARGET_GOOS}/${arch}" + GOOS="$TARGET_GOOS" GOARCH="$arch" CGO_ENABLED=0 go build -ldflags="-s -w -X clicd/internal/version.Version=${BUILD_VERSION}" -o "$BUILD_DIR/clicd-linux-${arch}" . +done + +first_arch="${TARGET_GOARCH_LIST%% *}" +cp "$BUILD_DIR/clicd-linux-${first_arch}" "$BUILD_DIR/clicd" echo "Go backend built successfully" @@ -62,7 +82,20 @@ echo "" echo "[3/3] Packaging..." cp -r "$WEB_DIR" "$BUILD_DIR/web" cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true -chmod +x "$BUILD_DIR/clicd" +chmod +x "$BUILD_DIR"/clicd* + +for arch in $TARGET_GOARCH_LIST; do + asset_dir="clicd-linux-${arch}" + package_root="$BUILD_DIR/package-${arch}" + rm -rf "$package_root" + mkdir -p "$package_root/$asset_dir" + cp "$BUILD_DIR/clicd-linux-${arch}" "$package_root/$asset_dir/clicd" + cp "$BUILD_DIR/install.sh" "$package_root/$asset_dir/install.sh" 2>/dev/null || true + chmod +x "$package_root/$asset_dir/clicd" + [ ! -f "$package_root/$asset_dir/install.sh" ] || chmod +x "$package_root/$asset_dir/install.sh" + tar -C "$package_root" -czf "$DIST_DIR/${asset_dir}.tar.gz" "$asset_dir" + cp "$BUILD_DIR/clicd-linux-${arch}" "$DIST_DIR/${asset_dir}" +done echo "" echo "=====================================" @@ -70,6 +103,11 @@ echo " Build Complete!" echo "=====================================" echo " Output: $BUILD_DIR/clicd" echo " Web: $BUILD_DIR/web/" +echo " Dist: $DIST_DIR/" +for arch in $TARGET_GOARCH_LIST; do + echo " dist/clicd-linux-${arch}" + echo " dist/clicd-linux-${arch}.tar.gz" +done echo "" echo " To deploy:" echo " 1. Copy build/ directory to server" diff --git a/docs/developer/build.md b/docs/developer/build.md index 8b1f0fa..d1cc8e0 100644 --- a/docs/developer/build.md +++ b/docs/developer/build.md @@ -30,6 +30,25 @@ bash build.sh 该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。 +默认目标为 Linux amd64。需要构建 ARM64 包时可以指定: + +```bash +CLICD_GOARCH=arm64 bash build.sh +``` + +需要同时构建 amd64 和 arm64 发布包时: + +```bash +CLICD_GOARCH=all bash build.sh +``` + +构建完成后会生成: + +- `dist/clicd-linux-amd64` +- `dist/clicd-linux-amd64.tar.gz` +- `dist/clicd-linux-arm64` +- `dist/clicd-linux-arm64.tar.gz` + ## 文档站构建 ```bash diff --git a/docs/developer/release.md b/docs/developer/release.md index 761ebf1..b9f629f 100644 --- a/docs/developer/release.md +++ b/docs/developer/release.md @@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语 ## Release 产物 -安装脚本会优先下载 Linux AMD64 产物: +安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物: ```text clicd-linux-amd64.tar.gz +clicd-linux-arm64.tar.gz ``` 在部分场景中也会尝试下载单独二进制: ```text clicd-linux-amd64 +clicd-linux-arm64 ``` ## 安装脚本行为 diff --git a/docs/en/developer/build.md b/docs/en/developer/build.md index 1b1a788..96285f9 100644 --- a/docs/en/developer/build.md +++ b/docs/en/developer/build.md @@ -30,6 +30,25 @@ bash build.sh The script chains frontend build, static asset sync, and Go binary build. +The default target is Linux amd64. To build an ARM64 package, set: + +```bash +CLICD_GOARCH=arm64 bash build.sh +``` + +To build both amd64 and arm64 release assets at once: + +```bash +CLICD_GOARCH=all bash build.sh +``` + +The build writes: + +- `dist/clicd-linux-amd64` +- `dist/clicd-linux-amd64.tar.gz` +- `dist/clicd-linux-arm64` +- `dist/clicd-linux-arm64.tar.gz` + ## Docs Build ```bash diff --git a/docs/en/developer/release.md b/docs/en/developer/release.md index 8e16543..2150d8a 100644 --- a/docs/en/developer/release.md +++ b/docs/en/developer/release.md @@ -12,16 +12,18 @@ Check the version in: ## Release Artifacts -The installer first tries to download the Linux AMD64 archive: +The installer first tries to download the Linux AMD64 or ARM64 archive for the host architecture: ```text clicd-linux-amd64.tar.gz +clicd-linux-arm64.tar.gz ``` In some cases, it may also try the standalone binary: ```text clicd-linux-amd64 +clicd-linux-arm64 ``` ## Installer Behavior diff --git a/docs/en/guide/installation.md b/docs/en/guide/installation.md index 1f66d62..5c8371a 100644 --- a/docs/en/guide/installation.md +++ b/docs/en/guide/installation.md @@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version ## Requirements -- Linux x86_64 host. +- Linux x86_64/amd64 or ARM64/aarch64 host. - Root privileges. - systemd. - Network access to GitHub Release downloads. @@ -17,7 +17,7 @@ CLICD provides a one-line installer. By default, it installs the latest version curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh ``` -The script defaults to `CLICD_VERSION=latest`, which downloads `clicd-linux-amd64.tar.gz` from `releases/latest`. +The script defaults to `CLICD_VERSION=latest` and downloads `clicd-linux-amd64.tar.gz` or `clicd-linux-arm64.tar.gz` from `releases/latest` according to the host architecture. ## Install a Specific Version diff --git a/docs/en/guide/introduction.md b/docs/en/guide/introduction.md index f57fc89..e109107 100644 --- a/docs/en/guide/introduction.md +++ b/docs/en/guide/introduction.md @@ -26,4 +26,4 @@ CLICD is a lightweight virtualization management panel for LXC and KVM. It bring - Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack. - Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC. -- Release: GitHub Actions builds Linux AMD64 release artifacts. The installer fetches the latest release by default. +- Release: GitHub Actions builds Linux AMD64/ARM64 release artifacts. The installer fetches the latest release by default. diff --git a/docs/en/operations/faq.md b/docs/en/operations/faq.md index f47fa65..1ce303f 100644 --- a/docs/en/operations/faq.md +++ b/docs/en/operations/faq.md @@ -2,7 +2,7 @@ ## Which version does the installer install by default? -It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 artifact from `releases/latest`. +It installs the latest version from GitHub Releases. The script default is `CLICD_VERSION=latest`, which downloads the Linux AMD64 or ARM64 artifact from `releases/latest` according to the host architecture. ## Can I pin a specific version? diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 47f5d2b..dd446d5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版 ## 环境要求 -- Linux x86_64 宿主机。 +- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。 - root 权限。 - systemd。 - 网络可访问 GitHub Release 下载地址。 @@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版 curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh ``` -脚本当前默认使用 `CLICD_VERSION=latest`,也就是下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz`。 +脚本当前默认使用 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 对应的 `clicd-linux-amd64.tar.gz` 或 `clicd-linux-arm64.tar.gz`。 ## 安装指定版本 diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 4b071b5..1a420cb 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -26,4 +26,4 @@ CLICD 是一个面向 LXC/KVM 的轻量虚拟化管理面板。它把常见宿 - 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。 - 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。 -- 发布:GitHub Actions 构建 Linux AMD64 release 产物,安装脚本默认拉取最新 Release。 +- 发布:GitHub Actions 构建 Linux AMD64/ARM64 release 产物,安装脚本默认拉取最新 Release。 diff --git a/docs/operations/faq.md b/docs/operations/faq.md index 7af1677..54bcf76 100644 --- a/docs/operations/faq.md +++ b/docs/operations/faq.md @@ -2,7 +2,7 @@ ## 安装脚本默认安装哪个版本? -默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。 +默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。 ## 可以固定安装某个版本吗? diff --git a/frontend/package.json b/frontend/package.json index 1703379..d06770a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "clicd-frontend", "private": true, - "version": "1.1.21", + "version": "1.1.22", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/CreateContainerModal.tsx b/frontend/src/components/CreateContainerModal.tsx index fc4fcb8..12f5f31 100644 --- a/frontend/src/components/CreateContainerModal.tsx +++ b/frontend/src/components/CreateContainerModal.tsx @@ -98,6 +98,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist const manualIPv4s = form.public_ipv4s || [] const maxVCPU = hostInfo?.cpu.cores || 64 const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined + const kvmAvailable = !!hostInfo?.runtime?.kvm_available + + useEffect(() => { + if (hostInfo && !kvmAvailable && form.virtualization === 'kvm') { + setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'lxc', template_id: '' })) + } + }, [hostInfo, kvmAvailable, form.virtualization]) const maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB) const natEnabled = form.assign_nat !== false @@ -241,8 +248,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist @@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist {networkText.publicIPv6} - {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)} + {ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)} @@ -762,7 +775,7 @@ const createNetworkText = { zh: { publicIPv4: '公网 IPv4', noAllocatableIPv4: '未检测到可分配公网 IPv4', - publicIPv6: '公网 IPv6', + publicIPv6: '可分配 IPv6 前缀', use: '使用', checkingIPv6Prefix: '正在检测 IPv6 前缀...', publicNAT: '公网 NAT', @@ -771,7 +784,7 @@ const createNetworkText = { en: { publicIPv4: 'Public IPv4', noAllocatableIPv4: 'No allocatable public IPv4 detected', - publicIPv6: 'Public IPv6', + publicIPv6: 'Allocatable IPv6 Prefix', use: 'Use', checkingIPv6Prefix: 'Checking IPv6 prefix...', publicNAT: 'Public NAT', @@ -779,6 +792,21 @@ const createNetworkText = { }, } as const +function formatIPv6StatusReason(reason: string | undefined, language: Language, fallback: string) { + if (!reason) return fallback + if (reason.includes('/128 single-address IPv6 is not assignable')) { + return language === 'en' + ? 'No allocatable IPv6 prefix. The host only has a /128 single IPv6 address.' + : '未检测到可分配 IPv6 前缀;宿主机只有 /128 单个 IPv6 地址,不能分配给容器。' + } + if (reason.includes('outbound IPv6 connectivity test failed')) { + return language === 'en' + ? reason + : '宿主机检测到 IPv6 前缀,但 IPv6 出站连通性测试失败。' + } + return reason +} + function formatAllocatableIPv4Count(count: number, language: Language) { return language === 'en' ? `${count} allocatable address${count === 1 ? '' : 'es'} detected` diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 3815218..8dc21fc 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -395,10 +395,8 @@ export default function Containers() { const isPlaceholder = !!container.isPlaceholder const isPolicyBlocked = !!container.policy_blocked const usage = usageByName[container.name] - const isKVM = (container.virtualization || 'lxc') === 'kvm' - const cpuPct = isRunning - ? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1)) + ? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1)) : 0 const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0 ? usage.memory_total_bytes diff --git a/frontend/src/pages/HostReport.tsx b/frontend/src/pages/HostReport.tsx index 7ddd84b..0dc72dd 100644 --- a/frontend/src/pages/HostReport.tsx +++ b/frontend/src/pages/HostReport.tsx @@ -205,7 +205,7 @@ const hostReportText = { ipv4Address: 'IPv4 地址', ipv4Prefix: 'IPv4 段', ipv6Address: 'IPv6 地址', - ipv6Prefix: 'IPv6 段', + ipv6Prefix: '可分配 IPv6 前缀', gateway: '网关', memoryModules: '内存条', noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限', @@ -277,7 +277,7 @@ const hostReportText = { ipv4Address: 'IPv4 Addresses', ipv4Prefix: 'IPv4 Prefixes', ipv6Address: 'IPv6 Addresses', - ipv6Prefix: 'IPv6 Prefixes', + ipv6Prefix: 'Allocatable IPv6 Prefixes', gateway: 'Gateway', memoryModules: 'Memory Modules', noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.', @@ -511,6 +511,7 @@ function diskTypeLabel(d: { type?: string; rotational?: boolean; virtual?: boole } function gpuTypeLabel(value: string, language: Language) { + if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟' if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显' if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显' return value || '-' diff --git a/frontend/src/pages/ImageManagement.tsx b/frontend/src/pages/ImageManagement.tsx index a564334..eaa0bb1 100644 --- a/frontend/src/pages/ImageManagement.tsx +++ b/frontend/src/pages/ImageManagement.tsx @@ -148,17 +148,19 @@ export default function ImageManagement() { onToggle={handleToggle} /> - img.downloaded).length} - totalCount={kvmImages.length} - onDownload={handleDownload} - onCancelDownload={handleCancelDownload} - onDelete={handleDelete} - onToggle={handleToggle} - /> + {kvmImages.length > 0 && ( + img.downloaded).length} + totalCount={kvmImages.length} + onDownload={handleDownload} + onCancelDownload={handleCancelDownload} + onDelete={handleDelete} + onToggle={handleToggle} + /> + )} ) } diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 03765d2..9ea93dc 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -128,7 +128,7 @@ export default function Login() { -

CLICD v1.1.21

+

CLICD v1.1.22

) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1fc2173..6d8e4be 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -235,6 +235,14 @@ export interface HostInfo { } disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number } load: { load1: number; load5: number; load15: number } + runtime?: { + lxc_available: boolean + kvm_available: boolean + dev_kvm: boolean + nested_virtualization: boolean + nested_detail: string + support_mode: string + } } export interface HostProbeReport { diff --git a/install.sh b/install.sh index 9224d2c..36bffbd 100644 --- a/install.sh +++ b/install.sh @@ -3,7 +3,6 @@ set -eu REPO="${CLICD_REPO:-MengMengCode/CLICD}" CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}" -ASSET="clicd-linux-amd64.tar.gz" ACTION="${1:-install}" ACTION_CONFIRM="${2:-}" ISSUE_URL="https://github.com/${REPO}/issues" @@ -11,6 +10,80 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}" INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}" LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created" +normalize_clicd_arch() { + arch="$1" + case "$(printf '%s' "$arch" | tr 'A-Z' 'a-z')" in + x86_64|amd64) echo amd64 ;; + aarch64|arm64) echo arm64 ;; + *) echo "" ;; + esac +} + +HOST_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)" +CLICD_ARCH_NORMALIZED="$(normalize_clicd_arch "${CLICD_ARCH:-$HOST_ARCH_RAW}")" +ASSET_DIR="clicd-linux-${CLICD_ARCH_NORMALIZED:-unknown}" +ASSET="${ASSET_DIR}.tar.gz" +BINARY_ASSET="$ASSET_DIR" + +kvm_supported_arch() { + [ "$CLICD_ARCH_NORMALIZED" = "amd64" ] || [ "$CLICD_ARCH_NORMALIZED" = "arm64" ] +} + +warn_kvm_unsupported_arch() { + if ! kvm_supported_arch; then + warn "当前架构 ${CLICD_ARCH_NORMALIZED:-unknown} 已适配 CLICD/LXC;KVM 功能当前支持 x86_64/amd64 和 aarch64/arm64,将跳过 KVM 专用依赖。" + fi +} + +qemu_system_package_apk() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo qemu-system-aarch64 ;; + *) echo qemu-system-x86_64 ;; + esac +} + +qemu_system_package_apt() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo qemu-system-arm ;; + *) echo qemu-system-x86 ;; + esac +} + +qemu_system_package_rpm() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo qemu-system-aarch64 ;; + *) echo qemu-kvm ;; + esac +} + +qemu_emulator_cmd() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo qemu-system-aarch64 ;; + *) echo qemu-system-x86_64 ;; + esac +} + +qemu_efi_package_apt() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo qemu-efi-aarch64 ;; + *) echo ovmf ;; + esac +} + +qemu_efi_package_apk() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo edk2-aarch64 ;; + *) echo ovmf ;; + esac +} + +qemu_efi_package_rpm() { + case "$CLICD_ARCH_NORMALIZED" in + arm64) echo edk2-aarch64 ;; + *) echo edk2-ovmf ;; + esac +} + normalize_lang() { lang="$1" case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in @@ -286,14 +359,8 @@ run_step() { } check_os_compatibility() { - log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)" - case "$(uname -m 2>/dev/null || echo unknown)" in - x86_64|amd64) - ;; - *) - die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)。" - ;; - esac + log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}" + [ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}。" if ! is_systemd && ! is_openrc; then die "未检测到 systemd 或 OpenRC,无法安装服务。" fi @@ -476,7 +543,16 @@ remove_clicd_lxc_image_cache() { "centos 9-Stream amd64" \ "archlinux current amd64" \ "fedora 44 amd64" \ - "rockylinux 10 amd64" + "rockylinux 10 amd64" \ + "ubuntu noble arm64" \ + "ubuntu jammy arm64" \ + "debian bookworm arm64" \ + "debian bullseye arm64" \ + "alpine 3.21 arm64" \ + "centos 9-Stream arm64" \ + "archlinux current arm64" \ + "fedora 44 arm64" \ + "rockylinux 10 arm64" do set -- $image distro="$1" @@ -950,15 +1026,21 @@ install_apk() { iproute2 \ iptables \ dnsmasq \ - dbus \ - qemu-system-x86_64 \ + dbus + + if kvm_supported_arch; then + apk add --no-cache \ + "$(qemu_system_package_apk)" \ qemu-img \ libvirt \ libvirt-daemon \ libvirt-client \ libvirt-qemu + else + warn_kvm_unsupported_arch + fi - for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools; do + for pkg in lxcfs shadow conntrack-tools quota-tools e2fsprogs xfsprogs cloud-utils genisoimage xorriso smartmontools "$(qemu_efi_package_apk)"; do apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" done } @@ -985,18 +1067,39 @@ install_apt() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq-base \ - qemu-kvm \ - qemu-system-x86 \ - qemu-utils \ - libvirt-daemon-system \ - libvirt-clients \ - cloud-image-utils \ - genisoimage \ - xorriso \ - smartmontools \ - virtinst \ - ovmf + dnsmasq-base + + if kvm_supported_arch; then + if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then + apt-get install -y \ + "$(qemu_system_package_apt)" \ + qemu-utils \ + libvirt-daemon-system \ + libvirt-clients \ + cloud-image-utils \ + genisoimage \ + xorriso \ + smartmontools \ + virtinst \ + "$(qemu_efi_package_apt)" + else + apt-get install -y \ + qemu-kvm \ + "$(qemu_system_package_apt)" \ + qemu-utils \ + libvirt-daemon-system \ + libvirt-clients \ + cloud-image-utils \ + genisoimage \ + xorriso \ + smartmontools \ + virtinst \ + "$(qemu_efi_package_apt)" + fi + else + warn_kvm_unsupported_arch + apt-get install -y qemu-utils genisoimage xorriso smartmontools >/dev/null 2>&1 || true + fi } enable_el_repos() { @@ -1032,8 +1135,11 @@ install_dnf() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq \ - qemu-kvm \ + dnsmasq + + if kvm_supported_arch; then + dnf install -y \ + "$(qemu_system_package_rpm)" \ qemu-img \ libvirt \ libvirt-daemon-kvm \ @@ -1041,8 +1147,12 @@ install_dnf() { virt-install \ cloud-utils \ genisoimage + else + warn_kvm_unsupported_arch + dnf install -y qemu-img genisoimage >/dev/null 2>&1 || true + fi - for pkg in lxcfs xorriso edk2-ovmf smartmontools; do + for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" done } @@ -1067,8 +1177,11 @@ install_yum() { quota \ e2fsprogs \ xfsprogs \ - dnsmasq \ - qemu-kvm \ + dnsmasq + + if kvm_supported_arch; then + yum install -y \ + "$(qemu_system_package_rpm)" \ qemu-img \ libvirt \ libvirt-daemon-kvm \ @@ -1076,8 +1189,12 @@ install_yum() { virt-install \ cloud-utils \ genisoimage + else + warn_kvm_unsupported_arch + yum install -y qemu-img genisoimage >/dev/null 2>&1 || true + fi - for pkg in lxcfs xorriso edk2-ovmf smartmontools; do + for pkg in lxcfs xorriso "$(qemu_efi_package_rpm)" smartmontools; do yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg" done } @@ -1117,14 +1234,19 @@ install_dependencies() { has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。" has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。" has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。" - has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。" - has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。" - has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。" - if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then - die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。" - fi - if [ ! -e /dev/kvm ]; then - warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。" + if kvm_supported_arch; then + has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。" + has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。" + has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。" + has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。" + if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then + die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。" + fi + if [ ! -e /dev/kvm ]; then + warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。" + fi + else + warn_kvm_unsupported_arch fi } @@ -1384,7 +1506,7 @@ download_release_if_needed() { if [ "$archive_ok" = "1" ]; then tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path" else - binary_asset="clicd-linux-amd64" + binary_asset="$BINARY_ASSET" if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}" else @@ -1402,9 +1524,9 @@ download_release_if_needed() { [ -n "$url" ] || continue log "Trying release binary: $url" if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then - mkdir -p "$tmp_dir/clicd-linux-amd64" - cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd" - chmod +x "$tmp_dir/clicd-linux-amd64/clicd" + mkdir -p "$tmp_dir/$ASSET_DIR" + cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd" + chmod +x "$tmp_dir/$ASSET_DIR/clicd" binary_ok=1 break fi @@ -1414,8 +1536,8 @@ download_release_if_needed() { [ "$binary_ok" = "1" ] || die "Release package download failed: $download_url" fi - [ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory" - [ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。" + [ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory" + [ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。" } install_binary() { @@ -1430,8 +1552,8 @@ install_binary() { download_dir="" if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)" - if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then - bin_src="$download_dir/clicd-linux-amd64/clicd" + if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then + bin_src="$download_dir/$ASSET_DIR/clicd" fi fi [ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"