From 02f72a0674de33f424ea4b22719e7a285ea74892 Mon Sep 17 00:00:00 2001 From: Leonardo Barcaroli Date: Fri, 8 May 2026 11:59:23 +0200 Subject: [PATCH 1/2] Update API library --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 98e840f7..48ed3f0b 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/gofrs/uuid v4.3.0+incompatible github.com/gorilla/websocket v1.5.0 github.com/iancoleman/strcase v0.2.0 - github.com/koyeb/koyeb-api-client-go v0.0.0-20260220105029-a97ddcaa1e92 + github.com/koyeb/koyeb-api-client-go v0.0.0-20260508095705-49c1b251a0af github.com/logrusorgru/aurora v2.0.3+incompatible github.com/manifoldco/promptui v0.9.0 github.com/mitchellh/go-homedir v1.1.0 diff --git a/go.sum b/go.sum index fb896651..bf7f79aa 100644 --- a/go.sum +++ b/go.sum @@ -168,8 +168,8 @@ github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/koyeb/koyeb-api-client-go v0.0.0-20260220105029-a97ddcaa1e92 h1:M3sDodjP5akaa2xtQytg8XJHqIilnd34KPrkrUN9kPQ= -github.com/koyeb/koyeb-api-client-go v0.0.0-20260220105029-a97ddcaa1e92/go.mod h1:+oQfFj2WL3gi9Pb+UHbob4D7xaT52mPfKyH1UvWa4PQ= +github.com/koyeb/koyeb-api-client-go v0.0.0-20260508095705-49c1b251a0af h1:oOiuGhmDqxiwrFMu2KJty5zXHOP6D6KgvvxQKdy49gM= +github.com/koyeb/koyeb-api-client-go v0.0.0-20260508095705-49c1b251a0af/go.mod h1:+oQfFj2WL3gi9Pb+UHbob4D7xaT52mPfKyH1UvWa4PQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= From 5362551baebcc95b474daebc3fa76d017af6a8d5 Mon Sep 17 00:00:00 2001 From: Leonardo Barcaroli Date: Fri, 8 May 2026 15:47:36 +0200 Subject: [PATCH 2/2] Use public_base_url and routing_key if provided Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- pkg/koyeb/sandbox.go | 57 ++++++++++++++++++++++++++++++------ pkg/koyeb/sandbox_client.go | 41 ++++++++++++++++++++++---- pkg/koyeb/sandbox_fs.go | 14 ++++----- pkg/koyeb/sandbox_port.go | 4 +-- pkg/koyeb/sandbox_process.go | 8 ++--- pkg/koyeb/sandbox_run.go | 6 ++-- 6 files changed, 99 insertions(+), 31 deletions(-) diff --git a/pkg/koyeb/sandbox.go b/pkg/koyeb/sandbox.go index 64213a50..7f1a4c4a 100644 --- a/pkg/koyeb/sandbox.go +++ b/pkg/koyeb/sandbox.go @@ -1,7 +1,6 @@ package koyeb import ( - "encoding/base64" "fmt" "strconv" "strings" @@ -286,6 +285,29 @@ type SandboxInfo struct { Domain string SandboxSecret string ProxyPort string // The external proxy port (from health check) + // BaseURL, when non-empty, is the sandbox API base URL derived from + // DeploymentMetadata.Sandbox.PublicUrl (with the "/koyeb-sandbox" path + // appended). When set, it takes precedence over Domain for client + // construction. + BaseURL string + // RoutingKey, when non-empty, is the value of + // DeploymentMetadata.Sandbox.RoutingKey. It is sent as the X-Routing-Key + // header on every sandbox HTTP request, in addition to the Authorization + // Bearer token. + RoutingKey string +} + +// NewClient creates a SandboxClient using the connection information in i. +// When BaseURL is set (provided by the API via DeploymentMetadata.Sandbox), +// it is used as-is and the RoutingKey is sent as the X-Routing-Key header. +// Otherwise the legacy mechanism (https://{Domain}/koyeb-sandbox) is used. +// In all cases the SandboxSecret is sent as a Bearer token in the +// Authorization header. +func (i *SandboxInfo) NewClient(opts ...SandboxClientOption) *SandboxClient { + if i.BaseURL != "" { + return newSandboxClientFromBaseURL(i.BaseURL, i.SandboxSecret, i.RoutingKey, opts...) + } + return NewSandboxClient(i.Domain, i.SandboxSecret, opts...) } // ValidatePort checks if a port number is valid @@ -440,6 +462,10 @@ func (h *SandboxHandler) fetchSandboxInfo(ctx *CLIContext, name string) (*Sandbo } deployment := deploymentRes.GetDeployment() + + // SANDBOX_SECRET is always required: it is sent as a Bearer token in the + // Authorization header on every sandbox HTTP request, regardless of + // whether DeploymentMetadata.Sandbox is provided by the API. definition := deployment.GetDefinition() envVars := definition.GetEnv() @@ -460,19 +486,32 @@ func (h *SandboxHandler) fetchSandboxInfo(ctx *CLIContext, name string) (*Sandbo } } - // The API returns env var values base64-encoded - decoded, err := base64.StdEncoding.DecodeString(sandboxSecret) - if err != nil { - // If it's not valid base64, use the value as-is - decoded = []byte(sandboxSecret) + // When the API exposes DeploymentMetadata.Sandbox, prefer its values: + // PublicUrl provides the host to reach (the "/koyeb-sandbox" path is + // still appended), and RoutingKey is sent in the X-Routing-Key header in + // addition to the Bearer token above. + var ( + baseURL string + routingKey string + ) + if deployment.HasMetadata() { + metadata := deployment.GetMetadata() + if metadata.HasSandbox() { + sandbox := metadata.GetSandbox() + if publicURL := sandbox.GetPublicUrl(); publicURL != "" { + baseURL = strings.TrimRight(publicURL, "/") + "/koyeb-sandbox" + } + routingKey = sandbox.GetRoutingKey() + } } - sandboxSecret = string(decoded) return &SandboxInfo{ ServiceID: serviceID, AppID: appID, Domain: domain, SandboxSecret: sandboxSecret, + BaseURL: baseURL, + RoutingKey: routingKey, }, nil } @@ -497,7 +536,7 @@ func (h *SandboxHandler) GetClientWithHealthCheck(ctx *CLIContext, sandboxName s return nil, nil, err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() // Perform health check health, err := client.Health(ctx.Context) @@ -537,7 +576,7 @@ func (h *SandboxHandler) Health(ctx *CLIContext, cmd *cobra.Command, args []stri return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() health, err := client.Health(ctx.Context) if err != nil { diff --git a/pkg/koyeb/sandbox_client.go b/pkg/koyeb/sandbox_client.go index c2044081..c007be14 100644 --- a/pkg/koyeb/sandbox_client.go +++ b/pkg/koyeb/sandbox_client.go @@ -39,8 +39,15 @@ var _ SandboxClientInterface = (*SandboxClient)(nil) // SandboxClient provides HTTP client for sandbox API type SandboxClient struct { - baseURL string - secret string + baseURL string + // secret is sent as a Bearer token in the Authorization header. It is + // always read from the SANDBOX_SECRET environment variable on the + // sandbox. + secret string + // routingKey, when non-empty, is sent in the X-Routing-Key header in + // addition to the Authorization header. It is provided by the API via + // DeploymentMetadata.Sandbox.RoutingKey. + routingKey string httpClient *http.Client maxRetries int retryDelay time.Duration @@ -72,13 +79,34 @@ func WithStreamTimeout(timeout time.Duration) SandboxClientOption { } } -// NewSandboxClient creates a new sandbox client +// NewSandboxClient creates a new sandbox client. The base URL is derived from +// the given domain by appending the legacy "/koyeb-sandbox" path. The secret +// is sent as a Bearer token in the Authorization header. This is the fallback +// used when DeploymentMetadata.Sandbox is not provided by the API. func NewSandboxClient(domain, secret string, opts ...SandboxClientOption) *SandboxClient { + return newSandboxClient(fmt.Sprintf("https://%s/koyeb-sandbox", domain), secret, "", opts...) +} + +// newSandboxClientFromBaseURL creates a sandbox client using an explicit base +// URL provided by the API via DeploymentMetadata.Sandbox.PublicUrl. The +// secret is still sent as a Bearer token in the Authorization header; the +// routingKey, when non-empty, is sent in the X-Routing-Key header in addition +// to the Authorization header. +func newSandboxClientFromBaseURL(baseURL, secret, routingKey string, opts ...SandboxClientOption) *SandboxClient { + return newSandboxClient(baseURL, secret, routingKey, opts...) +} + +func newSandboxClient(baseURL, secret, routingKey string, opts ...SandboxClientOption) *SandboxClient { c := &SandboxClient{ - baseURL: fmt.Sprintf("https://%s/koyeb-sandbox", domain), - secret: secret, + baseURL: strings.TrimRight(baseURL, "/"), + secret: secret, + routingKey: routingKey, httpClient: &http.Client{ Timeout: 2 * time.Minute, + // Wrap with DebugTransport so requests/responses to the + // sandbox base URL are dumped when the -d/--debug flag is set, + // matching the behavior of the main API client. + Transport: &DebugTransport{http.DefaultTransport}, }, maxRetries: 3, retryDelay: time.Second, @@ -145,6 +173,9 @@ func (c *SandboxClient) doRequest(ctx context.Context, method, path string, body } req.Header.Set("Authorization", "Bearer "+c.secret) + if c.routingKey != "" { + req.Header.Set("X-Routing-Key", c.routingKey) + } req.Header.Set("Content-Type", "application/json") return c.httpClient.Do(req) diff --git a/pkg/koyeb/sandbox_fs.go b/pkg/koyeb/sandbox_fs.go index 9510cc42..42618a4b 100644 --- a/pkg/koyeb/sandbox_fs.go +++ b/pkg/koyeb/sandbox_fs.go @@ -27,7 +27,7 @@ func (h *SandboxHandler) FsRead(ctx *CLIContext, cmd *cobra.Command, args []stri return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() content, err := client.ReadFile(ctx.Context, path) if err != nil { @@ -91,7 +91,7 @@ func (h *SandboxHandler) FsWrite(ctx *CLIContext, cmd *cobra.Command, args []str return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() err = client.WriteFile(ctx.Context, path, content) if err != nil { @@ -131,7 +131,7 @@ func (h *SandboxHandler) FsLs(ctx *CLIContext, cmd *cobra.Command, args []string return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() entries, err := client.ListDir(ctx.Context, path) if err != nil { @@ -193,7 +193,7 @@ func (h *SandboxHandler) FsMkdir(ctx *CLIContext, cmd *cobra.Command, args []str return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() err = client.MakeDir(ctx.Context, path) if err != nil { @@ -240,7 +240,7 @@ func (h *SandboxHandler) FsRm(ctx *CLIContext, cmd *cobra.Command, args []string return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() if recursive { err = client.DeleteDir(ctx.Context, path) @@ -286,7 +286,7 @@ func (h *SandboxHandler) FsUpload(ctx *CLIContext, cmd *cobra.Command, args []st if err != nil { return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() if fileInfo.IsDir() { if !recursive { @@ -475,7 +475,7 @@ func (h *SandboxHandler) FsDownload(ctx *CLIContext, cmd *cobra.Command, args [] return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() content, err := client.ReadFile(ctx.Context, remotePath) if err != nil { diff --git a/pkg/koyeb/sandbox_port.go b/pkg/koyeb/sandbox_port.go index fb63381b..43e00bca 100644 --- a/pkg/koyeb/sandbox_port.go +++ b/pkg/koyeb/sandbox_port.go @@ -24,7 +24,7 @@ func (h *SandboxHandler) ExposePort(ctx *CLIContext, cmd *cobra.Command, args [] return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() result, err := client.BindPort(ctx.Context, fmt.Sprintf("%d", port)) if err != nil { @@ -77,7 +77,7 @@ func (h *SandboxHandler) UnexposePort(ctx *CLIContext, cmd *cobra.Command, args return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() result, err := client.UnbindPort(ctx.Context) if err != nil { diff --git a/pkg/koyeb/sandbox_process.go b/pkg/koyeb/sandbox_process.go index 35f6e789..588a2ae8 100644 --- a/pkg/koyeb/sandbox_process.go +++ b/pkg/koyeb/sandbox_process.go @@ -48,7 +48,7 @@ func (h *SandboxHandler) StartProcess(ctx *CLIContext, cmd *cobra.Command, args return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() req := &ProcessRequest{ Cmd: command, @@ -84,7 +84,7 @@ func (h *SandboxHandler) ListProcesses(ctx *CLIContext, cmd *cobra.Command, args return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() processes, err := client.ListProcesses(ctx.Context) if err != nil { @@ -118,7 +118,7 @@ func (h *SandboxHandler) KillProcess(ctx *CLIContext, cmd *cobra.Command, args [ return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() err = client.KillProcess(ctx.Context, processID) if err != nil { @@ -155,7 +155,7 @@ func (h *SandboxHandler) ProcessLogs(ctx *CLIContext, cmd *cobra.Command, args [ return err } - client := NewSandboxClient(info.Domain, info.SandboxSecret) + client := info.NewClient() if follow { log.Info("Streaming logs (press Ctrl+C to stop)...") diff --git a/pkg/koyeb/sandbox_run.go b/pkg/koyeb/sandbox_run.go index fe2ae575..6c564d35 100644 --- a/pkg/koyeb/sandbox_run.go +++ b/pkg/koyeb/sandbox_run.go @@ -73,10 +73,8 @@ func (h *SandboxHandler) Run(ctx *CLIContext, cmd *cobra.Command, args []string) return err } - client := NewSandboxClient( - info.Domain, - info.SandboxSecret, - WithTimeout(time.Duration(timeout)*time.Second), + client := info.NewClient( + WithTimeout(time.Duration(timeout) * time.Second), ) req := &RunRequest{