Fix busy binary reinstall and add per-node log viewing.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+56
-11
@@ -49,6 +49,13 @@ func (c SSHConfig) dial() (*ssh.Client, error) {
|
||||
return ssh.Dial("tcp", addr, cfg)
|
||||
}
|
||||
|
||||
func Dial(n *models.Node, sshSecret string) (*ssh.Client, error) {
|
||||
return SSHConfig{
|
||||
Host: n.Host, Port: n.SSHPort, User: n.SSHUser,
|
||||
AuthType: n.SSHAuthType, Secret: sshSecret,
|
||||
}.dial()
|
||||
}
|
||||
|
||||
func run(client *ssh.Client, cmd string, log LogFunc) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
@@ -77,10 +84,12 @@ func shellQuote(s string) string {
|
||||
return strconv.Quote(s)
|
||||
}
|
||||
|
||||
// uploadViaStdin writes bytes to remotePath using `cat` over SSH stdin.
|
||||
// Avoids legacy `scp -t`, which fails on many modern OpenSSH servers (status 1).
|
||||
// uploadViaStdin writes to a temp file then atomically replaces the target.
|
||||
// Avoids "Text file busy" when the destination is a running binary / bind-mount.
|
||||
func uploadViaStdin(client *ssh.Client, remotePath string, data []byte, mode os.FileMode, log LogFunc) error {
|
||||
dir := filepath.ToSlash(filepath.Dir(remotePath))
|
||||
tmpPath := remotePath + ".tmp"
|
||||
|
||||
if _, err := run(client, "mkdir -p "+shellQuote(dir)+" 2>/dev/null || sudo mkdir -p "+shellQuote(dir), log); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
@@ -99,15 +108,14 @@ func uploadViaStdin(client *ssh.Client, remotePath string, data []byte, mode os.
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefer direct write; fall back to sudo tee if permission denied.
|
||||
cmd := fmt.Sprintf(
|
||||
`sh -c 'cat > %s && chmod %o %s' 2>/dev/null || sudo sh -c 'cat > %s && chmod %o %s'`,
|
||||
shellQuote(remotePath), mode, shellQuote(remotePath),
|
||||
shellQuote(remotePath), mode, shellQuote(remotePath),
|
||||
`sh -c 'cat > %s && chmod %o %s && mv -f %s %s' 2>/dev/null || sudo sh -c 'cat > %s && chmod %o %s && mv -f %s %s'`,
|
||||
shellQuote(tmpPath), mode, shellQuote(tmpPath), shellQuote(tmpPath), shellQuote(remotePath),
|
||||
shellQuote(tmpPath), mode, shellQuote(tmpPath), shellQuote(tmpPath), shellQuote(remotePath),
|
||||
)
|
||||
|
||||
if log != nil {
|
||||
log(fmt.Sprintf("upload %s (%d bytes) via ssh stdin", remotePath, len(data)))
|
||||
log(fmt.Sprintf("upload %s (%d bytes) via tmp+mv", remotePath, len(data)))
|
||||
}
|
||||
|
||||
if err := session.Start(cmd); err != nil {
|
||||
@@ -139,15 +147,46 @@ func uploadBinary(client *ssh.Client, remotePath string, data []byte, log LogFun
|
||||
return uploadViaStdin(client, remotePath, data, 0o755, log)
|
||||
}
|
||||
|
||||
func stopNodeContainers(client *ssh.Client, log LogFunc) {
|
||||
_, _ = run(client, `cd /opt/vpn-panel-node 2>/dev/null && (docker compose down --remove-orphans || docker-compose down --remove-orphans || true)`, log)
|
||||
_, _ = run(client, `docker rm -f vpn-panel-node 2>/dev/null || true`, log)
|
||||
// Ensure nothing still holds the binary open.
|
||||
_, _ = run(client, `sleep 1; true`, nil)
|
||||
}
|
||||
|
||||
// FetchDockerLogs pulls recent container / agent logs over SSH.
|
||||
func FetchDockerLogs(n *models.Node, sshSecret string, lines int) (string, error) {
|
||||
if lines <= 0 {
|
||||
lines = 300
|
||||
}
|
||||
client, err := Dial(n, sshSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ssh connect: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
cmd := fmt.Sprintf(
|
||||
`(docker logs --tail %d vpn-panel-node 2>&1 || docker compose -f /opt/vpn-panel-node/docker-compose.yml logs --tail %d 2>&1 || true); `+
|
||||
`echo "---- agent.log ----"; `+
|
||||
`(tail -n %d /opt/vpn-panel-node/data/agent.log 2>/dev/null || sudo tail -n %d /opt/vpn-panel-node/data/agent.log 2>/dev/null || echo "(no agent.log)")`,
|
||||
lines, lines, lines, lines,
|
||||
)
|
||||
out, err := run(client, cmd, nil)
|
||||
if out == "" && err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out == "" {
|
||||
out = "(empty logs)"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Provision installs Docker (if needed), uploads agent binary + compose, starts the node.
|
||||
func Provision(n *models.Node, sshSecret string, agentBinary []byte, log LogFunc) error {
|
||||
if len(agentBinary) == 0 {
|
||||
return fmt.Errorf("agent binary is empty — build cmd/node for linux/amd64")
|
||||
}
|
||||
client, err := SSHConfig{
|
||||
Host: n.Host, Port: n.SSHPort, User: n.SSHUser,
|
||||
AuthType: n.SSHAuthType, Secret: sshSecret,
|
||||
}.dial()
|
||||
client, err := Dial(n, sshSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh connect: %w", err)
|
||||
}
|
||||
@@ -164,6 +203,12 @@ func Provision(n *models.Node, sshSecret string, agentBinary []byte, log LogFunc
|
||||
return fmt.Errorf("prepare dir: %w", err)
|
||||
}
|
||||
|
||||
// Stop running agent first — otherwise overwrite fails with "Text file busy".
|
||||
if log != nil {
|
||||
log("stopping existing vpn-panel-node (if any)")
|
||||
}
|
||||
stopNodeContainers(client, log)
|
||||
|
||||
if err := uploadBinary(client, "/opt/vpn-panel-node/vpn-node", agentBinary, log); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user