Skip to content

Commit f8ef227

Browse files
committed
feat: add system update and uninstall commands to the ggo CLI, enhance agent connection handling and worker restart logic
1 parent c0fb784 commit f8ef227

10 files changed

Lines changed: 516 additions & 30 deletions

File tree

cmd/ggo/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/NexusGPU/gpu-go/cmd/ggo/libs"
1111
"github.com/NexusGPU/gpu-go/cmd/ggo/share"
1212
"github.com/NexusGPU/gpu-go/cmd/ggo/studio"
13+
"github.com/NexusGPU/gpu-go/cmd/ggo/system"
1314
"github.com/NexusGPU/gpu-go/cmd/ggo/use"
1415
"github.com/NexusGPU/gpu-go/cmd/ggo/version"
1516
"github.com/NexusGPU/gpu-go/cmd/ggo/worker"
@@ -57,6 +58,8 @@ func init() {
5758
rootCmd.AddCommand(deps.NewDepsCmd())
5859
rootCmd.AddCommand(studio.NewStudioCmd())
5960
rootCmd.AddCommand(libs.NewLibsCmd())
61+
rootCmd.AddCommand(system.NewUpdateCmd())
62+
rootCmd.AddCommand(system.NewUninstallCmd())
6063

6164
// Auth commands (login/logout at root level for convenience)
6265
rootCmd.AddCommand(auth.NewLoginCmd())

cmd/ggo/system/commands.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package system
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"runtime"
7+
8+
"github.com/spf13/cobra"
9+
)
10+
11+
// NewUpdateCmd creates the update command.
12+
func NewUpdateCmd() *cobra.Command {
13+
return newScriptCmd(
14+
scriptActionUpdate,
15+
"update",
16+
"Update ggo to the latest version",
17+
"Downloads and runs the platform install script from the CDN.",
18+
"ggo update",
19+
)
20+
}
21+
22+
// NewUninstallCmd creates the uninstall command.
23+
func NewUninstallCmd() *cobra.Command {
24+
return newScriptCmd(
25+
scriptActionUninstall,
26+
"uninstall",
27+
"Uninstall ggo from this machine",
28+
"Downloads and runs the platform uninstall script from the CDN.",
29+
"ggo uninstall",
30+
)
31+
}
32+
33+
func newScriptCmd(action scriptAction, use, short, long, example string) *cobra.Command {
34+
cmd := &cobra.Command{
35+
Use: use,
36+
Short: short,
37+
Long: long,
38+
Example: example,
39+
Args: cobra.NoArgs,
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
command, cmdArgs, err := buildScriptCommand(action, runtime.GOOS)
42+
if err != nil {
43+
return err
44+
}
45+
execCmd := exec.Command(command, cmdArgs...)
46+
execCmd.Stdout = os.Stdout
47+
execCmd.Stderr = os.Stderr
48+
execCmd.Stdin = os.Stdin
49+
return execCmd.Run()
50+
},
51+
}
52+
53+
return cmd
54+
}

cmd/ggo/system/system.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package system
2+
3+
import (
4+
"fmt"
5+
)
6+
7+
type scriptAction string
8+
9+
const (
10+
scriptActionUpdate scriptAction = "update"
11+
scriptActionUninstall scriptAction = "uninstall"
12+
)
13+
14+
const cdnBaseURL = "https://cdn.tensor-fusion.ai/archive/gpugo"
15+
16+
func buildScriptCommand(action scriptAction, goos string) (string, []string, error) {
17+
scriptBase, err := scriptBaseName(action)
18+
if err != nil {
19+
return "", nil, err
20+
}
21+
22+
switch goos {
23+
case "linux", "darwin":
24+
url := fmt.Sprintf("%s/%s.sh", cdnBaseURL, scriptBase)
25+
return "sh", []string{"-c", fmt.Sprintf("curl -sfL %s | sh", url)}, nil
26+
case "windows":
27+
url := fmt.Sprintf("%s/%s.ps1", cdnBaseURL, scriptBase)
28+
return "powershell", []string{
29+
"-NoProfile",
30+
"-ExecutionPolicy",
31+
"Bypass",
32+
"-Command",
33+
fmt.Sprintf("irm %s | iex", url),
34+
}, nil
35+
default:
36+
return "", nil, fmt.Errorf("unsupported OS: %s", goos)
37+
}
38+
}
39+
40+
func scriptBaseName(action scriptAction) (string, error) {
41+
switch action {
42+
case scriptActionUpdate:
43+
return "install", nil
44+
case scriptActionUninstall:
45+
return "uninstall", nil
46+
default:
47+
return "", fmt.Errorf("unsupported action: %s", action)
48+
}
49+
}

