mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
release: v1.1.22
This commit is contained in:
+38
-10
@@ -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/*
|
||||
|
||||
- name: Publish GitHub Release
|
||||
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
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
+355
-17
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-12
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -127,7 +128,9 @@ var cliTranslations = map[string]string{
|
||||
"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.",
|
||||
"当前架构不支持自动升级": "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",
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+67
-11
@@ -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 = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
|
||||
input = "\n\t <input type='tablet' bus='usb'/>"
|
||||
}
|
||||
osAttrs := ""
|
||||
features := "<features><acpi/><apic/></features>"
|
||||
if runtime.GOARCH == "arm64" {
|
||||
osAttrs = " firmware='efi'"
|
||||
features = "<features><acpi/><gic version='3'/></features>"
|
||||
}
|
||||
seedDisk := fmt.Sprintf(`<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='%s'/>
|
||||
<target dev='hdb' bus='ide'/>
|
||||
<readonly/>
|
||||
</disk>`, xmlEscape(seedPath))
|
||||
if runtime.GOARCH == "arm64" {
|
||||
seedDisk = fmt.Sprintf(`<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='%s'/>
|
||||
<target dev='vdb' bus='virtio'/>
|
||||
<readonly/>
|
||||
</disk>`, xmlEscape(seedPath))
|
||||
}
|
||||
return fmt.Sprintf(`<domain type='kvm'>
|
||||
<name>%s</name>
|
||||
%s
|
||||
@@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
||||
<currentMemory unit='MiB'>%d</currentMemory>
|
||||
<vcpu placement='static' current='%d'>%d</vcpu>
|
||||
<cputune><shares>2048</shares></cputune>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc'>hvm</type>
|
||||
<os%s>
|
||||
<type arch='%s' machine='%s'>hvm</type>
|
||||
<boot dev='hd'/>
|
||||
</os>
|
||||
<features><acpi/><apic/></features>
|
||||
%s
|
||||
<cpu mode='host-passthrough' check='none'/>
|
||||
<clock offset='utc'/>
|
||||
<on_poweroff>destroy</on_poweroff>
|
||||
<on_reboot>restart</on_reboot>
|
||||
<on_crash>restart</on_crash>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<emulator>%s</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2' cache='none'/>
|
||||
<source file='%s'/>
|
||||
<target dev='vda' bus='virtio'/>%s
|
||||
</disk>
|
||||
<disk type='file' device='cdrom'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='%s'/>
|
||||
<target dev='hdb' bus='ide'/>
|
||||
<readonly/>
|
||||
</disk>
|
||||
%s
|
||||
<interface type='network'>
|
||||
<mac address='%s'/>
|
||||
<source network='default'/>
|
||||
@@ -2232,7 +2288,7 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
||||
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
|
||||
%s
|
||||
</devices>
|
||||
</domain>`, xmlEscape(name), domainUUIDXML(name), ramMB, ramMB, vcpu, vcpu, xmlEscape(diskPath), iotune, xmlEscape(seedPath), xmlEscape(mac), bandwidth, input, video)
|
||||
</domain>`, 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package version
|
||||
|
||||
var (
|
||||
Version = "1.1.21"
|
||||
Version = "1.1.22"
|
||||
Repo = "MengMengCode/CLICD"
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 安装脚本行为
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 安装指定版本
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 安装脚本默认安装哪个版本?
|
||||
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会下载 `releases/latest` 下的 Linux AMD64 产物。
|
||||
默认安装 GitHub Releases 的最新版本。脚本中默认值是 `CLICD_VERSION=latest`,会按宿主架构下载 `releases/latest` 下的 Linux AMD64 或 ARM64 产物。
|
||||
|
||||
## 可以固定安装某个版本吗?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "clicd-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.21",
|
||||
"version": "1.1.22",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
disabled={!kvmAvailable}
|
||||
title={kvmAvailable ? '' : '当前宿主机不支持 KVM'}
|
||||
onClick={() => {
|
||||
if (kvmAvailable) {
|
||||
setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))
|
||||
}
|
||||
}}
|
||||
className={`rounded-md border px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400 ${form.virtualization === 'kvm' ? 'border-black bg-black text-white' : 'border-gray-300 text-gray-700 hover:bg-gray-50'}`}
|
||||
>
|
||||
KVM 虚拟机
|
||||
</button>
|
||||
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||
<span className="block text-xs text-gray-500 truncate">
|
||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : (ipv6Status?.reason || networkText.checkingIPv6Prefix)}
|
||||
{ipv6Available ? `${networkText.use} ${ipv6Prefix}` : formatIPv6StatusReason(ipv6Status?.reason, language, networkText.checkingIPv6Prefix)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || '-'
|
||||
|
||||
@@ -148,6 +148,7 @@ export default function ImageManagement() {
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
|
||||
{kvmImages.length > 0 && (
|
||||
<ImageTable
|
||||
title="KVM 虚拟机镜像"
|
||||
images={kvmImages}
|
||||
@@ -159,6 +160,7 @@ export default function ImageManagement() {
|
||||
onDelete={handleDelete}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Login() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.21</p>
|
||||
<p className="text-center text-xs text-gray-400 mt-6">CLICD v1.1.22</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+153
-31
@@ -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,9 +1067,12 @@ install_apt() {
|
||||
quota \
|
||||
e2fsprogs \
|
||||
xfsprogs \
|
||||
dnsmasq-base \
|
||||
qemu-kvm \
|
||||
qemu-system-x86 \
|
||||
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 \
|
||||
@@ -996,7 +1081,25 @@ install_apt() {
|
||||
xorriso \
|
||||
smartmontools \
|
||||
virtinst \
|
||||
ovmf
|
||||
"$(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,7 +1234,9 @@ install_dependencies() {
|
||||
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
|
||||
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
|
||||
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
|
||||
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
|
||||
@@ -1126,6 +1245,9 @@ install_dependencies() {
|
||||
if [ ! -e /dev/kvm ]; then
|
||||
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
|
||||
fi
|
||||
else
|
||||
warn_kvm_unsupported_arch
|
||||
fi
|
||||
}
|
||||
|
||||
configure_kernel_networking() {
|
||||
@@ -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 二进制,安装无法继续。"
|
||||
|
||||
Reference in New Issue
Block a user