mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-06 05:52:19 +08:00
release: v1.1.22
This commit is contained in:
+38
-10
@@ -14,9 +14,15 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux-amd64:
|
linux:
|
||||||
name: Linux amd64
|
name: Linux ${{ matrix.goarch }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -55,16 +61,21 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Build CLICD
|
- name: Build CLICD
|
||||||
|
env:
|
||||||
|
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||||
run: bash build.sh
|
run: bash build.sh
|
||||||
|
|
||||||
- name: Package CLICD
|
- name: Package CLICD
|
||||||
|
env:
|
||||||
|
CLICD_GOARCH: ${{ matrix.goarch }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist package/clicd-linux-amd64
|
asset_dir="clicd-linux-${CLICD_GOARCH}"
|
||||||
cp build/clicd package/clicd-linux-amd64/clicd
|
mkdir -p "dist" "package/${asset_dir}"
|
||||||
cp build/install.sh package/clicd-linux-amd64/install.sh
|
cp build/clicd "package/${asset_dir}/clicd"
|
||||||
chmod +x package/clicd-linux-amd64/clicd package/clicd-linux-amd64/install.sh
|
cp build/install.sh "package/${asset_dir}/install.sh"
|
||||||
tar -C package -czf dist/clicd-linux-amd64.tar.gz clicd-linux-amd64
|
chmod +x "package/${asset_dir}/clicd" "package/${asset_dir}/install.sh"
|
||||||
cp build/clicd dist/clicd-linux-amd64
|
tar -C package -czf "dist/${asset_dir}.tar.gz" "${asset_dir}"
|
||||||
|
cp build/clicd "dist/${asset_dir}"
|
||||||
|
|
||||||
- name: Package Mofang module
|
- name: Package Mofang module
|
||||||
run: |
|
run: |
|
||||||
@@ -87,11 +98,28 @@ jobs:
|
|||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: clicd-linux-amd64
|
name: clicd-linux-${{ matrix.goarch }}
|
||||||
path: dist/*
|
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
|
- name: Publish GitHub Release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ backend/internal/server/web/*
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/build/
|
/build/
|
||||||
|
/dist/
|
||||||
Mofang/*.zip
|
Mofang/*.zip
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -69,3 +70,4 @@ push-release.ps1
|
|||||||
deploy.ps1
|
deploy.ps1
|
||||||
backend/clicd
|
backend/clicd
|
||||||
api.md
|
api.md
|
||||||
|
deploy-arm.ps1
|
||||||
|
|||||||
+355
-17
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -219,6 +220,9 @@ var hostCPUMu sync.Mutex
|
|||||||
var lastHostCPU cpuTimes
|
var lastHostCPU cpuTimes
|
||||||
var hostIOMu sync.Mutex
|
var hostIOMu sync.Mutex
|
||||||
var lastHostIO hostIOSample
|
var lastHostIO hostIOSample
|
||||||
|
var egressIPv4Mu sync.Mutex
|
||||||
|
var cachedEgressIPv4 lxc.PublicIPInfo
|
||||||
|
var cachedEgressIPv4At time.Time
|
||||||
|
|
||||||
type cpuTimes struct {
|
type cpuTimes struct {
|
||||||
Total uint64
|
Total uint64
|
||||||
@@ -397,7 +401,7 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|||||||
now := unixNano()
|
now := unixNano()
|
||||||
|
|
||||||
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
network := NetworkInfo{RXBytes: rx, TXBytes: tx}
|
||||||
publicIPv4 := lxc.DetectPublicIPv4()
|
publicIPv4 := detectDisplayPublicIPv4()
|
||||||
network.PublicIPv4 = publicIPv4.Address
|
network.PublicIPv4 = publicIPv4.Address
|
||||||
network.PublicIPv4Interface = publicIPv4.Interface
|
network.PublicIPv4Interface = publicIPv4.Interface
|
||||||
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
network.PublicIPv4Addresses = lxc.DetectFreePublicIPv4Candidates(0)
|
||||||
@@ -437,23 +441,79 @@ func getHostRates() (NetworkInfo, DiskIOInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readHostNetworkBytes() (uint64, uint64) {
|
func readHostNetworkBytes() (uint64, uint64) {
|
||||||
entries, err := os.ReadDir("/sys/class/net")
|
ifaces := detectHostTrafficInterfaces()
|
||||||
if err != nil {
|
if len(ifaces) == 0 {
|
||||||
return 0, 0
|
ifaces = fallbackHostTrafficInterfaces()
|
||||||
}
|
}
|
||||||
|
|
||||||
var rx, tx uint64
|
var rx, tx uint64
|
||||||
for _, entry := range entries {
|
for _, name := range ifaces {
|
||||||
name := entry.Name()
|
|
||||||
if name == "lo" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
rx += readUintFile("/sys/class/net/" + name + "/statistics/rx_bytes")
|
||||||
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
tx += readUintFile("/sys/class/net/" + name + "/statistics/tx_bytes")
|
||||||
}
|
}
|
||||||
return rx, tx
|
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) {
|
func readHostDiskBytes() (uint64, uint64) {
|
||||||
f, err := os.Open("/proc/diskstats")
|
f, err := os.Open("/proc/diskstats")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -574,6 +634,8 @@ func trimOSReleaseValue(value string) string {
|
|||||||
|
|
||||||
func detectHostCPUProbe() HostCPUProbe {
|
func detectHostCPUProbe() HostCPUProbe {
|
||||||
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
|
probe := HostCPUProbe{Cores: runtime.NumCPU(), Threads: runtime.NumCPU(), Architecture: runtime.GOARCH}
|
||||||
|
armImplementer := ""
|
||||||
|
armPart := ""
|
||||||
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
if data, err := os.ReadFile("/proc/cpuinfo"); err == nil {
|
||||||
seenFlags := map[string]bool{}
|
seenFlags := map[string]bool{}
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
@@ -582,19 +644,28 @@ func detectHostCPUProbe() HostCPUProbe {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.TrimSpace(fields[0])
|
key := strings.TrimSpace(fields[0])
|
||||||
|
keyLower := strings.ToLower(key)
|
||||||
value := strings.TrimSpace(fields[1])
|
value := strings.TrimSpace(fields[1])
|
||||||
switch key {
|
switch keyLower {
|
||||||
case "model name", "Hardware", "Processor":
|
case "model name", "hardware", "processor":
|
||||||
if probe.Model == "" {
|
if probe.Model == "" && meaningfulCPUModel(value) {
|
||||||
probe.Model = value
|
probe.Model = value
|
||||||
}
|
}
|
||||||
case "cpu cores":
|
case "cpu cores":
|
||||||
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
|
if cores, err := strconv.Atoi(value); err == nil && cores > probe.Cores {
|
||||||
probe.Cores = 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) {
|
for _, flag := range strings.Fields(value) {
|
||||||
if flag == "vmx" || flag == "svm" {
|
if flag == "vmx" || flag == "svm" || flag == "virt" {
|
||||||
probe.Virtualization = true
|
probe.Virtualization = true
|
||||||
probe.VirtualizationKey = flag
|
probe.VirtualizationKey = flag
|
||||||
}
|
}
|
||||||
@@ -607,12 +678,132 @@ func detectHostCPUProbe() HostCPUProbe {
|
|||||||
}
|
}
|
||||||
sort.Strings(probe.Flags)
|
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 == "" {
|
if probe.Model == "" {
|
||||||
probe.Model = "Unknown"
|
probe.Model = "Unknown"
|
||||||
}
|
}
|
||||||
return probe
|
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 {
|
func detectMemoryModules() []HostMemoryModule {
|
||||||
if !commandExists("dmidecode") {
|
if !commandExists("dmidecode") {
|
||||||
return nil
|
return nil
|
||||||
@@ -724,7 +915,7 @@ func isVirtualBlockDevice(name, model, vendor string) bool {
|
|||||||
}
|
}
|
||||||
for _, token := range []string{
|
for _, token := range []string{
|
||||||
"qemu", "virtio", "virtual", "vmware", "vbox", "xen",
|
"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) {
|
if strings.Contains(lower, token) {
|
||||||
return true
|
return true
|
||||||
@@ -1153,9 +1344,126 @@ func detectAllPublicIPv4() []string {
|
|||||||
result = append(result, value)
|
result = append(result, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if egress := detectEgressPublicIPv4(); egress.Address != "" {
|
||||||
|
if !seen[egress.Address] {
|
||||||
|
seen[egress.Address] = true
|
||||||
|
result = append(result, egress.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
return result
|
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 {
|
func collectIPv4Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||||
result := make([]HostIPProbe, 0)
|
result := make([]HostIPProbe, 0)
|
||||||
for _, nic := range nics {
|
for _, nic := range nics {
|
||||||
@@ -1301,6 +1609,16 @@ func isContainerLikeInterfaceName(iface string) bool {
|
|||||||
return false
|
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 {
|
func collectIPv6Addresses(nics []HostNICProbe) []HostIPProbe {
|
||||||
result := make([]HostIPProbe, 0)
|
result := make([]HostIPProbe, 0)
|
||||||
for _, nic := range nics {
|
for _, nic := range nics {
|
||||||
@@ -1368,6 +1686,8 @@ func detectGPUVendor(value string) string {
|
|||||||
return "NVIDIA"
|
return "NVIDIA"
|
||||||
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
|
case strings.Contains(lower, "amd") || strings.Contains(lower, "ati"):
|
||||||
return "AMD"
|
return "AMD"
|
||||||
|
case strings.Contains(lower, "virtio") || strings.Contains(lower, "red hat") || strings.Contains(lower, "qemu"):
|
||||||
|
return "Virtio"
|
||||||
default:
|
default:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
}
|
}
|
||||||
@@ -1375,6 +1695,9 @@ func detectGPUVendor(value string) string {
|
|||||||
|
|
||||||
func detectGPUType(value string) string {
|
func detectGPUType(value string) string {
|
||||||
lower := strings.ToLower(value)
|
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") {
|
if strings.Contains(lower, "intel") {
|
||||||
return "integrated"
|
return "integrated"
|
||||||
}
|
}
|
||||||
@@ -1394,9 +1717,10 @@ func detectRuntimeProbe(env []HostEnvCheck) HostRuntimeProbe {
|
|||||||
devKVM := fileExists("/dev/kvm")
|
devKVM := fileExists("/dev/kvm")
|
||||||
nested, detail := detectNestedVirtualization()
|
nested, detail := detectNestedVirtualization()
|
||||||
lxcOK := envCheckOK(env, "lxc-create")
|
lxcOK := envCheckOK(env, "lxc-create")
|
||||||
|
kvmSupportedArch := runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64"
|
||||||
probe := HostRuntimeProbe{
|
probe := HostRuntimeProbe{
|
||||||
LXCAvailable: lxcOK,
|
LXCAvailable: lxcOK,
|
||||||
KVMAvailable: devKVM && envCheckOK(env, "virsh"),
|
KVMAvailable: kvmSupportedArch && devKVM && envCheckOK(env, "virsh") && envCheckOK(env, kvmQEMUCheckKey()),
|
||||||
DevKVM: devKVM,
|
DevKVM: devKVM,
|
||||||
NestedVirtualization: nested,
|
NestedVirtualization: nested,
|
||||||
NestedDetail: detail,
|
NestedDetail: detail,
|
||||||
@@ -1446,6 +1770,7 @@ func detectSystemProbe() HostSystemProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func detectHostEnvironment() []HostEnvCheck {
|
func detectHostEnvironment() []HostEnvCheck {
|
||||||
|
qemuCheck := commandCheck(kvmQEMUCheckKey(), "QEMU/KVM 虚拟机", false, kvmQEMUCommand(), "")
|
||||||
checks := []HostEnvCheck{
|
checks := []HostEnvCheck{
|
||||||
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
|
commandCheck("service-manager", "服务管理器 systemd/OpenRC", true, "systemctl", "systemd"),
|
||||||
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
|
commandCheck("lxc-create", "LXC 创建工具", true, "lxc-create", ""),
|
||||||
@@ -1454,7 +1779,7 @@ func detectHostEnvironment() []HostEnvCheck {
|
|||||||
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
commandCheck("ip", "iproute2 网络工具", true, "ip", ""),
|
||||||
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
commandCheck("conntrack", "conntrack 安全扫描", false, "conntrack", ""),
|
||||||
commandCheck("virsh", "libvirt virsh", false, "virsh", ""),
|
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("genisoimage", "KVM cloud-init ISO 工具", false, "genisoimage", "xorriso/mkisofs 可替代"),
|
||||||
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
commandCheck("xorriso", "ISO 备用工具", false, "xorriso", ""),
|
||||||
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
commandCheck("smartctl", "硬盘健康检测", false, "smartctl", ""),
|
||||||
@@ -1467,6 +1792,19 @@ func detectHostEnvironment() []HostEnvCheck {
|
|||||||
return checks
|
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 {
|
func commandCheck(key, label string, required bool, cmd string, fallback string) HostEnvCheck {
|
||||||
ok := commandExists(cmd)
|
ok := commandExists(cmd)
|
||||||
detail := "missing"
|
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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -227,9 +228,14 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
enabledSet := getEnabledImageSet()
|
enabledSet := getEnabledImageSet()
|
||||||
cleanupOldImageDownloadErrors()
|
cleanupOldImageDownloadErrors()
|
||||||
|
kvmAvailable := hostKVMAvailable()
|
||||||
|
|
||||||
templates := lxc.GetTemplates()
|
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 {
|
for _, t := range templates {
|
||||||
dl := imageDownloadInfo(t.ID)
|
dl := imageDownloadInfo(t.ID)
|
||||||
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
downloaded, size := imageDownloadedInfo(t.Distro, t.Release, t.Arch)
|
||||||
@@ -252,7 +258,7 @@ func HandleImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for _, t := range kvm.GetImages() {
|
for _, t := range kvmImages {
|
||||||
dl := imageDownloadInfo(t.ID)
|
dl := imageDownloadInfo(t.ID)
|
||||||
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
downloaded, size := kvm.ImageDownloadedInfo(t.ID)
|
||||||
manualPath := ""
|
manualPath := ""
|
||||||
@@ -309,6 +315,10 @@ func HandleImageDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
jsonResponse(w, http.StatusNotFound, APIResponse{Success: false, Message: "Template not found"})
|
||||||
return
|
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 {
|
if ok, _ := kvm.ImageDownloadedInfo(image.ID); ok {
|
||||||
ensureImageEnabled(image.ID)
|
ensureImageEnabled(image.ID)
|
||||||
clearImageDownload(image.ID)
|
clearImageDownload(image.ID)
|
||||||
@@ -534,6 +544,10 @@ func HandleEnabledImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
result := make([]map[string]string, 0)
|
result := make([]map[string]string, 0)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: result})
|
||||||
|
return
|
||||||
|
}
|
||||||
for _, t := range kvm.GetImages() {
|
for _, t := range kvm.GetImages() {
|
||||||
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
if downloaded, _ := kvm.ImageDownloadedInfo(t.ID); enabledSet[t.ID] && downloaded {
|
||||||
result = append(result, map[string]string{
|
result = append(result, map[string]string{
|
||||||
@@ -563,6 +577,9 @@ func isTemplateEnabledAndDownloaded(templateID string) bool {
|
|||||||
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
||||||
runtime = runtimeFromRequest(runtime)
|
runtime = runtimeFromRequest(runtime)
|
||||||
if runtime == config.VirtualizationKVM {
|
if runtime == config.VirtualizationKVM {
|
||||||
|
if !hostKVMAvailable() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
image := kvm.FindImage(templateID)
|
image := kvm.FindImage(templateID)
|
||||||
if image == nil {
|
if image == nil {
|
||||||
return false
|
return false
|
||||||
@@ -579,6 +596,13 @@ func isImageEnabledAndDownloaded(templateID string, runtime string) bool {
|
|||||||
return enabledSet[tmpl.ID] && isImageDownloaded(tmpl.Distro, tmpl.Release, tmpl.Arch)
|
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) {
|
func ensureImageEnabled(id string) {
|
||||||
// If the enabled list is empty, all templates are currently enabled by default.
|
// 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.
|
// We must populate the list with all template IDs first so that explicit toggles stick.
|
||||||
|
|||||||
+53
-37
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -125,32 +126,34 @@ var cliTranslations = map[string]string{
|
|||||||
"检查仓库": "Checking repository",
|
"检查仓库": "Checking repository",
|
||||||
"检查 GitHub 最新版本失败": "Failed to check the latest GitHub version",
|
"检查 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.",
|
"GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.",
|
||||||
"最新版本": "Latest version",
|
"最新版本": "Latest version",
|
||||||
"发布页面": "Release page",
|
"发布页面": "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",
|
||||||
"当前已经是最新版本。": "The current version is already the latest.",
|
"最新 Release 没有找到": "The latest release does not contain",
|
||||||
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
"无法自动升级。": "automatic upgrade is unavailable.",
|
||||||
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
"当前已经是最新版本。": "The current version is already the latest.",
|
||||||
"已取消。": "Cancelled.",
|
"是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue",
|
||||||
"升级失败": "Upgrade failed",
|
"输入 upgrade 开始升级": "Type upgrade to start upgrade",
|
||||||
"升级完成": "Upgrade completed",
|
"已取消。": "Cancelled.",
|
||||||
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
"升级失败": "Upgrade failed",
|
||||||
"GitHub API 返回": "GitHub API returned",
|
"升级完成": "Upgrade completed",
|
||||||
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
"原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.",
|
||||||
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
"GitHub API 返回": "GitHub API returned",
|
||||||
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
"GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.",
|
||||||
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
"GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.",
|
||||||
"正在下载升级包...": "Downloading upgrade package...",
|
"GitHub releases/latest 返回": "GitHub releases/latest returned",
|
||||||
"正在解压升级包...": "Extracting upgrade package...",
|
"无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect",
|
||||||
"解压失败": "Extraction failed",
|
"正在下载升级包...": "Downloading upgrade package...",
|
||||||
"备份旧二进制失败": "Failed to back up old binary",
|
"正在解压升级包...": "Extracting upgrade package...",
|
||||||
"旧版本已备份": "Old version backed up",
|
"解压失败": "Extraction failed",
|
||||||
"正在替换二进制...": "Replacing binary...",
|
"备份旧二进制失败": "Failed to back up old binary",
|
||||||
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
"旧版本已备份": "Old version backed up",
|
||||||
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
"正在替换二进制...": "Replacing binary...",
|
||||||
"下载失败,HTTP": "Download failed, HTTP",
|
"停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt",
|
||||||
"升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package",
|
"二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed",
|
||||||
"将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.",
|
"下载失败,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.",
|
"导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.",
|
||||||
"导入失败": "Import failed",
|
"导入失败": "Import failed",
|
||||||
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
"没有发现新的 ct-* 容器。": "No new ct-* containers found.",
|
||||||
@@ -557,11 +560,16 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
if repo == "" {
|
if repo == "" {
|
||||||
repo = version.Repo
|
repo = version.Repo
|
||||||
}
|
}
|
||||||
|
assetName, err := releaseArchiveAssetName(runtime.GOARCH)
|
||||||
|
if err != nil {
|
||||||
|
cliPrintf("当前架构不支持自动升级: %s\n", runtime.GOARCH)
|
||||||
|
return
|
||||||
|
}
|
||||||
current := version.Current()
|
current := version.Current()
|
||||||
cliPrintf("当前版本: %s\n", current)
|
cliPrintf("当前版本: %s\n", current)
|
||||||
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
cliPrintf("检查仓库: https://github.com/%s\n", repo)
|
||||||
|
|
||||||
release, err := fetchLatestRelease(repo)
|
release, err := fetchLatestRelease(repo, assetName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
cliPrintf("检查 GitHub 最新版本失败: %v\n", err)
|
||||||
return
|
return
|
||||||
@@ -576,9 +584,9 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
cliPrintf("发布页面: %s\n", release.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz")
|
assetURL := findReleaseAsset(release, assetName)
|
||||||
if assetURL == "" {
|
if assetURL == "" {
|
||||||
cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。")
|
cliPrintf("最新 Release 没有找到 %s,无法自动升级。\n", assetName)
|
||||||
return
|
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)
|
cliPrintf("升级失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -605,7 +613,7 @@ func cliUpgradeSystem(reader *bufio.Reader) {
|
|||||||
cliPrintln("原有数据已保留,Web 服务已重启。")
|
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)
|
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -617,7 +625,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
client := &http.Client{Timeout: 20 * time.Second}
|
client := &http.Client{Timeout: 20 * time.Second}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil {
|
if fallback, fallbackErr := fetchLatestReleaseFallback(repo, assetName); fallbackErr == nil {
|
||||||
return fallback, nil
|
return fallback, nil
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -627,7 +635,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
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 {
|
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||||
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
cliPrintln("GitHub API 被限流,已切换到备用检查方式。")
|
||||||
} else {
|
} else {
|
||||||
@@ -645,7 +653,7 @@ func fetchLatestRelease(repo string) (*githubRelease, error) {
|
|||||||
return &release, nil
|
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)
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://github.com/%s/releases/latest", repo), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -667,7 +675,6 @@ func fetchLatestReleaseFallback(repo string) (*githubRelease, error) {
|
|||||||
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
|
return nil, fmt.Errorf("无法从 GitHub releases/latest 跳转结果解析最新版本")
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetName = "clicd-linux-amd64.tar.gz"
|
|
||||||
return &githubRelease{
|
return &githubRelease{
|
||||||
TagName: tag,
|
TagName: tag,
|
||||||
Name: 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 {
|
func findReleaseAsset(release *githubRelease, name string) string {
|
||||||
for _, asset := range release.Assets {
|
for _, asset := range release.Assets {
|
||||||
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
if asset.Name == name && asset.BrowserDownloadURL != "" {
|
||||||
@@ -717,14 +733,14 @@ func findReleaseAsset(release *githubRelease, name string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func upgradeFromReleaseAsset(assetURL, latest string) error {
|
func upgradeFromReleaseAsset(assetURL, latest, assetName string) error {
|
||||||
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
|
tmpDir, err := os.MkdirTemp("", "clicd-upgrade-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz")
|
archivePath := filepath.Join(tmpDir, assetName)
|
||||||
cliPrintln("正在下载升级包...")
|
cliPrintln("正在下载升级包...")
|
||||||
if err := downloadFile(assetURL, archivePath); err != nil {
|
if err := downloadFile(assetURL, archivePath); err != nil {
|
||||||
return err
|
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) {
|
func TestCopyFileToBackupRejectsUnsafeFileName(t *testing.T) {
|
||||||
unsafeNames := []string{
|
unsafeNames := []string{
|
||||||
"../clicd",
|
"../clicd",
|
||||||
|
|||||||
+67
-11
@@ -20,6 +20,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1538,7 +1539,13 @@ func (m *Manager) validateHost(skipCloudInit bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := requireCommand(kvmEmulatorCommand()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if skipCloudInit {
|
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 {
|
if err := requireAnyCommand("genisoimage", "mkisofs", "xorriso"); err != nil {
|
||||||
return fmt.Errorf("%w (needed to generate Windows unattended setup ISO)", err)
|
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, ", "))
|
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 {
|
func ensureDefaultNetwork() error {
|
||||||
// Ensure libvirtd is running
|
// Ensure libvirtd is running
|
||||||
if err := exec.Command("systemctl", "start", "libvirtd").Run(); err != nil {
|
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>"
|
video = "<video><model type='qxl' ram='65536' vram='65536' heads='1' primary='yes'/></video>"
|
||||||
input = "\n\t <input type='tablet' bus='usb'/>"
|
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'>
|
return fmt.Sprintf(`<domain type='kvm'>
|
||||||
<name>%s</name>
|
<name>%s</name>
|
||||||
%s
|
%s
|
||||||
@@ -2193,29 +2254,24 @@ func domainXML(name string, vcpu int, ramMB int, diskPath, seedPath, mac string,
|
|||||||
<currentMemory unit='MiB'>%d</currentMemory>
|
<currentMemory unit='MiB'>%d</currentMemory>
|
||||||
<vcpu placement='static' current='%d'>%d</vcpu>
|
<vcpu placement='static' current='%d'>%d</vcpu>
|
||||||
<cputune><shares>2048</shares></cputune>
|
<cputune><shares>2048</shares></cputune>
|
||||||
<os>
|
<os%s>
|
||||||
<type arch='x86_64' machine='pc'>hvm</type>
|
<type arch='%s' machine='%s'>hvm</type>
|
||||||
<boot dev='hd'/>
|
<boot dev='hd'/>
|
||||||
</os>
|
</os>
|
||||||
<features><acpi/><apic/></features>
|
%s
|
||||||
<cpu mode='host-passthrough' check='none'/>
|
<cpu mode='host-passthrough' check='none'/>
|
||||||
<clock offset='utc'/>
|
<clock offset='utc'/>
|
||||||
<on_poweroff>destroy</on_poweroff>
|
<on_poweroff>destroy</on_poweroff>
|
||||||
<on_reboot>restart</on_reboot>
|
<on_reboot>restart</on_reboot>
|
||||||
<on_crash>restart</on_crash>
|
<on_crash>restart</on_crash>
|
||||||
<devices>
|
<devices>
|
||||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
<emulator>%s</emulator>
|
||||||
<disk type='file' device='disk'>
|
<disk type='file' device='disk'>
|
||||||
<driver name='qemu' type='qcow2' cache='none'/>
|
<driver name='qemu' type='qcow2' cache='none'/>
|
||||||
<source file='%s'/>
|
<source file='%s'/>
|
||||||
<target dev='vda' bus='virtio'/>%s
|
<target dev='vda' bus='virtio'/>%s
|
||||||
</disk>
|
</disk>
|
||||||
<disk type='file' device='cdrom'>
|
%s
|
||||||
<driver name='qemu' type='raw'/>
|
|
||||||
<source file='%s'/>
|
|
||||||
<target dev='hdb' bus='ide'/>
|
|
||||||
<readonly/>
|
|
||||||
</disk>
|
|
||||||
<interface type='network'>
|
<interface type='network'>
|
||||||
<mac address='%s'/>
|
<mac address='%s'/>
|
||||||
<source network='default'/>
|
<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
|
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>%s
|
||||||
%s
|
%s
|
||||||
</devices>
|
</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 {
|
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 (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Image struct {
|
type Image struct {
|
||||||
@@ -16,6 +17,15 @@ type Image struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetImages() []Image {
|
func GetImages() []Image {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return arm64Images()
|
||||||
|
default:
|
||||||
|
return amd64Images()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func amd64Images() []Image {
|
||||||
return []Image{
|
return []Image{
|
||||||
{
|
{
|
||||||
ID: "kvm-ubuntu-noble", Name: "Ubuntu 24.04 KVM",
|
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 {
|
func FindImage(id string) *Image {
|
||||||
for _, image := range GetImages() {
|
for _, image := range GetImages() {
|
||||||
if image.ID == id {
|
if image.ID == id {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package lxc
|
package lxc
|
||||||
|
|
||||||
|
import "runtime"
|
||||||
|
|
||||||
// Template represents an LXC image template
|
// Template represents an LXC image template
|
||||||
type Template struct {
|
type Template struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -13,55 +15,65 @@ type Template struct {
|
|||||||
|
|
||||||
// GetTemplates returns available LXC image templates (only verified working ones)
|
// GetTemplates returns available LXC image templates (only verified working ones)
|
||||||
func GetTemplates() []Template {
|
func GetTemplates() []Template {
|
||||||
|
arch := defaultTemplateArch()
|
||||||
return []Template{
|
return []Template{
|
||||||
{
|
{
|
||||||
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
ID: "ubuntu-noble", Name: "Ubuntu 24.04",
|
||||||
Distro: "ubuntu", Release: "noble", Arch: "amd64",
|
Distro: "ubuntu", Release: "noble", Arch: arch,
|
||||||
Description: "Ubuntu 24.04 LTS",
|
Description: "Ubuntu 24.04 LTS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
ID: "ubuntu-jammy", Name: "Ubuntu 22.04",
|
||||||
Distro: "ubuntu", Release: "jammy", Arch: "amd64",
|
Distro: "ubuntu", Release: "jammy", Arch: arch,
|
||||||
Description: "Ubuntu 22.04 LTS",
|
Description: "Ubuntu 22.04 LTS",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "debian-bookworm", Name: "Debian 12",
|
ID: "debian-bookworm", Name: "Debian 12",
|
||||||
Distro: "debian", Release: "bookworm", Arch: "amd64",
|
Distro: "debian", Release: "bookworm", Arch: arch,
|
||||||
Description: "Debian 12 (Bookworm)",
|
Description: "Debian 12 (Bookworm)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "debian-bullseye", Name: "Debian 11",
|
ID: "debian-bullseye", Name: "Debian 11",
|
||||||
Distro: "debian", Release: "bullseye", Arch: "amd64",
|
Distro: "debian", Release: "bullseye", Arch: arch,
|
||||||
Description: "Debian 11 (Bullseye)",
|
Description: "Debian 11 (Bullseye)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "alpine-3.21", Name: "Alpine 3.21",
|
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",
|
Description: "Alpine Linux 3.21",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "centos-9-stream", Name: "CentOS 9 Stream",
|
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",
|
Description: "CentOS 9 Stream",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "archlinux-current", Name: "Arch Linux",
|
ID: "archlinux-current", Name: "Arch Linux",
|
||||||
Distro: "archlinux", Release: "current", Arch: "amd64",
|
Distro: "archlinux", Release: "current", Arch: arch,
|
||||||
Description: "Arch Linux (Rolling)",
|
Description: "Arch Linux (Rolling)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "fedora-44", Name: "Fedora 44",
|
ID: "fedora-44", Name: "Fedora 44",
|
||||||
Distro: "fedora", Release: "44", Arch: "amd64",
|
Distro: "fedora", Release: "44", Arch: arch,
|
||||||
Description: "Fedora 44",
|
Description: "Fedora 44",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
ID: "rockylinux-10", Name: "Rocky Linux 10",
|
||||||
Distro: "rockylinux", Release: "10", Arch: "amd64",
|
Distro: "rockylinux", Release: "10", Arch: arch,
|
||||||
Description: "Rocky Linux 10",
|
Description: "Rocky Linux 10",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultTemplateArch() string {
|
||||||
|
switch runtime.GOARCH {
|
||||||
|
case "arm64":
|
||||||
|
return "arm64"
|
||||||
|
default:
|
||||||
|
return "amd64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FindTemplate finds a template by ID
|
// FindTemplate finds a template by ID
|
||||||
func FindTemplate(id string) *Template {
|
func FindTemplate(id string) *Template {
|
||||||
templates := GetTemplates()
|
templates := GetTemplates()
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Version = "1.1.21"
|
Version = "1.1.22"
|
||||||
Repo = "MengMengCode/CLICD"
|
Repo = "MengMengCode/CLICD"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ set -e
|
|||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BUILD_DIR="$SCRIPT_DIR/build"
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
DIST_DIR="$SCRIPT_DIR/dist"
|
||||||
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
FRONTEND_DIR="$SCRIPT_DIR/frontend"
|
||||||
BACKEND_DIR="$SCRIPT_DIR/backend"
|
BACKEND_DIR="$SCRIPT_DIR/backend"
|
||||||
WEB_DIR="$SCRIPT_DIR/web"
|
WEB_DIR="$SCRIPT_DIR/web"
|
||||||
@@ -17,9 +18,11 @@ echo "====================================="
|
|||||||
|
|
||||||
# Clean previous build
|
# Clean previous build
|
||||||
rm -rf "$BUILD_DIR"
|
rm -rf "$BUILD_DIR"
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
rm -rf "$WEB_DIR"
|
rm -rf "$WEB_DIR"
|
||||||
rm -rf "$EMBED_WEB_DIR"
|
rm -rf "$EMBED_WEB_DIR"
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
mkdir -p "$WEB_DIR"
|
mkdir -p "$WEB_DIR"
|
||||||
mkdir -p "$EMBED_WEB_DIR"
|
mkdir -p "$EMBED_WEB_DIR"
|
||||||
touch "$EMBED_WEB_DIR/.gitkeep"
|
touch "$EMBED_WEB_DIR/.gitkeep"
|
||||||
@@ -51,9 +54,26 @@ cd "$BACKEND_DIR"
|
|||||||
go mod tidy
|
go mod tidy
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# Build for Linux amd64
|
|
||||||
BUILD_VERSION="${CLICD_VERSION:-dev}"
|
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"
|
echo "Go backend built successfully"
|
||||||
|
|
||||||
@@ -62,7 +82,20 @@ echo ""
|
|||||||
echo "[3/3] Packaging..."
|
echo "[3/3] Packaging..."
|
||||||
cp -r "$WEB_DIR" "$BUILD_DIR/web"
|
cp -r "$WEB_DIR" "$BUILD_DIR/web"
|
||||||
cp "$SCRIPT_DIR/install.sh" "$BUILD_DIR/install.sh" 2>/dev/null || true
|
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 ""
|
||||||
echo "====================================="
|
echo "====================================="
|
||||||
@@ -70,6 +103,11 @@ echo " Build Complete!"
|
|||||||
echo "====================================="
|
echo "====================================="
|
||||||
echo " Output: $BUILD_DIR/clicd"
|
echo " Output: $BUILD_DIR/clicd"
|
||||||
echo " Web: $BUILD_DIR/web/"
|
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 ""
|
||||||
echo " To deploy:"
|
echo " To deploy:"
|
||||||
echo " 1. Copy build/ directory to server"
|
echo " 1. Copy build/ directory to server"
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ bash build.sh
|
|||||||
|
|
||||||
该脚本用于串联前端构建、静态资源同步和 Go 二进制构建。
|
该脚本用于串联前端构建、静态资源同步和 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
|
```bash
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ CLICD 的安装和升级依赖 GitHub Release 产物。发布时建议使用语
|
|||||||
|
|
||||||
## Release 产物
|
## Release 产物
|
||||||
|
|
||||||
安装脚本会优先下载 Linux AMD64 产物:
|
安装脚本会按宿主架构优先下载 Linux AMD64 或 ARM64 产物:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64.tar.gz
|
clicd-linux-amd64.tar.gz
|
||||||
|
clicd-linux-arm64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
在部分场景中也会尝试下载单独二进制:
|
在部分场景中也会尝试下载单独二进制:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64
|
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 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
|
## Docs Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,16 +12,18 @@ Check the version in:
|
|||||||
|
|
||||||
## Release Artifacts
|
## 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
|
```text
|
||||||
clicd-linux-amd64.tar.gz
|
clicd-linux-amd64.tar.gz
|
||||||
|
clicd-linux-arm64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
In some cases, it may also try the standalone binary:
|
In some cases, it may also try the standalone binary:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
clicd-linux-amd64
|
clicd-linux-amd64
|
||||||
|
clicd-linux-arm64
|
||||||
```
|
```
|
||||||
|
|
||||||
## Installer Behavior
|
## Installer Behavior
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CLICD provides a one-line installer. By default, it installs the latest version
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux x86_64 host.
|
- Linux x86_64/amd64 or ARM64/aarch64 host.
|
||||||
- Root privileges.
|
- Root privileges.
|
||||||
- systemd.
|
- systemd.
|
||||||
- Network access to GitHub Release downloads.
|
- 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
|
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
|
## 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.
|
- Backend: Go, `net/http`, SQLite, systemd, LXC, KVM/libvirt, cgroup v2, iptables, conntrack.
|
||||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, lucide-react, xterm.js, noVNC.
|
- 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?
|
## 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?
|
## Can I pin a specific version?
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
|||||||
|
|
||||||
## 环境要求
|
## 环境要求
|
||||||
|
|
||||||
- Linux x86_64 宿主机。
|
- Linux x86_64/amd64 或 ARM64/aarch64 宿主机。
|
||||||
- root 权限。
|
- root 权限。
|
||||||
- systemd。
|
- systemd。
|
||||||
- 网络可访问 GitHub Release 下载地址。
|
- 网络可访问 GitHub Release 下载地址。
|
||||||
@@ -17,7 +17,7 @@ CLICD 提供一键安装脚本。脚本默认安装 GitHub Releases 的最新版
|
|||||||
curl -fsSL https://raw.githubusercontent.com/MengMengCode/CLICD/main/install.sh | sudo sh
|
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。
|
- 后端:Go、`net/http`、SQLite、systemd、LXC、KVM/libvirt、cgroup v2、iptables、conntrack。
|
||||||
- 前端:React、TypeScript、Vite、Tailwind CSS、lucide-react、xterm.js、noVNC。
|
- 前端: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",
|
"name": "clicd-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.21",
|
"version": "1.1.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
const manualIPv4s = form.public_ipv4s || []
|
const manualIPv4s = form.public_ipv4s || []
|
||||||
const maxVCPU = hostInfo?.cpu.cores || 64
|
const maxVCPU = hostInfo?.cpu.cores || 64
|
||||||
const maxRAMMB = hostInfo?.ram.total_mb ? Number(hostInfo.ram.total_mb) : undefined
|
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 maxDiskGB = hostInfo?.disk.total_gb ? Math.max(1, Math.floor(hostInfo.disk.total_gb)) : undefined
|
||||||
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
const resourceErrors = validateResourceInputs(form, maxVCPU, maxRAMMB, maxDiskGB)
|
||||||
const natEnabled = form.assign_nat !== false
|
const natEnabled = form.assign_nat !== false
|
||||||
@@ -241,8 +248,14 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setForm((prev) => applyTemplateDefaults({ ...prev, virtualization: 'kvm', template_id: '' }))}
|
disabled={!kvmAvailable}
|
||||||
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'}`}
|
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 虚拟机
|
KVM 虚拟机
|
||||||
</button>
|
</button>
|
||||||
@@ -408,7 +421,7 @@ export default function CreateContainerModal({ isOpen, onClose, onSuccess, exist
|
|||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
<span className="block font-medium text-gray-800">{networkText.publicIPv6}</span>
|
||||||
<span className="block text-xs text-gray-500 truncate">
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -762,7 +775,7 @@ const createNetworkText = {
|
|||||||
zh: {
|
zh: {
|
||||||
publicIPv4: '公网 IPv4',
|
publicIPv4: '公网 IPv4',
|
||||||
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
noAllocatableIPv4: '未检测到可分配公网 IPv4',
|
||||||
publicIPv6: '公网 IPv6',
|
publicIPv6: '可分配 IPv6 前缀',
|
||||||
use: '使用',
|
use: '使用',
|
||||||
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
checkingIPv6Prefix: '正在检测 IPv6 前缀...',
|
||||||
publicNAT: '公网 NAT',
|
publicNAT: '公网 NAT',
|
||||||
@@ -771,7 +784,7 @@ const createNetworkText = {
|
|||||||
en: {
|
en: {
|
||||||
publicIPv4: 'Public IPv4',
|
publicIPv4: 'Public IPv4',
|
||||||
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
noAllocatableIPv4: 'No allocatable public IPv4 detected',
|
||||||
publicIPv6: 'Public IPv6',
|
publicIPv6: 'Allocatable IPv6 Prefix',
|
||||||
use: 'Use',
|
use: 'Use',
|
||||||
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
checkingIPv6Prefix: 'Checking IPv6 prefix...',
|
||||||
publicNAT: 'Public NAT',
|
publicNAT: 'Public NAT',
|
||||||
@@ -779,6 +792,21 @@ const createNetworkText = {
|
|||||||
},
|
},
|
||||||
} as const
|
} 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) {
|
function formatAllocatableIPv4Count(count: number, language: Language) {
|
||||||
return language === 'en'
|
return language === 'en'
|
||||||
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
? `${count} allocatable address${count === 1 ? '' : 'es'} detected`
|
||||||
|
|||||||
@@ -395,10 +395,8 @@ export default function Containers() {
|
|||||||
const isPlaceholder = !!container.isPlaceholder
|
const isPlaceholder = !!container.isPlaceholder
|
||||||
const isPolicyBlocked = !!container.policy_blocked
|
const isPolicyBlocked = !!container.policy_blocked
|
||||||
const usage = usageByName[container.name]
|
const usage = usageByName[container.name]
|
||||||
const isKVM = (container.virtualization || 'lxc') === 'kvm'
|
|
||||||
|
|
||||||
const cpuPct = isRunning
|
const cpuPct = isRunning
|
||||||
? clamp((usage?.cpu_usage_pct || 0) / (isKVM ? (container.vcpu || 1) : 1))
|
? clamp((usage?.cpu_usage_pct || 0) / (container.vcpu || 1))
|
||||||
: 0
|
: 0
|
||||||
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
const ramTotalBytes = usage?.memory_total_bytes && usage.memory_total_bytes > 0
|
||||||
? usage.memory_total_bytes
|
? usage.memory_total_bytes
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ const hostReportText = {
|
|||||||
ipv4Address: 'IPv4 地址',
|
ipv4Address: 'IPv4 地址',
|
||||||
ipv4Prefix: 'IPv4 段',
|
ipv4Prefix: 'IPv4 段',
|
||||||
ipv6Address: 'IPv6 地址',
|
ipv6Address: 'IPv6 地址',
|
||||||
ipv6Prefix: 'IPv6 段',
|
ipv6Prefix: '可分配 IPv6 前缀',
|
||||||
gateway: '网关',
|
gateway: '网关',
|
||||||
memoryModules: '内存条',
|
memoryModules: '内存条',
|
||||||
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
noMemoryModules: '未检测到内存条明细,可能缺少 dmidecode 或权限受限',
|
||||||
@@ -277,7 +277,7 @@ const hostReportText = {
|
|||||||
ipv4Address: 'IPv4 Addresses',
|
ipv4Address: 'IPv4 Addresses',
|
||||||
ipv4Prefix: 'IPv4 Prefixes',
|
ipv4Prefix: 'IPv4 Prefixes',
|
||||||
ipv6Address: 'IPv6 Addresses',
|
ipv6Address: 'IPv6 Addresses',
|
||||||
ipv6Prefix: 'IPv6 Prefixes',
|
ipv6Prefix: 'Allocatable IPv6 Prefixes',
|
||||||
gateway: 'Gateway',
|
gateway: 'Gateway',
|
||||||
memoryModules: 'Memory Modules',
|
memoryModules: 'Memory Modules',
|
||||||
noMemoryModules: 'No memory module details detected. dmidecode may be missing or permissions may be limited.',
|
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) {
|
function gpuTypeLabel(value: string, language: Language) {
|
||||||
|
if (value === 'virtual') return language === 'en' ? 'Virtual' : '虚拟'
|
||||||
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
if (value === 'integrated') return language === 'en' ? 'Integrated' : '核显'
|
||||||
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
if (value === 'discrete') return language === 'en' ? 'Discrete' : '独显'
|
||||||
return value || '-'
|
return value || '-'
|
||||||
|
|||||||
@@ -148,17 +148,19 @@ export default function ImageManagement() {
|
|||||||
onToggle={handleToggle}
|
onToggle={handleToggle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImageTable
|
{kvmImages.length > 0 && (
|
||||||
title="KVM 虚拟机镜像"
|
<ImageTable
|
||||||
images={kvmImages}
|
title="KVM 虚拟机镜像"
|
||||||
actionLoading={actionLoading}
|
images={kvmImages}
|
||||||
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
|
actionLoading={actionLoading}
|
||||||
totalCount={kvmImages.length}
|
downloadedCount={kvmImages.filter((img) => img.downloaded).length}
|
||||||
onDownload={handleDownload}
|
totalCount={kvmImages.length}
|
||||||
onCancelDownload={handleCancelDownload}
|
onDownload={handleDownload}
|
||||||
onDelete={handleDelete}
|
onCancelDownload={handleCancelDownload}
|
||||||
onToggle={handleToggle}
|
onDelete={handleDelete}
|
||||||
/>
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -235,6 +235,14 @@ export interface HostInfo {
|
|||||||
}
|
}
|
||||||
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
|
disk_io: { read_bytes: number; write_bytes: number; read_bps: number; write_bps: number }
|
||||||
load: { load1: number; load5: number; load15: 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 {
|
export interface HostProbeReport {
|
||||||
|
|||||||
+169
-47
@@ -3,7 +3,6 @@ set -eu
|
|||||||
|
|
||||||
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
|
REPO="${CLICD_REPO:-MengMengCode/CLICD}"
|
||||||
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
|
CLICD_INSTALL_VERSION="${CLICD_VERSION:-latest}"
|
||||||
ASSET="clicd-linux-amd64.tar.gz"
|
|
||||||
ACTION="${1:-install}"
|
ACTION="${1:-install}"
|
||||||
ACTION_CONFIRM="${2:-}"
|
ACTION_CONFIRM="${2:-}"
|
||||||
ISSUE_URL="https://github.com/${REPO}/issues"
|
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.$$}"
|
INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}"
|
||||||
LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created"
|
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() {
|
normalize_lang() {
|
||||||
lang="$1"
|
lang="$1"
|
||||||
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
|
case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in
|
||||||
@@ -286,14 +359,8 @@ run_step() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_os_compatibility() {
|
check_os_compatibility() {
|
||||||
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=$(uname -m 2>/dev/null || echo unknown)"
|
log "系统检测:ID=${OS_ID} ID_LIKE=${OS_LIKE} ARCH=${HOST_ARCH_RAW} CLICD_ARCH=${CLICD_ARCH_NORMALIZED:-unsupported}"
|
||||||
case "$(uname -m 2>/dev/null || echo unknown)" in
|
[ -n "$CLICD_ARCH_NORMALIZED" ] || die "当前安装包支持 x86_64/amd64 和 aarch64/arm64,当前架构:${HOST_ARCH_RAW}。"
|
||||||
x86_64|amd64)
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
die "当前安装包仅支持 x86_64/amd64,当前架构:$(uname -m 2>/dev/null || echo unknown)。"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if ! is_systemd && ! is_openrc; then
|
if ! is_systemd && ! is_openrc; then
|
||||||
die "未检测到 systemd 或 OpenRC,无法安装服务。"
|
die "未检测到 systemd 或 OpenRC,无法安装服务。"
|
||||||
fi
|
fi
|
||||||
@@ -476,7 +543,16 @@ remove_clicd_lxc_image_cache() {
|
|||||||
"centos 9-Stream amd64" \
|
"centos 9-Stream amd64" \
|
||||||
"archlinux current amd64" \
|
"archlinux current amd64" \
|
||||||
"fedora 44 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
|
do
|
||||||
set -- $image
|
set -- $image
|
||||||
distro="$1"
|
distro="$1"
|
||||||
@@ -950,15 +1026,21 @@ install_apk() {
|
|||||||
iproute2 \
|
iproute2 \
|
||||||
iptables \
|
iptables \
|
||||||
dnsmasq \
|
dnsmasq \
|
||||||
dbus \
|
dbus
|
||||||
qemu-system-x86_64 \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
apk add --no-cache \
|
||||||
|
"$(qemu_system_package_apk)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon \
|
libvirt-daemon \
|
||||||
libvirt-client \
|
libvirt-client \
|
||||||
libvirt-qemu
|
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"
|
apk add --no-cache "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -985,18 +1067,39 @@ install_apt() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq-base \
|
dnsmasq-base
|
||||||
qemu-kvm \
|
|
||||||
qemu-system-x86 \
|
if kvm_supported_arch; then
|
||||||
qemu-utils \
|
if [ "$CLICD_ARCH_NORMALIZED" = "arm64" ]; then
|
||||||
libvirt-daemon-system \
|
apt-get install -y \
|
||||||
libvirt-clients \
|
"$(qemu_system_package_apt)" \
|
||||||
cloud-image-utils \
|
qemu-utils \
|
||||||
genisoimage \
|
libvirt-daemon-system \
|
||||||
xorriso \
|
libvirt-clients \
|
||||||
smartmontools \
|
cloud-image-utils \
|
||||||
virtinst \
|
genisoimage \
|
||||||
ovmf
|
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() {
|
enable_el_repos() {
|
||||||
@@ -1032,8 +1135,11 @@ install_dnf() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq \
|
dnsmasq
|
||||||
qemu-kvm \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
dnf install -y \
|
||||||
|
"$(qemu_system_package_rpm)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon-kvm \
|
libvirt-daemon-kvm \
|
||||||
@@ -1041,8 +1147,12 @@ install_dnf() {
|
|||||||
virt-install \
|
virt-install \
|
||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
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"
|
dnf install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -1067,8 +1177,11 @@ install_yum() {
|
|||||||
quota \
|
quota \
|
||||||
e2fsprogs \
|
e2fsprogs \
|
||||||
xfsprogs \
|
xfsprogs \
|
||||||
dnsmasq \
|
dnsmasq
|
||||||
qemu-kvm \
|
|
||||||
|
if kvm_supported_arch; then
|
||||||
|
yum install -y \
|
||||||
|
"$(qemu_system_package_rpm)" \
|
||||||
qemu-img \
|
qemu-img \
|
||||||
libvirt \
|
libvirt \
|
||||||
libvirt-daemon-kvm \
|
libvirt-daemon-kvm \
|
||||||
@@ -1076,8 +1189,12 @@ install_yum() {
|
|||||||
virt-install \
|
virt-install \
|
||||||
cloud-utils \
|
cloud-utils \
|
||||||
genisoimage
|
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"
|
yum install -y "$pkg" >/dev/null 2>&1 || warn "可选依赖未安装:$pkg"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
@@ -1117,14 +1234,19 @@ install_dependencies() {
|
|||||||
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
|
has_cmd lxc-create || die "依赖安装后仍未找到 lxc-create,请检查 LXC 软件源/安装日志。"
|
||||||
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
|
has_cmd iptables || die "依赖安装后仍未找到 iptables,请检查系统网络工具包。"
|
||||||
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
|
has_cmd ip || die "依赖安装后仍未找到 ip 命令,请检查 iproute2 安装。"
|
||||||
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
|
if kvm_supported_arch; then
|
||||||
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
|
has_cmd virsh || die "依赖安装后仍未找到 virsh,请检查 libvirt-client/libvirt-clients 安装。"
|
||||||
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
|
has_cmd "$(qemu_emulator_cmd)" || die "依赖安装后仍未找到 $(qemu_emulator_cmd),请检查 QEMU 安装。"
|
||||||
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
has_cmd qemu-img || die "依赖安装后仍未找到 qemu-img,请检查 qemu-utils/qemu-img 安装。"
|
||||||
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
|
has_cmd cloud-localds || die "依赖安装后仍未找到 cloud-localds,请检查 cloud-image-utils/cloud-utils 安装。"
|
||||||
fi
|
if ! has_cmd genisoimage && ! has_cmd mkisofs && ! has_cmd xorriso; then
|
||||||
if [ ! -e /dev/kvm ]; then
|
die "Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。"
|
||||||
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
|
fi
|
||||||
|
if [ ! -e /dev/kvm ]; then
|
||||||
|
warn "未检测到 /dev/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn_kvm_unsupported_arch
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1384,7 +1506,7 @@ download_release_if_needed() {
|
|||||||
if [ "$archive_ok" = "1" ]; then
|
if [ "$archive_ok" = "1" ]; then
|
||||||
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
tar -xzf "$archive_path" -C "$tmp_dir" || die "Failed to extract release package: $archive_path"
|
||||||
else
|
else
|
||||||
binary_asset="clicd-linux-amd64"
|
binary_asset="$BINARY_ASSET"
|
||||||
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
if [ "$CLICD_INSTALL_VERSION" = "latest" ]; then
|
||||||
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
binary_url="https://github.com/${REPO}/releases/latest/download/${binary_asset}"
|
||||||
else
|
else
|
||||||
@@ -1402,9 +1524,9 @@ download_release_if_needed() {
|
|||||||
[ -n "$url" ] || continue
|
[ -n "$url" ] || continue
|
||||||
log "Trying release binary: $url"
|
log "Trying release binary: $url"
|
||||||
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
if download_file "$url" "$binary_path" && [ -s "$binary_path" ]; then
|
||||||
mkdir -p "$tmp_dir/clicd-linux-amd64"
|
mkdir -p "$tmp_dir/$ASSET_DIR"
|
||||||
cp "$binary_path" "$tmp_dir/clicd-linux-amd64/clicd"
|
cp "$binary_path" "$tmp_dir/$ASSET_DIR/clicd"
|
||||||
chmod +x "$tmp_dir/clicd-linux-amd64/clicd"
|
chmod +x "$tmp_dir/$ASSET_DIR/clicd"
|
||||||
binary_ok=1
|
binary_ok=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1414,8 +1536,8 @@ download_release_if_needed() {
|
|||||||
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
[ "$binary_ok" = "1" ] || die "Release package download failed: $download_url"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ -d "$tmp_dir/clicd-linux-amd64" ] || die "Release package layout is invalid: missing clicd-linux-amd64 directory"
|
[ -d "$tmp_dir/$ASSET_DIR" ] || die "Release package layout is invalid: missing $ASSET_DIR directory"
|
||||||
[ -f "$tmp_dir/clicd-linux-amd64/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
[ -f "$tmp_dir/$ASSET_DIR/clicd" ] || die "下载的发行版包中未找到 clicd 二进制。"
|
||||||
}
|
}
|
||||||
|
|
||||||
install_binary() {
|
install_binary() {
|
||||||
@@ -1430,8 +1552,8 @@ install_binary() {
|
|||||||
download_dir=""
|
download_dir=""
|
||||||
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
if [ ! -f "$bin_src" ] && [ -f "$INSTALL_DOWNLOAD_MARKER" ]; then
|
||||||
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
download_dir="$(sed -n '1p' "$INSTALL_DOWNLOAD_MARKER" 2>/dev/null || true)"
|
||||||
if [ -n "$download_dir" ] && [ -f "$download_dir/clicd-linux-amd64/clicd" ]; then
|
if [ -n "$download_dir" ] && [ -f "$download_dir/$ASSET_DIR/clicd" ]; then
|
||||||
bin_src="$download_dir/clicd-linux-amd64/clicd"
|
bin_src="$download_dir/$ASSET_DIR/clicd"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
[ -f "$bin_src" ] || die "未找到 clicd 二进制,安装无法继续。"
|
||||||
|
|||||||
Reference in New Issue
Block a user