cmd/ggo/system/system_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package system
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestBuildScriptCommand_UninstallLinux(t *testing.T) {
11+
cmd, args, err := buildScriptCommand(scriptActionUninstall, "linux")
12+
require.NoError(t, err)
13+
assert.Equal(t, "sh", cmd)
14+
assert.Equal(t, []string{
15+
"-c",
16+
"curl -sfL https://cdn.tensor-fusion.ai/archive/gpugo/uninstall.sh | sh",
17+
}, args)
18+
}
19+
20+
func TestBuildScriptCommand_UninstallWindows(t *testing.T) {
21+
cmd, args, err := buildScriptCommand(scriptActionUninstall, "windows")
22+
require.NoError(t, err)
23+
assert.Equal(t, "powershell", cmd)
24+
assert.Equal(t, []string{
25+
"-NoProfile",
26+
"-ExecutionPolicy",
27+
"Bypass",
28+
"-Command",
29+
"irm https://cdn.tensor-fusion.ai/archive/gpugo/uninstall.ps1 | iex",
30+
}, args)
31+
}
32+
33+
func TestBuildScriptCommand_UpdateDarwin(t *testing.T) {
34+
cmd, args, err := buildScriptCommand(scriptActionUpdate, "darwin")
35+
require.NoError(t, err)
36+
assert.Equal(t, "sh", cmd)
37+
assert.Equal(t, []string{
38+
"-c",
39+
"curl -sfL https://cdn.tensor-fusion.ai/archive/gpugo/install.sh | sh",
40+
}, args)
41+
}
42+
43+
func TestBuildScriptCommand_UpdateWindows(t *testing.T) {
44+
cmd, args, err := buildScriptCommand(scriptActionUpdate, "windows")
45+
require.NoError(t, err)
46+
assert.Equal(t, "powershell", cmd)
47+
assert.Equal(t, []string{
48+
"-NoProfile",
49+
"-ExecutionPolicy",
50+
"Bypass",
51+
"-Command",
52+
"irm https://cdn.tensor-fusion.ai/archive/gpugo/install.ps1 | iex",
53+
}, args)
54+
}
55+
56+
func TestBuildScriptCommand_UnsupportedOS(t *testing.T) {
57+
_, _, err := buildScriptCommand(scriptActionUpdate, "plan9")
58+
require.Error(t, err)
59+
}

internal/agent/agent.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ func (a *Agent) convertToWorkerInfos(apiWorkers []api.WorkerConfig) ([]*hvApi.Wo
446446
envVars["TF_ENABLE_LOG"] = "1"
447447
envVars[EnvURLAuth] = "1"
448448
envVars[EnvAuthorizedKeyPath] = filepath.Join(a.paths.ConfigDir(), w.WorkerID+"_share_codes")
449+
envVars[EnvConnectionInfoPath] = a.connectionsDir
449450

450451
// Set hard limiter environment variables for Fractional GPU support
451452
// TODO: use MIG for partitioned
@@ -1070,7 +1071,7 @@ func (a *Agent) collectWorkerStatus(
10701071
return a.collectWorkerStatusFromHypervisor(forceRefresh, connectionChanges, currentConnections, gpuChanges)
10711072
}
10721073
// Fallback to config-based status
1073-
return a.collectWorkerStatusFromConfig(forceRefresh, gpuChanges)
1074+
return a.collectWorkerStatusFromConfig(forceRefresh, currentConnections, gpuChanges)
10741075
}
10751076

