github.com/qiniu/go-sdk/v7@v7.27.0/sandbox/sandbox.go (about) 1 package sandbox 2 3 import ( 4 "context" 5 "crypto/sha256" 6 "encoding/base64" 7 "errors" 8 "fmt" 9 "math" 10 "math/rand/v2" 11 "net" 12 "net/http" 13 "net/url" 14 "strconv" 15 "strings" 16 "sync" 17 "time" 18 19 "github.com/google/uuid" 20 "github.com/qiniu/go-sdk/v7/reqid" 21 "github.com/qiniu/go-sdk/v7/sandbox/internal/apis" 22 "github.com/qiniu/go-sdk/v7/sandbox/internal/envdapi/process/processconnect" 23 24 connect "connectrpc.com/connect" 25 ) 26 27 // envdPort 是 envd agent 的默认端口。 28 const envdPort = 49983 29 30 // DefaultUser 是沙箱命令执行和文件操作的默认用户名。 31 const DefaultUser = "user" 32 33 // Sandbox 表示一个运行中的沙箱实例。 34 // 持有客户端引用,用于执行生命周期操作和 envd agent 通信。 35 type Sandbox struct { 36 sandboxID string 37 templateID string 38 clientID string 39 alias *string 40 domain *string 41 trafficAccessToken *string 42 43 // envdAccessToken 用于 envd 认证,需通过 envdTokenMu 保护并发读写。 44 envdTokenMu sync.RWMutex 45 envdAccessToken *string 46 envdTokenLoaded bool // 标记是否已尝试获取过 token(避免重复请求) 47 48 client *Client 49 50 // 共享的 ProcessClient(Commands 和 Pty 共用) 51 processRPCOnce sync.Once 52 processRPC processconnect.ProcessClient 53 54 // envd 子模块(懒初始化) 55 filesOnce sync.Once 56 files *Filesystem 57 58 commandsOnce sync.Once 59 commands *Commands 60 61 ptyOnce sync.Once 62 pty *Pty 63 64 gitOnce sync.Once 65 git *Git 66 } 67 68 // newSandbox 从 API 响应创建 Sandbox 实例。 69 func newSandbox(c *Client, s *apis.Sandbox) *Sandbox { 70 sb := &Sandbox{ 71 sandboxID: s.SandboxID, 72 templateID: s.TemplateID, 73 clientID: s.ClientID, 74 alias: s.Alias, 75 domain: s.Domain, 76 trafficAccessToken: s.TrafficAccessToken, 77 client: c, 78 } 79 if s.EnvdAccessToken != nil { 80 sb.envdAccessToken = s.EnvdAccessToken 81 sb.envdTokenLoaded = true 82 } 83 return sb 84 } 85 86 // ID 返回沙箱 ID。 87 func (s *Sandbox) ID() string { return s.sandboxID } 88 89 // TemplateID 返回沙箱所属的模板 ID。 90 func (s *Sandbox) TemplateID() string { return s.templateID } 91 92 // Alias 返回沙箱的别名。 93 func (s *Sandbox) Alias() *string { return s.alias } 94 95 // Domain 返回沙箱的域名。 96 func (s *Sandbox) Domain() *string { return s.domain } 97 98 // processClient 返回共享的 ProcessClient 实例,Commands 和 Pty 共用。 99 func (s *Sandbox) processClient() processconnect.ProcessClient { 100 s.processRPCOnce.Do(func() { 101 s.processRPC = processconnect.NewProcessClient( 102 s.client.config.HTTPClient, 103 s.envdURL(), 104 connect.WithInterceptors(keepaliveInterceptor{}), 105 ) 106 }) 107 return s.processRPC 108 } 109 110 // Create 根据指定模板创建一个新的沙箱。 111 // 默认自动生成幂等键,在遇到服务端错误或网络错误时自动重试。 112 func (c *Client) Create(ctx context.Context, params CreateParams) (*Sandbox, error) { 113 apiParams, err := params.toAPI() 114 if err != nil { 115 return nil, err 116 } 117 var editors []apis.RequestEditorFn 118 if params.hasKodoResource() { 119 cred, err := c.GetCredentialsOption() 120 if err != nil { 121 return nil, err 122 } 123 if cred == nil { 124 return nil, fmt.Errorf("kodo resource requires Qiniu AK/SK credentials, please configure them in sandbox.Config") 125 } 126 editors = append(editors, cred) 127 } 128 idempotencyKey := params.IdempotencyKey 129 if idempotencyKey == "" { 130 idempotencyKey = newIdempotencyKey() 131 } 132 var resp *apis.CreateSandboxResponse 133 err = c.retryCall(ctx, func() error { 134 var e error 135 resp, e = c.api.CreateSandboxWithResponse(ctx, &apis.CreateSandboxParams{ 136 IdempotencyKey: &idempotencyKey, 137 }, apiParams, editors...) 138 if e != nil { 139 return e 140 } 141 if resp.JSON201 == nil { 142 return newAPIError(resp.HTTPResponse, resp.Body) 143 } 144 return nil 145 }) 146 if err != nil { 147 return nil, err 148 } 149 sb := newSandbox(c, resp.JSON201) 150 if !sb.envdTokenLoaded { 151 if tErr := sb.refreshEnvdToken(ctx); tErr != nil { 152 return nil, fmt.Errorf("create sandbox %s: %w", sb.sandboxID, tErr) 153 } 154 } 155 return sb, nil 156 } 157 158 // Connect 连接到一个已有的沙箱,可选择恢复已暂停的沙箱。 159 // 在遇到服务端错误或网络错误时自动重试。 160 func (c *Client) Connect(ctx context.Context, sandboxID string, params ConnectParams) (*Sandbox, error) { 161 var resp *apis.ConnectSandboxResponse 162 err := c.retryCall(ctx, func() error { 163 var e error 164 resp, e = c.api.ConnectSandboxWithResponse(ctx, sandboxID, params.toAPI()) 165 if e != nil { 166 return e 167 } 168 if resp.JSON200 == nil && resp.JSON201 == nil { 169 return newAPIError(resp.HTTPResponse, resp.Body) 170 } 171 return nil 172 }) 173 if err != nil { 174 return nil, err 175 } 176 var sandboxResp *apis.Sandbox 177 if resp.JSON200 != nil { 178 sandboxResp = resp.JSON200 179 } else { 180 sandboxResp = resp.JSON201 181 } 182 sb := newSandbox(c, sandboxResp) 183 // Connect API 可能不返回 envdAccessToken,需要通过 GetSandbox 补充。 184 // envdAccessToken 用于 envd gRPC 认证,缺少时 PTY/命令执行等操作会静默失败。 185 if !sb.envdTokenLoaded { 186 if tErr := sb.refreshEnvdToken(ctx); tErr != nil { 187 return nil, fmt.Errorf("connect sandbox %s: %w", sandboxID, tErr) 188 } 189 } 190 return sb, nil 191 } 192 193 // retryCall 执行 API 调用并在可重试错误时自动重试。 194 // 重试次数由 Config.RetryMax 控制(nil 默认 5,0 禁用重试)。 195 func (c *Client) retryCall(ctx context.Context, fn func() error) error { 196 maxRetries := 5 197 if c.config.RetryMax != nil { 198 maxRetries = *c.config.RetryMax 199 } 200 backoffFn := c.config.RetryBackoff 201 if backoffFn == nil { 202 backoffFn = exponentialBackoff 203 } 204 for attempt := 0; ; attempt++ { 205 if err := ctx.Err(); err != nil { 206 return err 207 } 208 err := fn() 209 if err == nil { 210 return nil 211 } 212 if ctxErr := ctx.Err(); ctxErr != nil { 213 return ctxErr 214 } 215 if attempt >= maxRetries || !isRetryable(err) { 216 return err 217 } 218 d := backoffFn(attempt) 219 if d > 0 { 220 timer := time.NewTimer(d) 221 select { 222 case <-timer.C: 223 case <-ctx.Done(): 224 if !timer.Stop() { 225 select { 226 case <-timer.C: 227 default: 228 } 229 } 230 return ctx.Err() 231 } 232 } 233 } 234 } 235 236 // exponentialBackoff 指数退避(500ms → 1s → 2s → 4s → 8s,上限 10s)+ 随机抖动。 237 func exponentialBackoff(attempt int) time.Duration { 238 const maxBackoff = 10 * time.Second 239 base := 500 * time.Millisecond 240 if attempt >= 5 { 241 base = maxBackoff 242 } else if attempt > 0 { 243 base *= time.Duration(1 << attempt) 244 } 245 if base > maxBackoff { 246 base = maxBackoff 247 } 248 jitter := time.Duration(rand.Int64N(int64(base/2 + 1))) 249 return base + jitter 250 } 251 252 // List 列出沙箱,支持分页和状态过滤。 253 func (c *Client) List(ctx context.Context, params *ListParams) ([]ListedSandbox, error) { 254 resp, err := c.api.ListSandboxesV2WithResponse(ctx, params.toAPI()) 255 if err != nil { 256 return nil, err 257 } 258 if resp.JSON200 == nil { 259 return nil, newAPIError(resp.HTTPResponse, resp.Body) 260 } 261 return listedSandboxesFromAPI(*resp.JSON200), nil 262 } 263 264 // GetInjections 返回沙箱当前的运行时请求注入规则。 265 // 响应中的密钥、令牌和 Header 等敏感字段由服务端脱敏。 266 // 返回值不能直接用于 UpdateInjections;更新时必须提供包含真实敏感值的新配置。 267 func (s *Sandbox) GetInjections(ctx context.Context) ([]MaskedSandboxInjection, error) { 268 resp, err := s.client.api.GetSandboxInjectionsWithResponse(ctx, s.sandboxID) 269 if err != nil { 270 return nil, err 271 } 272 if resp.JSON200 == nil { 273 return nil, newAPIError(resp.HTTPResponse, resp.Body) 274 } 275 return maskedSandboxInjectionsFromAPI(resp.JSON200.Injections) 276 } 277 278 // UpdateInjections 替换沙箱的全部运行时请求注入规则。 279 // 变更会立即应用于新的出站 HTTPS 连接。 280 func (s *Sandbox) UpdateInjections(ctx context.Context, injections []SandboxInjectionSpec) error { 281 apiInjections := make([]apis.SandboxInjection, len(injections)) 282 for i, injection := range injections { 283 apiInjection, err := sandboxInjectionSpecToAPI(injection) 284 if err != nil { 285 return err 286 } 287 apiInjections[i] = apiInjection 288 } 289 resp, err := s.client.api.UpdateSandboxInjectionsWithResponse(ctx, s.sandboxID, apis.UpdateSandboxInjectionsJSONRequestBody{ 290 Injections: apiInjections, 291 }) 292 if err != nil { 293 return err 294 } 295 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 296 return newAPIError(resp.HTTPResponse, resp.Body) 297 } 298 return nil 299 } 300 301 // UpdateGitHubToken 更新沙箱使用的 GitHub 授权令牌。 302 func (s *Sandbox) UpdateGitHubToken(ctx context.Context, authorizationToken string) error { 303 resp, err := s.client.api.UpdateSandboxGithubTokenWithResponse(ctx, s.sandboxID, apis.UpdateSandboxGithubTokenJSONRequestBody{ 304 AuthorizationToken: authorizationToken, 305 }) 306 if err != nil { 307 return err 308 } 309 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 310 return newAPIError(resp.HTTPResponse, resp.Body) 311 } 312 return nil 313 } 314 315 // Kill 终止沙箱。 316 func (s *Sandbox) Kill(ctx context.Context) error { 317 resp, err := s.client.api.DeleteSandboxWithResponse(ctx, s.sandboxID) 318 if err != nil { 319 return err 320 } 321 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 322 return newAPIError(resp.HTTPResponse, resp.Body) 323 } 324 return nil 325 } 326 327 // SetTimeout 更新沙箱超时时间。 328 // 沙箱将在从现在起经过指定时长后过期。 329 // timeout 必须 >= 1 秒。 330 func (s *Sandbox) SetTimeout(ctx context.Context, timeout time.Duration) error { 331 if timeout < time.Second { 332 return fmt.Errorf("timeout must be at least 1 second, got %v", timeout) 333 } 334 secs := timeout.Seconds() 335 if secs > float64(math.MaxInt32) { 336 return fmt.Errorf("timeout %v exceeds maximum allowed value", timeout) 337 } 338 timeoutSec := int32(secs) 339 resp, err := s.client.api.UpdateSandboxTimeoutWithResponse(ctx, s.sandboxID, apis.UpdateSandboxTimeoutJSONRequestBody{ 340 Timeout: timeoutSec, 341 }) 342 if err != nil { 343 return err 344 } 345 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 346 return newAPIError(resp.HTTPResponse, resp.Body) 347 } 348 return nil 349 } 350 351 // refreshEnvdToken 通过 GetSandbox API 获取 envdAccessToken 并更新到当前实例。 352 // 调用者必须确保已持有 envdTokenMu 的写锁或在初始化阶段调用。 353 func (s *Sandbox) refreshEnvdToken(ctx context.Context) error { 354 resp, err := s.client.api.GetSandboxWithResponse(ctx, s.sandboxID) 355 if err != nil { 356 return fmt.Errorf("get sandbox %s for envd token: %w", s.sandboxID, err) 357 } 358 if resp.JSON200 == nil { 359 return fmt.Errorf("get sandbox %s for envd token: %w", s.sandboxID, newAPIError(resp.HTTPResponse, resp.Body)) 360 } 361 s.envdTokenMu.Lock() 362 s.envdAccessToken = resp.JSON200.EnvdAccessToken 363 s.envdTokenLoaded = true 364 s.envdTokenMu.Unlock() 365 return nil 366 } 367 368 // GetInfo 返回沙箱的详细信息。 369 func (s *Sandbox) GetInfo(ctx context.Context) (*SandboxInfo, error) { 370 resp, err := s.client.api.GetSandboxWithResponse(ctx, s.sandboxID) 371 if err != nil { 372 return nil, err 373 } 374 if resp.JSON200 == nil { 375 return nil, newAPIError(resp.HTTPResponse, resp.Body) 376 } 377 return sandboxInfoFromAPI(resp.JSON200), nil 378 } 379 380 // IsRunning 通过探测 envd /health 端点检查沙箱是否正在运行且可用。 381 // 与 GetInfo(查询控制面状态)不同,此方法直接验证沙箱内部 agent 是否可达。 382 // 返回 true 表示沙箱运行中且 envd agent 已就绪;返回 false 表示沙箱不可达(已暂停、已终止等)。 383 func (s *Sandbox) IsRunning(ctx context.Context) (bool, error) { 384 req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.envdURL()+"/health", nil) 385 if err != nil { 386 return false, err 387 } 388 setReqidHeader(ctx, req) 389 resp, err := s.client.config.HTTPClient.Do(req) 390 if err != nil { 391 return false, err 392 } 393 resp.Body.Close() 394 395 if resp.StatusCode == http.StatusNoContent { 396 return true, nil 397 } 398 if resp.StatusCode == http.StatusBadGateway { 399 return false, nil 400 } 401 return false, newAPIError(resp, nil) 402 } 403 404 // GetMetrics 返回沙箱的资源指标。 405 func (s *Sandbox) GetMetrics(ctx context.Context, params *GetMetricsParams) ([]SandboxMetric, error) { 406 resp, err := s.client.api.GetSandboxMetricsWithResponse(ctx, s.sandboxID, params.toAPI()) 407 if err != nil { 408 return nil, err 409 } 410 if resp.JSON200 == nil { 411 return nil, newAPIError(resp.HTTPResponse, resp.Body) 412 } 413 return sandboxMetricsFromAPI(*resp.JSON200), nil 414 } 415 416 // GetLogs 返回沙箱日志。 417 func (s *Sandbox) GetLogs(ctx context.Context, params *GetLogsParams) (*SandboxLogs, error) { 418 resp, err := s.client.api.GetSandboxLogsWithResponse(ctx, s.sandboxID, params.toAPI()) 419 if err != nil { 420 return nil, err 421 } 422 if resp.JSON200 == nil { 423 return nil, newAPIError(resp.HTTPResponse, resp.Body) 424 } 425 return sandboxLogsFromAPI(resp.JSON200), nil 426 } 427 428 // Pause 暂停沙箱,以便后续恢复。 429 func (s *Sandbox) Pause(ctx context.Context) error { 430 resp, err := s.client.api.PauseSandboxWithResponse(ctx, s.sandboxID) 431 if err != nil { 432 return err 433 } 434 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 435 return newAPIError(resp.HTTPResponse, resp.Body) 436 } 437 return nil 438 } 439 440 // Refresh 延长沙箱的存活时间。 441 func (s *Sandbox) Refresh(ctx context.Context, params RefreshParams) error { 442 resp, err := s.client.api.RefreshSandboxWithResponse(ctx, s.sandboxID, params.toAPI()) 443 if err != nil { 444 return err 445 } 446 if resp.HTTPResponse.StatusCode != http.StatusNoContent { 447 return newAPIError(resp.HTTPResponse, resp.Body) 448 } 449 return nil 450 } 451 452 // WaitForReady 轮询 GetInfo 直到沙箱状态变为 "running" 或上下文被取消。 453 // 默认轮询间隔为 1 秒,可通过 WithPollInterval 等选项自定义。 454 func (s *Sandbox) WaitForReady(ctx context.Context, opts ...PollOption) (*SandboxInfo, error) { 455 o := defaultPollOpts(time.Second) 456 for _, fn := range opts { 457 fn(o) 458 } 459 460 return pollLoop(ctx, o, func() (bool, *SandboxInfo, error) { 461 info, err := s.GetInfo(ctx) 462 if err != nil { 463 return false, nil, fmt.Errorf("get sandbox %s: %w", s.sandboxID, err) 464 } 465 if info.State == StateRunning { 466 return true, info, nil 467 } 468 return false, nil, nil 469 }) 470 } 471 472 // CreateAndWait 创建沙箱并等待其就绪。 473 func (c *Client) CreateAndWait(ctx context.Context, params CreateParams, opts ...PollOption) (*Sandbox, *SandboxInfo, error) { 474 sb, err := c.Create(ctx, params) 475 if err != nil { 476 return nil, nil, fmt.Errorf("create sandbox: %w", err) 477 } 478 info, err := sb.WaitForReady(ctx, opts...) 479 if err != nil { 480 return nil, nil, err 481 } 482 return sb, info, nil 483 } 484 485 // GetSandboxesMetrics 返回指定沙箱 ID 列表的指标数据。 486 func (c *Client) GetSandboxesMetrics(ctx context.Context, params *GetSandboxesMetricsParams) (*SandboxesWithMetrics, error) { 487 resp, err := c.api.GetSandboxesMetricsWithResponse(ctx, params.toAPI()) 488 if err != nil { 489 return nil, err 490 } 491 if resp.JSON200 == nil { 492 return nil, newAPIError(resp.HTTPResponse, resp.Body) 493 } 494 return sandboxesWithMetricsFromAPI(resp.JSON200), nil 495 } 496 497 // Files 返回文件系统操作接口。 498 func (s *Sandbox) Files() *Filesystem { 499 s.filesOnce.Do(func() { 500 s.files = newFilesystem(s) 501 }) 502 return s.files 503 } 504 505 // Commands 返回命令执行操作接口。 506 func (s *Sandbox) Commands() *Commands { 507 s.commandsOnce.Do(func() { 508 s.commands = newCommands(s, s.processClient()) 509 }) 510 return s.commands 511 } 512 513 // Pty 返回 PTY 终端操作接口。 514 func (s *Sandbox) Pty() *Pty { 515 s.ptyOnce.Do(func() { 516 s.pty = newPty(s, s.processClient()) 517 }) 518 return s.pty 519 } 520 521 // Git 返回 git 操作接口。 522 // 沙箱内需预装 git 二进制;当前仅支持 HTTPS + username/password (token) 认证。 523 func (s *Sandbox) Git() *Git { 524 s.gitOnce.Do(func() { 525 s.git = newGit(s.Commands()) 526 }) 527 return s.git 528 } 529 530 // GetHost 返回访问沙箱指定端口的外部域名。 531 // 格式: {port}-{sandboxID}.{domain} 532 func (s *Sandbox) GetHost(port int) string { 533 if s.domain == nil || *s.domain == "" { 534 return "" 535 } 536 return fmt.Sprintf("%d-%s.%s", port, s.sandboxID, *s.domain) 537 } 538 539 // envdURL 返回 envd agent 的基础 URL。 540 func (s *Sandbox) envdURL() string { 541 return fmt.Sprintf("https://%s", s.GetHost(envdPort)) 542 } 543 544 // envdBasicAuth 返回 envd 用户身份认证的 Authorization 头值。 545 // 格式为 Basic base64(username:),仅用于 OS 用户身份标识,不含密码。 546 // envd 的访问控制通过独立的 X-Access-Token 头实现。 547 func envdBasicAuth(user string) string { 548 return "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":")) 549 } 550 551 // setEnvdAuth 将 envd 认证头设置到 ConnectRPC 请求。 552 // 设置两个独立的 header: 553 // - Authorization: Basic base64(user:) — OS 用户身份标识 554 // - X-Access-Token: <token> — envd 访问控制(仅当 token 存在时) 555 func (s *Sandbox) setEnvdAuth(req interface{ Header() http.Header }, user string) { 556 req.Header().Set("Authorization", envdBasicAuth(user)) 557 s.envdTokenMu.RLock() 558 tok := s.envdAccessToken 559 s.envdTokenMu.RUnlock() 560 if tok != nil && *tok != "" { 561 req.Header().Set("X-Access-Token", *tok) 562 } 563 } 564 565 // keepalivePingIntervalSec 是 keepalive ping 间隔(秒),与 JS SDK 保持一致。 566 // envd 服务端会按此间隔在 gRPC 流中发送 keepalive 消息,防止代理/LB 断开空闲连接。 567 const keepalivePingIntervalSec = "50" 568 569 // keepalivePingHeader 是 keepalive ping 间隔的 HTTP header 名。 570 const keepalivePingHeader = "Keepalive-Ping-Interval" 571 572 // keepaliveInterceptor 是一个 ConnectRPC 拦截器,为所有流式请求注入 Keepalive-Ping-Interval header。 573 type keepaliveInterceptor struct{} 574 575 func (keepaliveInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { 576 return next 577 } 578 579 func (keepaliveInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { 580 return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { 581 conn := next(ctx, spec) 582 conn.RequestHeader().Set(keepalivePingHeader, keepalivePingIntervalSec) 583 return conn 584 } 585 } 586 587 func (keepaliveInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { 588 return next 589 } 590 591 // setReqidHeader 从 context 中提取 reqid 并注入到 HTTP 请求头。 592 // 用于绕过 oapi-codegen 客户端的直接 HTTP 调用(如 envd API)。 593 func setReqidHeader(ctx context.Context, req *http.Request) { 594 if id, ok := reqid.ReqidFromContext(ctx); ok { 595 req.Header.Set("X-Reqid", id) 596 } 597 } 598 599 // FileURLOption 文件 URL 选项。 600 type FileURLOption func(*fileURLOpts) 601 602 type fileURLOpts struct { 603 user string 604 signatureExpiration int 605 } 606 607 // WithFileUser 设置文件操作的用户。 608 func WithFileUser(user string) FileURLOption { 609 return func(o *fileURLOpts) { o.user = user } 610 } 611 612 // WithSignatureExpiration 设置签名过期时间(秒)。 613 func WithSignatureExpiration(seconds int) FileURLOption { 614 return func(o *fileURLOpts) { o.signatureExpiration = seconds } 615 } 616 617 // fileSignature 计算文件操作签名。 618 // 算法: "v1_" + SHA256(path + ":" + operation + ":" + username + ":" + accessToken + ":" + expiration) 619 // 620 // 注意: 此签名算法由后端服务定义,SDK 端需与服务端保持一致,不可单独修改。 621 // 当前算法未使用 HMAC,存在已知 accessToken 情况下的签名伪造风险, 622 // 后续安全加固需由服务端统一推进。 623 // 624 // 当前使用 ":" 作为字段分隔符,若 path 或 username 包含 ":",可能导致签名碰撞。 625 // 此问题需由后端统一修复(如切换到不可见分隔符或对字段做转义)。 626 func fileSignature(path, operation, username, accessToken string, expiration int) string { 627 raw := fmt.Sprintf("%s:%s:%s:%s:%d", path, operation, username, accessToken, expiration) 628 hash := sha256.Sum256([]byte(raw)) 629 return "v1_" + fmt.Sprintf("%x", hash) 630 } 631 632 // DownloadURL 返回从沙箱下载文件的 URL。 633 func (s *Sandbox) DownloadURL(path string, opts ...FileURLOption) string { 634 return s.fileURL(path, "read", opts...) 635 } 636 637 // UploadURL 返回向沙箱上传文件的 URL(POST multipart/form-data)。 638 func (s *Sandbox) UploadURL(path string, opts ...FileURLOption) string { 639 return s.fileURL(path, "write", opts...) 640 } 641 642 // fileURL 构造带签名的 envd 文件操作 URL。 643 func (s *Sandbox) fileURL(path, operation string, opts ...FileURLOption) string { 644 o := &fileURLOpts{user: DefaultUser} 645 for _, fn := range opts { 646 fn(o) 647 } 648 649 q := url.Values{} 650 q.Set("path", path) 651 q.Set("username", o.user) 652 653 s.envdTokenMu.RLock() 654 tok := s.envdAccessToken 655 s.envdTokenMu.RUnlock() 656 if tok != nil && *tok != "" { 657 exp := o.signatureExpiration 658 if exp == 0 { 659 exp = 300 660 } 661 sig := fileSignature(path, operation, o.user, *tok, exp) 662 q.Set("signature", sig) 663 q.Set("signature_expiration", strconv.Itoa(exp)) 664 } 665 666 return s.envdURL() + "/files?" + q.Encode() 667 } 668 669 // batchUploadURL 返回批量上传文件的 URL。 670 // 与 UploadURL 不同,不设置 path 查询参数,文件路径由 multipart part filename 提供。 671 func (s *Sandbox) batchUploadURL(user string) string { 672 q := url.Values{} 673 q.Set("username", user) 674 return s.envdURL() + "/files?" + q.Encode() 675 } 676 677 // newIdempotencyKey 生成一个 UUID v4 格式的幂等键。 678 func newIdempotencyKey() string { 679 return uuid.New().String() 680 } 681 682 // isRetryableStatusCode 判断 HTTP 状态码是否可重试。 683 // 可重试:408(超时)及 5xx 服务端错误(排除 501)。 684 func isRetryableStatusCode(code int) bool { 685 if code == 408 { 686 return true 687 } 688 return code >= 500 && code != 501 689 } 690 691 // isRetryableError 判断错误是否为网络层面的可重试错误。 692 func isRetryableError(err error) bool { 693 if err == nil { 694 return false 695 } 696 var netErr net.Error 697 if errors.As(err, &netErr) && netErr.Timeout() { 698 return true 699 } 700 var opErr *net.OpError 701 if errors.As(err, &opErr) { 702 return true 703 } 704 s := err.Error() 705 return strings.Contains(s, "connection refused") || 706 strings.Contains(s, "connection reset") || 707 strings.Contains(s, "broken pipe") || 708 strings.Contains(s, "no such host") || 709 strings.Contains(s, "unexpected EOF") || 710 strings.Contains(s, "use of closed network connection") 711 } 712 713 // isRetryable 判断错误是否可重试:APIError 按状态码判断,其余按网络错误判断。 714 func isRetryable(err error) bool { 715 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { 716 return false 717 } 718 var apiErr *APIError 719 if errors.As(err, &apiErr) { 720 return isRetryableStatusCode(apiErr.StatusCode) 721 } 722 return isRetryableError(err) 723 }