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  }