10761077
// collectWorkerStatusFromHypervisor gets worker status from hypervisor
@@ -1162,7 +1163,11 @@ func (a *Agent) collectWorkerStatusFromHypervisor(
11621163
}
11631164

11641165
// collectWorkerStatusFromConfig gets worker status from local config (fallback)
1165-
func (a *Agent) collectWorkerStatusFromConfig(forceRefresh bool, gpuChanges map[string]bool) ([]api.WorkerStatus, error) {
1166+
func (a *Agent) collectWorkerStatusFromConfig(
1167+
forceRefresh bool,
1168+
currentConnections map[string][]string,
1169+
gpuChanges map[string]bool,
1170+
) ([]api.WorkerStatus, error) {
11661171
workerConfigs, err := a.config.LoadWorkers()
11671172
if err != nil {
11681173
return nil, err
@@ -1179,6 +1184,10 @@ func (a *Agent) collectWorkerStatusFromConfig(forceRefresh bool, gpuChanges map[
11791184
workerChanged := true
11801185
connectionChanged := true
11811186
gpuChanged := forceRefresh || len(gpuChanges) > 0
1187+
connections := w.Connections
1188+
if connLines, ok := currentConnections[w.WorkerID]; ok {
1189+
connections = parseConnectionsToAPI(connLines)
1190+
}
11821191

11831192
gpuIndices := resolveWorkerGPUIndices(w.WorkerID, w.GPUIndices, w.GPUIDs, gpuIndexByID)
11841193
workerStatuses[i] = api.WorkerStatus{
@@ -1187,7 +1196,7 @@ func (a *Agent) collectWorkerStatusFromConfig(forceRefresh bool, gpuChanges map[
11871196
PID: w.PID,
11881197
GPUIDs: w.GPUIDs,
11891198
GPUIndices: gpuIndices,
1190-
Connections: w.Connections,
1199+
Connections: connections,
11911200
WorkerChanged: &workerChanged,
11921201
ConnectionChanged: &connectionChanged,
11931202
GPUChanged: &gpuChanged,

internal/agent/agent_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"net/http/httptest"
8+
"os"
89
"path/filepath"
910
"sync"
1011
"testing"
@@ -277,6 +278,70 @@ func TestAgent_ReportStatus(t *testing.T) {
277278
assert.Equal(t, 12345, receivedReq.Workers[0].PID)
278279
}
279280

281+
func TestAgent_ReportStatus_ReadsConnectionFilesEveryTime(t *testing.T) {
282+
tmpDir := t.TempDir()
283+
configDir := filepath.Join(tmpDir, "config")
284+
stateDir := filepath.Join(tmpDir, "state")
285+
connectionsDir := filepath.Join(tmpDir, "connections")
286+
require.NoError(t, os.MkdirAll(connectionsDir, 0755))
287+
288+
var mu sync.Mutex
289+
var receivedReqs []api.AgentStatusRequest
290+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291+
var req api.AgentStatusRequest
292+
_ = json.NewDecoder(r.Body).Decode(&req)
293+
mu.Lock()
294+
receivedReqs = append(receivedReqs, req)
295+
mu.Unlock()
296+
w.Header().Set("Content-Type", "application/json")
297+
_ = json.NewEncoder(w).Encode(api.SuccessResponse{Success: true})
298+
}))
299+
defer server.Close()
300+
301+
configMgr := config.NewManager(configDir, stateDir)
302+
err := configMgr.SaveGPUs([]config.GPUConfig{
303+
{GPUID: "GPU-0", GPUIndex: 0, Vendor: "nvidia", Model: "RTX 4090", VRAMMb: 24576},
304+
})
305+
require.NoError(t, err)
306+
err = configMgr.SaveWorkers([]config.WorkerConfig{
307+
{WorkerID: "worker_1", GPUIDs: []string{"GPU-0"}, ListenPort: 9001, Enabled: true, Status: "running", PID: 12345},
308+
})
309+
require.NoError(t, err)
310+
err = os.WriteFile(filepath.Join(connectionsDir, "worker_1.txt"), []byte("10.1.1.1,1234,11\n"), 0644)
311+
require.NoError(t, err)
312+
313+
client := api.NewClient(
314+
api.WithBaseURL(server.URL),
315+
api.WithAgentSecret("gpugo_secret123"),
316+
)
317+
agent := &Agent{
318+
client: client,
319+
config: configMgr,
320+
ctx: context.Background(),
321+
agentID: "agent_test123",
322+
paths: platform.DefaultPaths().WithConfigDir(configDir),
323+
connectionsDir: connectionsDir,
324+
}
325+
326+
err = agent.reportStatus()
327+
require.NoError(t, err)
328+
329+
err = os.WriteFile(filepath.Join(connectionsDir, "worker_1.txt"), []byte("10.2.2.2,5678,22\n"), 0644)
330+
require.NoError(t, err)
331+
err = agent.reportStatus()
332+
require.NoError(t, err)
333+
334+
mu.Lock()
335+
defer mu.Unlock()
336+
require.Len(t, receivedReqs, 2)
337+
require.Len(t, receivedReqs[0].Workers, 1)
338+
require.Len(t, receivedReqs[0].Workers[0].Connections, 1)
339+
assert.Equal(t, "10.1.1.1", receivedReqs[0].Workers[0].Connections[0].ClientIP)
340+
require.Len(t, receivedReqs[1].Workers, 1)
341+
require.Len(t, receivedReqs[1].Workers[0].Connections, 1)
342+
assert.Equal(t, "10.2.2.2", receivedReqs[1].Workers[0].Connections[0].ClientIP)
343+
}
344+
280345
func TestAgent_HandleHeartbeatResponse(t *testing.T) {
281346
tmpDir := t.TempDir()
282347
configDir := filepath.Join(tmpDir, "config")
@@ -492,6 +557,44 @@ func TestAgent_WithHypervisor(t *testing.T) {
492557
agent.Stop()
493558
}
494559

560+
func TestAgent_ConvertToWorkerInfos_IncludesConnectionInfoPath(t *testing.T) {
561+
tmpDir := t.TempDir()
562+
configDir := filepath.Join(tmpDir, "config")
563+
stateDir := filepath.Join(tmpDir, "state")
564+
565+
configMgr := config.NewManager(configDir, stateDir)
566+
cfg := &config.Config{
567+
ConfigVersion: 1,
568+
AgentID: "agent_test123",
569+
AgentSecret: "gpugo_secret123",
570+
ServerURL: "http://localhost",
571+
License: api.License{
572+
Plain: "test|pro|9999999999",
573+
Encrypted: "enc",
574+
},
575+
}
576+
err := configMgr.SaveConfig(cfg)
577+
require.NoError(t, err)
578+
579+
agent := NewAgent(api.NewClient(), configMgr)
580+
agent.workerBinaryPath = "/bin/true"
581+
agent.connectionsDir = filepath.Join(tmpDir, "connections")
582+
583+
infos, err := agent.convertToWorkerInfos([]api.WorkerConfig{
584+
{
585+
WorkerID: "worker_1",
586+
GPUIDs: []string{"gpu-0"},
587+
ListenPort: 9001,
588+
Enabled: true,
589+
},
590+
})
591+
require.NoError(t, err)
592+
require.Len(t, infos, 1)
593+
require.NotNil(t, infos[0].WorkerRunningInfo)
594+
595+
assert.Equal(t, agent.connectionsDir, infos[0].WorkerRunningInfo.Env[EnvConnectionInfoPath])
596+
}
597+
495598
func TestAgent_LicenseParsing(t *testing.T) {
496599
tmpDir := t.TempDir()
497600
configDir := filepath.Join(tmpDir, "config")

0 commit comments

Comments
 (0)