Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/project-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,21 +378,21 @@ entry. Rebuild the frontend to change values embedded in browser assets.

Declare an existing template by name. `memory_mb` and `ports` assert the deployed
image settings; changing them requires a new source deployment. Configuration
apply updates `idle_timeout_seconds` and `ttl_seconds` for new sessions.
apply updates `ttl_seconds`, the default VM lifetime for new sessions.

```yaml
version: 1
sandboxes:
- name: local-custom
memory_mb: 1024
ports: [8080]
idle_timeout_seconds: 90
ttl_seconds: 600
```

Preview with `volcano config deploy --dry-run`, apply with `volcano config deploy`,
and export with `volcano config pull --force`. Add `cloud` after `volcano` for a
cloud project. Omitted fields preserve current settings; an explicit idle timeout
of `0` disables idle expiration. TTL must be 30–28800 seconds, and a nonzero idle
timeout must not exceed it. Config apply does not build images or delete templates
cloud project. Omitted fields preserve current settings. Cloud TTL must be
30–28800 seconds. Local mode accepts `0` for unlimited lifetime or a positive TTL.
Comment on lines +394 to +395

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor unlimited template lifetimes in local sessions

When a local template is configured with the newly supported ttl_seconds: 0, volcano sandboxes run --template ... still initializes --duration to 3600 and always sends it as MaxDurationSeconds (internal/cmd/sandboxes/exec.go:155-177), overriding the template default; explicitly passing --duration 0 is also rejected. Thus the advertised unlimited lifetime cannot be used by the CLI's session-creation command. Leave the duration omitted unless the flag was supplied, and validate zero according to local/cloud mode.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

`idle_timeout_seconds` is no longer supported; remove it from existing manifests.
Config apply does not build images or delete templates
omitted from the manifest. See [Sandbox source deployment](sandboxes.md).
7 changes: 6 additions & 1 deletion docs/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ shell variables and `cd` changes do not. `exit` or EOF detaches without terminat
the session. Full-screen programs such as `vim` require a PTY, which this prompt
does not provide. Always terminate sessions you no longer need.

Without `--duration`, `run` inherits the template's `ttl_seconds`. The default
is 3600 seconds in cloud mode and unlimited in local mode. To override a finite
template with an unlimited local session, use
`volcano sandboxes run --template TEMPLATE_ID --duration 0`.

`exec` requires `--` before the command. Each argument is shell-quoted. For shell syntax such as pipes or background processes, explicitly invoke `sh -c`:

```bash
Expand Down Expand Up @@ -84,7 +89,7 @@ A template saves a preset and memory size. Custom image builds are not supported
| `--memory` | `exec`, `run` | Override memory with 1024 or 2048 MB; otherwise inherit the preset or template |
| `--region` | `exec`, `run` | Select a region; defaults to `us-east-1` |
| `--timeout` | `exec` | Command deadline: 1–60 seconds for one-shot execution, 1–3600 for session execution; defaults to 60 |
| `--duration` | `run` | Maximum session lifetime: 30–28800 seconds; defaults to 3600 |
| `--duration` | `run` | Override session lifetime in seconds: cloud 30–28800; local 0 (unlimited) or positive. Omitted inherits the template |
| `--request-id` | `exec`, `run` | UUID used to retry the same request safely |
| `--json` | All except `deployments source` | Print compact JSON; `exec`, `shell`, and `files read` return structured results |

Expand Down
266 changes: 210 additions & 56 deletions internal/apiclient/client.gen.go

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions internal/cmd/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ sandboxes:
- name: local-custom
memory_mb: 1024
ports: [8080]
idle_timeout_seconds: 0
ttl_seconds: 600
`), 0o644))

Expand Down Expand Up @@ -148,7 +147,7 @@ sandboxes:
assert.Equal(t, false, realtime["enabled"])
sandboxes, err := json.Marshal(uploaded["sandboxes"])
require.NoError(t, err)
assert.JSONEq(t, `[{"name":"local-custom","memory_mb":1024,"ports":[8080],"idle_timeout_seconds":0,"ttl_seconds":600}]`, string(sandboxes))
assert.JSONEq(t, `[{"name":"local-custom","memory_mb":1024,"ports":[8080],"ttl_seconds":600}]`, string(sandboxes))

