refactor: install.sh and xbctl
This commit is contained in:
+382
-38
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -179,6 +181,8 @@ func run(args []string) error {
|
||||
case "version", "-v", "--version":
|
||||
fmt.Printf("xbctl %s (built %s)\n", version, buildTime)
|
||||
return nil
|
||||
case "config":
|
||||
return runConfig(args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
return nil
|
||||
@@ -194,6 +198,8 @@ func printUsage() {
|
||||
xbctl list [--output text|json]
|
||||
xbctl instance list [--output text|json]
|
||||
xbctl instance get <id> [--output text|json]
|
||||
xbctl config init --mode node|machine --panel-url URL --token TOKEN [flags]
|
||||
xbctl config health-port [--config PATH]
|
||||
xbctl service status|start|stop|restart|logs
|
||||
xbctl health
|
||||
xbctl bind add-node --panel URL --token TOKEN --node-id ID [--node-type TYPE] [--kernel singbox|xray]
|
||||
@@ -624,46 +630,11 @@ func pruneCredentialKeys(path string, removed []config.Config) error {
|
||||
}
|
||||
|
||||
func writeInstallMeta(path string, root *config.RootConfig) error {
|
||||
versionValue := "unknown"
|
||||
ver := "unknown"
|
||||
if meta, err := loadInstallMeta(path); err == nil && strings.TrimSpace(meta.Version) != "" {
|
||||
versionValue = meta.Version
|
||||
ver = meta.Version
|
||||
}
|
||||
instances := normalizeRootInstances(root)
|
||||
items := make([]instanceSummary, 0, len(instances))
|
||||
latestID := ""
|
||||
for _, inst := range instances {
|
||||
id, err := inst.AutoInstanceID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
latestID = id
|
||||
item := instanceSummary{
|
||||
ID: id,
|
||||
PanelURL: inst.Panel.URL,
|
||||
Mode: instanceMode(inst),
|
||||
NodeID: intPtr(inst.Panel.NodeID),
|
||||
MachineID: machineIDPtr(&inst),
|
||||
HealthPort: inst.HealthPort,
|
||||
}
|
||||
if item.Mode == "machine" {
|
||||
item.NodeID = nil
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
meta := installMeta{
|
||||
ConfigMode: "instances",
|
||||
Version: versionValue,
|
||||
LatestInstanceID: latestID,
|
||||
InstanceCount: len(items),
|
||||
Instances: items,
|
||||
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal install meta: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
return writeInstallMetaVersioned(path, root, ver, "")
|
||||
}
|
||||
|
||||
func instanceMode(inst config.Config) string {
|
||||
@@ -792,3 +763,376 @@ func machineIDPtr(cfg *config.Config) *int {
|
||||
vv := cfg.Machine.MachineID
|
||||
return &vv
|
||||
}
|
||||
|
||||
// ── config subcommand ─────────────────────────────────────────────────
|
||||
|
||||
func runConfig(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("usage: xbctl config <init|health-port>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runConfigInit(args[1:])
|
||||
case "health-port":
|
||||
return runConfigHealthPort(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown config command: %s", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// runConfigInit generates/merges an instance into config.yml, writes
|
||||
// credentials.env and install-meta.json. It replaces the Python PY_INST,
|
||||
// PY_CFG, PY_ENV, and PY_META heredoc blocks in install.sh.
|
||||
//
|
||||
// Output (stdout, one per line):
|
||||
//
|
||||
// INSTANCE_ID=<generated-id>
|
||||
// ENV_KEY=<credential-env-var-name>
|
||||
func runConfigInit(args []string) error {
|
||||
var (
|
||||
configIn string
|
||||
configOut string
|
||||
credentialsIn string
|
||||
credentialsOut string
|
||||
metaPath string
|
||||
mode string
|
||||
panelURL string
|
||||
nodeID int
|
||||
nodeType string
|
||||
machineID int
|
||||
kernelType string
|
||||
healthPort int
|
||||
gomemlimit string
|
||||
gogc int
|
||||
installRoot string
|
||||
token string
|
||||
releaseVersion string
|
||||
)
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
if i+1 >= len(args) {
|
||||
break
|
||||
}
|
||||
switch args[i] {
|
||||
case "--config":
|
||||
i++
|
||||
configIn = args[i]
|
||||
case "--output":
|
||||
i++
|
||||
configOut = args[i]
|
||||
case "--credentials-in":
|
||||
i++
|
||||
credentialsIn = args[i]
|
||||
case "--credentials-out":
|
||||
i++
|
||||
credentialsOut = args[i]
|
||||
case "--meta":
|
||||
i++
|
||||
metaPath = args[i]
|
||||
case "--mode":
|
||||
i++
|
||||
mode = args[i]
|
||||
case "--panel-url":
|
||||
i++
|
||||
panelURL = args[i]
|
||||
case "--node-id":
|
||||
i++
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --node-id: %w", err)
|
||||
}
|
||||
nodeID = v
|
||||
case "--node-type":
|
||||
i++
|
||||
nodeType = args[i]
|
||||
case "--machine-id":
|
||||
i++
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --machine-id: %w", err)
|
||||
}
|
||||
machineID = v
|
||||
case "--kernel":
|
||||
i++
|
||||
kernelType = args[i]
|
||||
case "--health-port":
|
||||
i++
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --health-port: %w", err)
|
||||
}
|
||||
healthPort = v
|
||||
case "--gomemlimit":
|
||||
i++
|
||||
gomemlimit = args[i]
|
||||
case "--gogc":
|
||||
i++
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --gogc: %w", err)
|
||||
}
|
||||
gogc = v
|
||||
case "--install-root":
|
||||
i++
|
||||
installRoot = args[i]
|
||||
case "--token":
|
||||
i++
|
||||
token = args[i]
|
||||
case "--version":
|
||||
i++
|
||||
releaseVersion = args[i]
|
||||
}
|
||||
}
|
||||
|
||||
if mode == "" {
|
||||
return errors.New("--mode is required (node or machine)")
|
||||
}
|
||||
if panelURL == "" {
|
||||
return errors.New("--panel-url is required")
|
||||
}
|
||||
if mode == "node" && nodeID <= 0 {
|
||||
return errors.New("--node-id is required for node mode")
|
||||
}
|
||||
if mode == "machine" && machineID <= 0 {
|
||||
return errors.New("--machine-id is required for machine mode")
|
||||
}
|
||||
|
||||
// Build the new instance.
|
||||
inst := config.Config{
|
||||
Panel: config.PanelConfig{URL: panelURL},
|
||||
Kernel: config.KernelConfig{
|
||||
Type: kernelType,
|
||||
LogLevel: "warn",
|
||||
},
|
||||
Log: config.LogConfig{Level: "info", Output: "stdout"},
|
||||
HealthPort: healthPort,
|
||||
}
|
||||
|
||||
if mode == "machine" {
|
||||
inst.Machine = &config.MachineConfig{MachineID: machineID}
|
||||
} else {
|
||||
inst.Panel.NodeID = nodeID
|
||||
if nodeType != "" {
|
||||
inst.Panel.NodeType = nodeType
|
||||
}
|
||||
}
|
||||
|
||||
// Generate deterministic instance ID.
|
||||
instanceID, err := inst.AutoInstanceID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate instance ID: %w", err)
|
||||
}
|
||||
inst.InstanceID = instanceID
|
||||
|
||||
if installRoot == "" {
|
||||
installRoot = "/etc/xboard-node"
|
||||
}
|
||||
inst.Kernel.ConfigDir = filepath.Join(installRoot, "instances", instanceID)
|
||||
|
||||
// Build credential env key.
|
||||
envKey := "INSTANCE_" + strings.ToUpper(strings.ReplaceAll(instanceID, "-", "_"))
|
||||
if mode == "machine" {
|
||||
envKey += "_MACHINE_TOKEN"
|
||||
inst.Machine.TokenEnv = envKey
|
||||
} else {
|
||||
envKey += "_API_KEY"
|
||||
inst.Panel.TokenEnv = envKey
|
||||
}
|
||||
|
||||
if gomemlimit != "" {
|
||||
inst.Runtime.GoMemLimit = gomemlimit
|
||||
}
|
||||
if gogc > 0 {
|
||||
inst.Runtime.GoGCPercent = gogc
|
||||
}
|
||||
|
||||
// Load existing config (if any).
|
||||
root := &config.RootConfig{}
|
||||
hasExisting := false
|
||||
if configIn != "" {
|
||||
if loaded, loadErr := loadWritableRootConfig(configIn); loadErr == nil {
|
||||
root = loaded
|
||||
hasExisting = len(root.Instances) > 0 || root.Config.Panel.URL != "" || root.Config.Kernel.Type != ""
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise existing instance IDs so dedup works correctly.
|
||||
var instances []config.Config
|
||||
if hasExisting {
|
||||
instances = normalizeRootInstances(root)
|
||||
seen := make(map[string]bool)
|
||||
deduped := make([]config.Config, 0, len(instances))
|
||||
for _, existing := range instances {
|
||||
autoID, idErr := existing.AutoInstanceID()
|
||||
if idErr == nil && autoID != "" {
|
||||
existing.InstanceID = autoID
|
||||
}
|
||||
id := existing.InstanceID
|
||||
if id != "" && seen[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seen[id] = true
|
||||
}
|
||||
deduped = append(deduped, existing)
|
||||
}
|
||||
instances = deduped
|
||||
}
|
||||
|
||||
// Merge: replace if same ID exists, otherwise append.
|
||||
replaced := false
|
||||
for i, existing := range instances {
|
||||
if existing.InstanceID == instanceID {
|
||||
instances[i] = inst
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
instances = append(instances, inst)
|
||||
}
|
||||
root.Instances = instances
|
||||
|
||||
// Write config.
|
||||
if configOut == "" {
|
||||
configOut = configIn
|
||||
}
|
||||
if configOut == "" {
|
||||
return errors.New("--output (or --config) is required")
|
||||
}
|
||||
if err := writeRootConfig(configOut, root); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write credentials.
|
||||
if token != "" && credentialsOut != "" {
|
||||
if err := mergeCredentials(credentialsIn, credentialsOut, envKey, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Write install-meta.json.
|
||||
if metaPath != "" {
|
||||
if err := writeInstallMetaVersioned(metaPath, root, releaseVersion, instanceID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Output for bash capture.
|
||||
fmt.Printf("INSTANCE_ID=%s\n", instanceID)
|
||||
fmt.Printf("ENV_KEY=%s\n", envKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeCredentials reads existing key=value credentials, adds/replaces
|
||||
// the given key, and writes the result preserving insertion order.
|
||||
func mergeCredentials(srcPath, dstPath, key, value string) error {
|
||||
entries := make(map[string]string)
|
||||
var order []string
|
||||
if srcPath != "" {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err == nil {
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, "=")
|
||||
if _, exists := entries[k]; !exists {
|
||||
order = append(order, k)
|
||||
}
|
||||
entries[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := entries[key]; !exists {
|
||||
order = append(order, key)
|
||||
}
|
||||
entries[key] = value
|
||||
|
||||
var buf strings.Builder
|
||||
for _, k := range order {
|
||||
fmt.Fprintf(&buf, "%s=%s\n", k, entries[k])
|
||||
}
|
||||
return os.WriteFile(dstPath, []byte(buf.String()), 0o600)
|
||||
}
|
||||
|
||||
// writeInstallMetaVersioned writes install-meta.json with an explicit
|
||||
// version and latest-instance-ID. When latestID is empty, the last
|
||||
// instance in the config is used (backward compat with writeInstallMeta).
|
||||
func writeInstallMetaVersioned(path string, root *config.RootConfig, ver, latestID string) error {
|
||||
if ver == "" {
|
||||
ver = "unknown"
|
||||
}
|
||||
instances := normalizeRootInstances(root)
|
||||
items := make([]instanceSummary, 0, len(instances))
|
||||
for _, inst := range instances {
|
||||
id, err := inst.AutoInstanceID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if latestID == "" {
|
||||
latestID = id
|
||||
}
|
||||
item := instanceSummary{
|
||||
ID: id,
|
||||
PanelURL: inst.Panel.URL,
|
||||
Mode: instanceMode(inst),
|
||||
NodeID: intPtr(inst.Panel.NodeID),
|
||||
MachineID: machineIDPtr(&inst),
|
||||
HealthPort: inst.HealthPort,
|
||||
}
|
||||
if item.Mode == "machine" {
|
||||
item.NodeID = nil
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
meta := installMeta{
|
||||
ConfigMode: "instances",
|
||||
Version: ver,
|
||||
LatestInstanceID: latestID,
|
||||
InstanceCount: len(items),
|
||||
Instances: items,
|
||||
UpdatedAt: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
data, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal install meta: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
// runConfigHealthPort reads health_port from an existing config file and
|
||||
// prints it to stdout. Exits silently if the file does not exist or has
|
||||
// no health_port.
|
||||
func runConfigHealthPort(args []string) error {
|
||||
cfgPath := defaultConfigPath
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--config" && i+1 < len(args) {
|
||||
i++
|
||||
cfgPath = args[i]
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
return nil // file not found → no health port
|
||||
}
|
||||
root := &config.RootConfig{}
|
||||
if err := yaml.Unmarshal(data, root); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Check instances first, then legacy top-level.
|
||||
if len(root.Instances) > 0 {
|
||||
for _, inst := range root.Instances {
|
||||
if inst.HealthPort > 0 {
|
||||
fmt.Println(inst.HealthPort)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.Config.HealthPort > 0 {
|
||||
fmt.Println(root.Config.HealthPort)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+57
-268
@@ -77,20 +77,12 @@ load_health_port_from_config() {
|
||||
return
|
||||
fi
|
||||
local parsed
|
||||
parsed=$(python3 - "$cfg_path" <<'PYCFG'
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
port = data.get('health_port', 0)
|
||||
if isinstance(port, int):
|
||||
print(port)
|
||||
PYCFG
|
||||
)
|
||||
if [ -n "$parsed" ] && [[ "$parsed" =~ ^[0-9]+$ ]]; then
|
||||
if [ -x "$CLI_PATH" ]; then
|
||||
parsed=$("$CLI_PATH" config health-port --config "$cfg_path" 2>/dev/null)
|
||||
else
|
||||
parsed=$(grep -m1 'health_port:' "$cfg_path" 2>/dev/null | sed 's/.*health_port:[[:space:]]*//' | tr -cd '0-9')
|
||||
fi
|
||||
if [ -n "$parsed" ] && [ "$parsed" -ge 0 ] 2>/dev/null; then
|
||||
HEALTH_PORT="$parsed"
|
||||
if [ "$HEALTH_PORT" -eq 0 ]; then
|
||||
HEALTH_ENABLED=0
|
||||
@@ -449,17 +441,6 @@ validate_install_request() {
|
||||
esac
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python3 - <<'PY' "$1"
|
||||
import json, sys
|
||||
print(json.dumps(sys.argv[1]))
|
||||
PY
|
||||
}
|
||||
|
||||
current_mode() { :; }
|
||||
|
||||
current_target() { :; }
|
||||
|
||||
detect_current_state() {
|
||||
local has_binary=0 has_config=0 has_service=0
|
||||
[ -x "$BINARY_PATH" ] && has_binary=1
|
||||
@@ -562,229 +543,49 @@ stage_xbctl() {
|
||||
fi
|
||||
}
|
||||
|
||||
render_runtime_block() {
|
||||
local output=""
|
||||
if [ -n "$RUNTIME_GOMEMLIMIT" ] || [ -n "$RUNTIME_GOGC" ]; then
|
||||
output+=$'runtime:\n'
|
||||
if [ -n "$RUNTIME_GOMEMLIMIT" ]; then
|
||||
output+=" gomemlimit: \"${RUNTIME_GOMEMLIMIT}\"\n"
|
||||
fi
|
||||
if [ -n "$RUNTIME_GOGC" ]; then
|
||||
output+=" gogc: ${RUNTIME_GOGC}\n"
|
||||
fi
|
||||
fi
|
||||
printf '%b' "$output"
|
||||
}
|
||||
|
||||
render_config() {
|
||||
INSTANCE_ID=$(python3 - "$MODE" "$PANEL_URL" "$NODE_ID" "$MACHINE_ID" <<'PY_INST'
|
||||
import hashlib
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
mode, panel_url, node_id, machine_id = sys.argv[1:]
|
||||
node_id = int(node_id or 0)
|
||||
machine_id = int(machine_id or 0)
|
||||
u = urlparse((panel_url or '').strip())
|
||||
if not u.scheme or not u.netloc:
|
||||
raise SystemExit(f"invalid panel url: {panel_url}")
|
||||
scheme = u.scheme.lower()
|
||||
host = u.netloc.lower()
|
||||
path = u.path.rstrip('/')
|
||||
normalized = f"{scheme}://{host}{path}"
|
||||
slug_src = f"{host}{('-' + path.strip('/')) if path.strip('/') else ''}".lower()
|
||||
slug = []
|
||||
last_dash = False
|
||||
for ch in slug_src:
|
||||
if ch.isalnum():
|
||||
slug.append(ch)
|
||||
last_dash = False
|
||||
elif not last_dash:
|
||||
slug.append('-')
|
||||
last_dash = True
|
||||
slug = ''.join(slug).strip('-') or 'panel'
|
||||
slug = (slug[:48].strip('-') or 'panel')
|
||||
target_value = str(machine_id) if mode == "machine" else str(node_id)
|
||||
target_label = f"machine-{machine_id}" if mode == "machine" else f"node-{node_id}"
|
||||
short = hashlib.sha1(f"{normalized}|{mode}|{target_value}".encode()).hexdigest()[:6]
|
||||
print(f"{slug}-{target_label}-{short}")
|
||||
PY_INST
|
||||
local init_args=(
|
||||
config init
|
||||
--mode "$MODE"
|
||||
--panel-url "$PANEL_URL"
|
||||
--kernel "${KERNEL_TYPE:-singbox}"
|
||||
--health-port "${HEALTH_PORT:-0}"
|
||||
--token "$TOKEN"
|
||||
--version "$RELEASE_VERSION"
|
||||
--output "$TMP_DIR/config.yml"
|
||||
--credentials-out "$TMP_DIR/credentials.env"
|
||||
--meta "$TMP_DIR/install-meta.json"
|
||||
--install-root "$INSTALL_ROOT"
|
||||
)
|
||||
|
||||
python3 - "$CONFIG_FILE" "$TMP_DIR/config.yml" "$MODE" "$PANEL_URL" "$NODE_ID" "$NODE_TYPE" "$MACHINE_ID" "$KERNEL_TYPE" "$HEALTH_PORT" "$RUNTIME_GOMEMLIMIT" "$RUNTIME_GOGC" "$INSTALL_ROOT" "$INSTANCE_ID" <<'PY_CFG'
|
||||
import os
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
import yaml
|
||||
config_file, out_file, mode, panel_url, node_id, node_type, machine_id, kernel_type, health_port, gomemlimit, gogc, install_root, instance_id = sys.argv[1:]
|
||||
node_id = int(node_id or 0)
|
||||
machine_id = int(machine_id or 0)
|
||||
health_port = int(health_port or 0)
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
else:
|
||||
data = {}
|
||||
legacy_keys = ["panel", "node", "kernel", "cert", "log", "runtime", "ws", "standalone", "health_port", "nodes", "machine"]
|
||||
instances = data.get("instances")
|
||||
if not isinstance(instances, list):
|
||||
instances = []
|
||||
if any(k in data for k in legacy_keys):
|
||||
legacy = {k: deepcopy(data[k]) for k in legacy_keys if k in data}
|
||||
if legacy:
|
||||
instances.append(legacy)
|
||||
for k in legacy_keys:
|
||||
data.pop(k, None)
|
||||
|
||||
def normalize_existing_id(inst):
|
||||
panel = inst.get("panel") or {}
|
||||
machine = inst.get("machine") or {}
|
||||
panel_url_existing = panel.get("url", "")
|
||||
from urllib.parse import urlparse
|
||||
u = urlparse((panel_url_existing or '').strip())
|
||||
if not u.scheme or not u.netloc:
|
||||
return inst.get("id", "")
|
||||
normalized_existing = f"{u.scheme.lower()}://{u.netloc.lower()}{u.path.rstrip('/')}"
|
||||
if machine and machine.get("machine_id"):
|
||||
existing_mode = "machine"
|
||||
target_value = str(machine.get("machine_id"))
|
||||
target_label = f"machine-{machine.get('machine_id')}"
|
||||
else:
|
||||
existing_mode = "node"
|
||||
target_value = str(panel.get("node_id", 0))
|
||||
target_label = f"node-{panel.get('node_id', 0)}"
|
||||
slug_src = f"{u.netloc.lower()}{('-' + u.path.rstrip('/').strip('/')) if u.path.rstrip('/').strip('/') else ''}".lower()
|
||||
slug = []
|
||||
last_dash = False
|
||||
for ch in slug_src:
|
||||
if ch.isalnum():
|
||||
slug.append(ch)
|
||||
last_dash = False
|
||||
elif not last_dash:
|
||||
slug.append('-')
|
||||
last_dash = True
|
||||
slug = ''.join(slug).strip('-') or 'panel'
|
||||
slug = (slug[:48].strip('-') or 'panel')
|
||||
short = __import__('hashlib').sha1(f"{normalized_existing}|{existing_mode}|{target_value}".encode()).hexdigest()[:6]
|
||||
return f"{slug}-{target_label}-{short}"
|
||||
|
||||
normalized_instances = []
|
||||
seen_ids = set()
|
||||
for inst in instances:
|
||||
inst = deepcopy(inst)
|
||||
normalized_id = normalize_existing_id(inst)
|
||||
if normalized_id:
|
||||
inst["id"] = normalized_id
|
||||
current_id = inst.get("id")
|
||||
if current_id and current_id in seen_ids:
|
||||
continue
|
||||
if current_id:
|
||||
seen_ids.add(current_id)
|
||||
normalized_instances.append(inst)
|
||||
instances = normalized_instances
|
||||
|
||||
env_key = f"INSTANCE_{instance_id.upper().replace('-', '_')}_{'MACHINE_TOKEN' if mode == 'machine' else 'API_KEY'}"
|
||||
new_inst = {
|
||||
"id": instance_id,
|
||||
"panel": {"url": panel_url},
|
||||
"kernel": {
|
||||
"type": kernel_type,
|
||||
"config_dir": f"{install_root}/instances/{instance_id}",
|
||||
"log_level": "warn",
|
||||
},
|
||||
"log": {"level": "info", "output": "stdout"},
|
||||
}
|
||||
if health_port > 0:
|
||||
new_inst["health_port"] = health_port
|
||||
if gomemlimit or gogc:
|
||||
new_inst["runtime"] = {}
|
||||
if gomemlimit:
|
||||
new_inst["runtime"]["gomemlimit"] = gomemlimit
|
||||
if gogc:
|
||||
new_inst["runtime"]["gogc"] = int(gogc)
|
||||
if mode == 'machine':
|
||||
new_inst["machine"] = {"machine_id": machine_id, "token_env": env_key}
|
||||
else:
|
||||
new_inst["panel"]["node_id"] = node_id
|
||||
new_inst["panel"]["token_env"] = env_key
|
||||
if node_type:
|
||||
new_inst["panel"]["node_type"] = node_type
|
||||
new_inst["node"] = {"push_interval": 0, "pull_interval": 0}
|
||||
replaced = False
|
||||
for i, inst in enumerate(instances):
|
||||
if inst.get("id") == instance_id:
|
||||
instances[i] = new_inst
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
instances.append(new_inst)
|
||||
data["instances"] = instances
|
||||
with open(out_file, 'w', encoding='utf-8') as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=False)
|
||||
PY_CFG
|
||||
|
||||
local env_key
|
||||
if [ "$MODE" = "machine" ]; then
|
||||
env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_MACHINE_TOKEN"
|
||||
else
|
||||
env_key="INSTANCE_$(printf '%s' "$INSTANCE_ID" | tr '[:lower:]-' '[:upper:]_')_API_KEY"
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
init_args+=(--config "$CONFIG_FILE")
|
||||
fi
|
||||
python3 - "$CREDENTIALS_FILE" "$TMP_DIR/credentials.env" "$env_key" "$TOKEN" <<'PY_ENV'
|
||||
import os
|
||||
import sys
|
||||
src, out, key, value = sys.argv[1:]
|
||||
entries = {}
|
||||
order = []
|
||||
if os.path.exists(src):
|
||||
with open(src, 'r', encoding='utf-8') as f:
|
||||
for raw in f:
|
||||
line = raw.rstrip('\n')
|
||||
if not line or line.startswith('#') or '=' not in line:
|
||||
continue
|
||||
k, v = line.split('=', 1)
|
||||
if k not in entries:
|
||||
order.append(k)
|
||||
entries[k] = v
|
||||
if key not in entries:
|
||||
order.append(key)
|
||||
entries[key] = value
|
||||
with open(out, 'w', encoding='utf-8') as f:
|
||||
for k in order:
|
||||
f.write(f"{k}={entries[k]}\n")
|
||||
PY_ENV
|
||||
chmod 600 "$TMP_DIR/credentials.env"
|
||||
if [ -f "$CREDENTIALS_FILE" ]; then
|
||||
init_args+=(--credentials-in "$CREDENTIALS_FILE")
|
||||
fi
|
||||
if [ "$MODE" = "machine" ]; then
|
||||
init_args+=(--machine-id "$MACHINE_ID")
|
||||
else
|
||||
init_args+=(--node-id "$NODE_ID")
|
||||
if [ -n "$NODE_TYPE" ]; then
|
||||
init_args+=(--node-type "$NODE_TYPE")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$RUNTIME_GOMEMLIMIT" ]; then
|
||||
init_args+=(--gomemlimit "$RUNTIME_GOMEMLIMIT")
|
||||
fi
|
||||
if [ -n "$RUNTIME_GOGC" ] && [ "$RUNTIME_GOGC" -gt 0 ] 2>/dev/null; then
|
||||
init_args+=(--gogc "$RUNTIME_GOGC")
|
||||
fi
|
||||
|
||||
local output
|
||||
output=$("$TMP_DIR/xbctl" "${init_args[@]}") || {
|
||||
log_error "xbctl config init failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
render_install_meta() {
|
||||
python3 - "$TMP_DIR/config.yml" "$TMP_DIR/install-meta.json" "$RELEASE_VERSION" "$INSTANCE_ID" <<'PY_META'
|
||||
import json
|
||||
import sys
|
||||
import yaml
|
||||
from datetime import datetime, timezone
|
||||
cfg_path, out_path, version, latest_id = sys.argv[1:]
|
||||
with open(cfg_path, 'r', encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
instances = []
|
||||
for inst in data.get('instances', []) or []:
|
||||
item = {
|
||||
'id': inst.get('id', ''),
|
||||
'panel_url': ((inst.get('panel') or {}).get('url', '')),
|
||||
'mode': 'machine' if inst.get('machine') else ('standalone' if inst.get('standalone') else 'node'),
|
||||
'node_id': ((inst.get('panel') or {}).get('node_id')),
|
||||
'machine_id': ((inst.get('machine') or {}).get('machine_id')),
|
||||
'health_port': inst.get('health_port', 0),
|
||||
}
|
||||
instances.append(item)
|
||||
meta = {
|
||||
'config_mode': 'instances',
|
||||
'version': version,
|
||||
'latest_instance_id': latest_id,
|
||||
'instance_count': len(instances),
|
||||
'instances': instances,
|
||||
'updated_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
}
|
||||
with open(out_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
PY_META
|
||||
INSTANCE_ID=$(echo "$output" | grep '^INSTANCE_ID=' | cut -d= -f2-)
|
||||
chmod 600 "$TMP_DIR/credentials.env"
|
||||
}
|
||||
|
||||
render_service() {
|
||||
@@ -850,7 +651,7 @@ install_staged_files() {
|
||||
install -m 600 "$TMP_DIR/config.yml" "$CONFIG_FILE"
|
||||
install -m 600 "$TMP_DIR/credentials.env" "$CREDENTIALS_FILE"
|
||||
install -m 644 "$TMP_DIR/install-meta.json" "$INSTALL_META"
|
||||
if [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then
|
||||
if [ -f "$0" ] && [ "$(realpath "$0")" != "$(realpath "$INSTALLER_COPY_PATH" 2>/dev/null || echo "$INSTALLER_COPY_PATH")" ]; then
|
||||
install -m 755 "$0" "$INSTALLER_COPY_PATH"
|
||||
fi
|
||||
install -m 755 "$TMP_DIR/xbctl" "$CLI_PATH"
|
||||
@@ -909,7 +710,6 @@ perform_install() {
|
||||
stage_binary
|
||||
stage_xbctl
|
||||
render_config
|
||||
render_install_meta
|
||||
render_service
|
||||
backup_existing_state
|
||||
install_staged_files
|
||||
@@ -972,12 +772,11 @@ perform_uninstall() {
|
||||
fi
|
||||
rm -f "$BINARY_PATH"
|
||||
rm -f "$CLI_PATH"
|
||||
rm -f "$INSTALL_META"
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
rm -rf "$INSTALL_ROOT"
|
||||
log_info "Removed ${INSTALL_ROOT}"
|
||||
else
|
||||
rm -f "$CONFIG_FILE" "$CREDENTIALS_FILE"
|
||||
rm -f "$INSTALL_META"
|
||||
log_info "Config preserved under ${INSTALL_ROOT}"
|
||||
fi
|
||||
log_info "Uninstall complete"
|
||||
@@ -990,27 +789,17 @@ perform_status() {
|
||||
echo " state: ${CURRENT_STATE}"
|
||||
if [ -f "$INSTALL_META" ]; then
|
||||
echo " meta: ${INSTALL_META}"
|
||||
python3 - "$INSTALL_META" <<'PY'
|
||||
import json, sys
|
||||
with open(sys.argv[1]) as f:
|
||||
data = json.load(f)
|
||||
config_mode = data.get("config_mode")
|
||||
if config_mode:
|
||||
print(f" config_mode: {config_mode}")
|
||||
for key in ("mode", "panel_url", "kernel", "node_id", "machine_id", "health_port", "version", "updated_at", "latest_instance_id", "instance_count"):
|
||||
if key in data:
|
||||
print(f" {key}: {data[key]}")
|
||||
instances = data.get("instances") or []
|
||||
if instances:
|
||||
print(" instances:")
|
||||
for item in instances:
|
||||
mode = item.get("mode", "")
|
||||
panel_url = item.get("panel_url", "")
|
||||
node_id = item.get("node_id")
|
||||
machine_id = item.get("machine_id")
|
||||
target = f"node_id={node_id}" if node_id else (f"machine_id={machine_id}" if machine_id else "")
|
||||
print(f" - id={item.get('id','')} mode={mode} panel_url={panel_url} {target}".rstrip())
|
||||
PY
|
||||
if [ -x "$CLI_PATH" ]; then
|
||||
"$CLI_PATH" list 2>/dev/null || true
|
||||
else
|
||||
# Simple key extraction from JSON (no Python needed)
|
||||
local val
|
||||
for key in config_mode version latest_instance_id instance_count updated_at; do
|
||||
val=$(sed -n "s/.*\"${key}\": *\"\{0,1\}\([^\"]*\)\"\{0,1\}.*/\1/p" "$INSTALL_META" | head -1)
|
||||
val="${val%,}" # strip trailing comma from numeric JSON values
|
||||
[ -n "$val" ] && echo " ${key}: ${val}"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if [ -f "$SERVICE_PATH" ]; then
|
||||
echo " service: ${SERVICE_NAME}"
|
||||
|
||||
Reference in New Issue
Block a user