功能:完善云管助手与GPU故障恢复核对

This commit is contained in:
songyiming committed 2026-11-13 19:08:40 +08:00
1 parent b6026f9759
commit 9f7b01e4db
42 files changed
+3576 -120

No files matched your search

+35 -2
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -123,16 +124,48 @@ func (c *Client) Action(ctx context.Context, node string, vmid int, action strin
}
func (c *Client) Task(ctx context.Context, node, upid string) (TaskStatus, error) {
var status TaskStatus
err := c.request(ctx, "GET", "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", nil, &status)
err := c.readWithRetry(ctx, "/nodes/"+url.PathEscape(node)+"/tasks/"+url.PathEscape(upid)+"/status", &status)
return status, err
}
func (c *Client) Power(ctx context.Context, node string, vmid int) (string, error) {
var status struct {
Status string `json:"status"`
}
err := c.request(ctx, "GET", fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), nil, &status)
err := c.readWithRetry(ctx, fmt.Sprintf("/nodes/%s/qemu/%d/status/current", url.PathEscape(node), vmid), &status)
return status.Status, err
}
// Retry only observations. A lost POST response may already have changed the
// VM, so commands must never use this path.
func (c *Client) readWithRetry(ctx context.Context, path string, out any) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
if err = ctx.Err(); err != nil {
return err
}
err = c.request(ctx, "GET", path, nil, out)
if err == nil || !retryableRead(err) || attempt == 2 {
return err
}
timer := time.NewTimer(time.Duration(attempt+1) * 250 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return err
}
func retryableRead(err error) bool {
var response *HTTPError
if errors.As(err, &response) {
return response.Status == 408 || response.Status == 429 || response.Status == 500 || response.Status == 502 || response.Status == 503 || response.Status == 504
}
var network net.Error
return errors.As(err, &network) && (network.Timeout() || network.Temporary()) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)
}
func (c *Client) Wait(ctx context.Context, node, upid string, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
+65
View File
@@ -2,6 +2,7 @@ package proxmox
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -58,6 +59,70 @@ func TestTLSAndTaskLifecycle(t *testing.T) {
t.Fatal("restore must never map to hard reset")
}
}
func TestObservationRetriesDoNotReplayPowerCommands(t *testing.T) {
for _, endpoint := range []string{"task", "power"} {
t.Run(endpoint, func(t *testing.T) {
reads, writes := 0, 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
writes++
w.WriteHeader(http.StatusServiceUnavailable)
return
}
reads++
if reads < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
fmtBody := `{"data":{"status":"running"}}`
if endpoint == "task" {
fmtBody = `{"data":{"status":"stopped","exitstatus":"OK"}}`
}
w.Write([]byte(fmtBody))
}))
defer ts.Close()
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
var err error
if endpoint == "task" {
err = c.Wait(context.Background(), "node", "UPID:one", time.Millisecond)
} else {
_, err = c.Power(context.Background(), "node", 100)
}
if err != nil || reads != 3 {
t.Fatal("observation did not recover", reads, err)
}
if _, err = c.Action(context.Background(), "node", 100, "start"); err == nil || writes != 1 {
t.Fatal("command response loss must not replay POST", writes, err)
}
})
}
}
func TestObservationRetriesAreBoundedAndRespectCancellation(t *testing.T) {
for _, status := range []int{http.StatusForbidden, http.StatusServiceUnavailable} {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(status)
}))
c := &Client{BaseURL: ts.URL, HTTP: ts.Client()}
_, err := c.Power(context.Background(), "node", 100)
want := 1
if status == http.StatusServiceUnavailable {
want = 3
}
if err == nil || calls != want {
t.Fatal("incorrect retry limit", calls, err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err = c.Power(ctx, "node", 100); !errors.Is(err, context.Canceled) || calls != want {
t.Fatal("cancellation ignored", calls, err)
}
ts.Close()
}
}
func TestFailedPVEJobIsNotSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"data":{"status":"stopped","exitstatus":"ERROR: disk full"}}`))