assert.Contains(t, out, "Configuration deployed from volcano-config.yaml")
assert.Contains(t, out, "variables: 1 created, 1 deleted")
Expand Down
16 changes: 11 additions & 5 deletions internal/cmd/sandboxes/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,20 @@ func (c *commands) outputCommand(cmd *cobra.Command, result apiclient.SandboxCom
func (c *commands) run() *cobra.Command {
var opts launchOptions
cmd := &cobra.Command{Use: "run", Short: "Start a persistent session", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
if opts.duration < 30 || opts.duration > 28800 {
return errors.New("--duration must be between 30 and 28800 seconds")
}
request, key, err := opts.request()
if err != nil {
return err
}
request.MaxDurationSeconds = &opts.duration
if cmd.Flags().Changed("duration") {
if c.local {
if opts.duration < 0 {
return errors.New("--duration must be zero (unlimited) or positive for local sessions")
}
} else if opts.duration < 30 || opts.duration > 28800 {
return errors.New("--duration must be between 30 and 28800 seconds for cloud sessions")
}
request.MaxDurationSeconds = &opts.duration
}
project, err := c.project()
if err != nil {
return err
Expand All @@ -174,7 +180,7 @@ func (c *commands) run() *cobra.Command {
return c.write(cmd, value)
}}
opts.flags(cmd)
cmd.Flags().IntVar(&opts.duration, "duration", 3600, "Maximum session lifetime in seconds")
cmd.Flags().IntVar(&opts.duration, "duration", 0, "Maximum session lifetime in seconds; omitted inherits the template (cloud: 30–28800; local: 0 for unlimited or positive)")
return cmd
}

Expand Down
77 changes: 77 additions & 0 deletions internal/cmd/sandboxes/run_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package sandboxes

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestRunSessionLifetime(t *testing.T) {
t.Parallel()
for _, local := range []bool{false, true} {
mode := map[bool]string{false: "cloud", true: "local"}[local]
for _, selector := range []string{"preset", "template"} {
for _, duration := range []string{"", "-1", "0", "1", "29", "30", "300", "28800", "28801"} {
t.Run(mode+"/"+selector+"/duration="+duration, func(t *testing.T) {
t.Parallel()
seconds, _ := strconv.Atoi(duration)
valid := duration == "" || (local && seconds >= 0) || (!local && seconds >= 30 && seconds <= 28800)
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
assert.True(t, valid, "invalid duration must not reach transport")
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "/projects/"+testProject+"/sandbox-sessions", r.URL.Path)
var body map[string]any
decoder := json.NewDecoder(r.Body)
decoder.UseNumber()
if err := decoder.Decode(&body); !assert.NoError(t, err) {
w.WriteHeader(http.StatusBadRequest)
return
}
if duration == "" {
assert.NotContains(t, body, "max_duration_seconds", "inherit the template lifetime")
} else {
assert.Equal(t, json.Number(duration), body["max_duration_seconds"])
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"` + testSession + `","expires_at":null}`))
}))
defer server.Close()
deps := testDeps(server)
cmd := New(deps)
if local {
// Local Sandbox APIs authenticate, so deps.LocalMode remains false.
cmd = NewLocal(deps, deps)
}
value := "node22"
if selector == "template" {
value = testSession
}
args := []string{"run", "--" + selector, value}
if duration != "" {
args = append(args, "--duration", duration)
}
cmd.SetArgs(args)
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
err := cmd.Execute()
if valid {
require.NoError(t, err)
assert.Equal(t, 1, requests)
} else {
require.ErrorContains(t, err, "--duration")
assert.Zero(t, requests)
}
})
}
}
}
}
9 changes: 5 additions & 4 deletions internal/cmd/sandboxes/sandboxes.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,22 @@ type commands struct {
deps cliruntime.Deps
usageDeps cliruntime.Deps
json bool
local bool
}

// New returns the Sandbox command tree.
func New(deps cliruntime.Deps) *cobra.Command {
return newCommands(deps, deps)
return newCommands(deps, deps, false)
}

// NewLocal uses authenticated local user credentials for project usage and
// service-role credentials for the Sandbox session APIs.
func NewLocal(deps, usageDeps cliruntime.Deps) *cobra.Command {
return newCommands(deps, usageDeps)
return newCommands(deps, usageDeps, true)
}

func newCommands(deps, usageDeps cliruntime.Deps) *cobra.Command {
c := &commands{deps: deps, usageDeps: usageDeps}
func newCommands(deps, usageDeps cliruntime.Deps, local bool) *cobra.Command {
c := &commands{deps: deps, usageDeps: usageDeps, local: local}
cmd := &cobra.Command{Use: "sandboxes", Short: "Run isolated commands and manage temporary sessions"}
cmd.PersistentFlags().BoolVar(&c.json, "json", false, "Print compact JSON; use structured results for exec, shell, and files read")
cmd.AddCommand(c.presets(), c.usage(), c.execute(), c.shell(), c.run(), c.sessions(), c.get(), c.control("suspend"), c.control("resume"), c.control("terminate"), c.files(), c.templates(), c.deployments())
Expand Down
11 changes: 5 additions & 6 deletions internal/projectconfig/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,12 @@ type Manifest struct {

// SandboxManifest declares image assertions and default session settings for an
// existing Sandbox template. Nil fields preserve existing values; explicit
// zero idle timeout and empty ports remain present during round trips.
// zero TTL and empty ports remain present during round trips.
type SandboxManifest struct {
Name string `yaml:"name" json:"name"`
MemoryMB *int `yaml:"memory_mb,omitempty" json:"memory_mb,omitempty"`
Ports *[]int `yaml:"ports,omitempty" json:"ports,omitempty"`
IdleTimeoutSeconds *int `yaml:"idle_timeout_seconds,omitempty" json:"idle_timeout_seconds,omitempty"`
TTLSeconds *int `yaml:"ttl_seconds,omitempty" json:"ttl_seconds,omitempty"`
Name string `yaml:"name" json:"name"`
MemoryMB *int `yaml:"memory_mb,omitempty" json:"memory_mb,omitempty"`
Ports *[]int `yaml:"ports,omitempty" json:"ports,omitempty"`
TTLSeconds *int `yaml:"ttl_seconds,omitempty" json:"ttl_seconds,omitempty"`
}

// ProjectManifest declares project-level settings.
Expand Down
10 changes: 7 additions & 3 deletions internal/projectconfig/manifest_sandboxes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,12 @@ sandboxes:
- name: local-custom
memory_mb: 1024
ports: [8080]
idle_timeout_seconds: 90
ttl_seconds: 600
`), noEnv)
require.NoError(t, err)
body, err := manifest.uploadBody()
require.NoError(t, err)
require.JSONEq(t, `{"version":1,"sandboxes":[{"name":"local-custom","memory_mb":1024,"ports":[8080],"idle_timeout_seconds":90,"ttl_seconds":600}]}`, string(body))
require.JSONEq(t, `{"version":1,"sandboxes":[{"name":"local-custom","memory_mb":1024,"ports":[8080],"ttl_seconds":600}]}`, string(body))
var wire apiclient.ProjectConfig
require.NoError(t, json.Unmarshal(body, &wire))
require.NotNil(t, wire.Sandboxes)
Expand All @@ -46,7 +45,7 @@ func TestSandboxManifestPreservesOmittedAndExplicitValues(t *testing.T) {
"omitted section": {"version: 1\n", `{"version":1}`},
"empty section": {"version: 1\nsandboxes: []\n", `{"version":1,"sandboxes":[]}`},
"omitted settings": {"version: 1\nsandboxes:\n - name: custom\n", `{"version":1,"sandboxes":[{"name":"custom"}]}`},
"zero and empty settings": {"version: 1\nsandboxes:\n - name: custom\n ports: []\n idle_timeout_seconds: 0\n", `{"version":1,"sandboxes":[{"name":"custom","ports":[],"idle_timeout_seconds":0}]}`},
"zero and empty settings": {"version: 1\nsandboxes:\n - name: custom\n ports: []\n ttl_seconds: 0\n", `{"version":1,"sandboxes":[{"name":"custom","ports":[],"ttl_seconds":0}]}`},
} {
t.Run(name, func(t *testing.T) {
manifest, err := Parse([]byte(example.yaml), noEnv)
Expand All @@ -69,3 +68,8 @@ func TestSandboxManifestRejectsUnknownFields(t *testing.T) {
_, err := Parse([]byte("version: 1\nsandboxes:\n - name: custom\n ttl_second: 600\n"), noEnv)
require.ErrorContains(t, err, "field ttl_second not found")
}

func TestSandboxManifestRejectsRemovedIdleTimeout(t *testing.T) {
_, err := Parse([]byte("version: 1\nsandboxes:\n - name: custom\n idle_timeout_seconds: 90\n"), noEnv)
require.ErrorContains(t, err, "field idle_timeout_seconds not found")
}
Loading
Loading