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
2 changes: 1 addition & 1 deletion api/compute/rpc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ interfaces:
parameters:
- name: app
type: string
doc: Empty lists all apps for unscoped callers, or the bound app for app-scoped callers.
doc: Empty lists all apps for unscoped callers, or the bound app for app-scoped callers. Otherwise matches the app each pool reports, which for addon pools comes from their app label.
results:
- name: pools
type: list
Expand Down
21 changes: 16 additions & 5 deletions cli/commands/sandbox_pool_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"fmt"
"slices"
"time"

"miren.dev/runtime/api/compute/compute_v1alpha"
Expand Down Expand Up @@ -29,6 +30,8 @@ type sandboxPoolListEntry struct {
}

func SandboxPoolList(ctx *Context, opts struct {
App string `long:"app" description:"Only show pools belonging to this app"`
Service string `long:"service" description:"Only show pools of this service (e.g. web, worker)"`
FormatOptions
ConfigCentric
}) error {
Expand All @@ -42,18 +45,25 @@ func SandboxPoolList(ctx *Context, opts struct {
}
var pools []*compute_v1alpha.SandboxPoolInfo
if err == nil {
res, callErr := compute_v1alpha.NewSandboxPoolsClient(client).List(ctx, "")
res, callErr := compute_v1alpha.NewSandboxPoolsClient(client).List(ctx, opts.App)
err = callErr
if err == nil {
pools = res.Pools()
}
}
if isMissingRPC(err) {
pools, err = sandboxPoolsLegacy(ctx)
// Servers without the RPC cannot filter by app.
pools = slices.DeleteFunc(pools, func(pool *compute_v1alpha.SandboxPoolInfo) bool {
return opts.App != "" && pool.App() != opts.App
})
}
if err != nil {
return err
}
pools = slices.DeleteFunc(pools, func(pool *compute_v1alpha.SandboxPoolInfo) bool {
return opts.Service != "" && pool.Service() != opts.Service
})

if opts.IsJSON() {
entries := make([]sandboxPoolListEntry, 0, len(pools))
Expand All @@ -71,7 +81,7 @@ func SandboxPoolList(ctx *Context, opts struct {
}

var rows []ui.Row
headers := []string{"ID", "VERSION", "SERVICE", "MODE", "DESIRED", "CURRENT", "READY", "CREATED", "UPDATED"}
headers := []string{"ID", "APP", "VERSION", "SERVICE", "MODE", "DESIRED", "CURRENT", "READY", "CREATED", "UPDATED"}

for _, pool := range pools {
versionDisplay := ui.DisplayAppVersion(pool.Version())
Expand All @@ -81,8 +91,9 @@ func SandboxPoolList(ctx *Context, opts struct {

rows = append(rows, ui.Row{
ui.DisplayShortID(pool.ShortId(), pool.Id()),
versionDisplay,
pool.Service(),
orDash(pool.App()),
orDash(versionDisplay),
orDash(pool.Service()),
pool.ScalingMode(),
fmt.Sprintf("%d", pool.DesiredInstances()),
fmt.Sprintf("%d", pool.CurrentInstances()),
Expand All @@ -93,7 +104,7 @@ func SandboxPoolList(ctx *Context, opts struct {
}

if len(rows) == 0 {
ctx.Printf("No sandbox pools found\n")
ctx.Printf("No sandbox pools found%s\n", sandboxFilter{app: opts.App, service: opts.Service}.describe())
return nil
}

Expand Down
152 changes: 95 additions & 57 deletions cli/commands/sandbox_pool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"path/filepath"
"slices"
"strings"
"testing"

"github.com/stretchr/testify/require"
"miren.dev/runtime/api/app/app_v1alpha"
"miren.dev/runtime/api/compute/compute_v1alpha"
"miren.dev/runtime/api/core/core_v1alpha"
"miren.dev/runtime/api/entityserver"
"miren.dev/runtime/api/entityserver/entityserver_v1alpha"
"miren.dev/runtime/pkg/entity"
"miren.dev/runtime/pkg/entity/testutils"
"miren.dev/runtime/pkg/entity/types"
"miren.dev/runtime/pkg/rpc"
appserver "miren.dev/runtime/servers/app"
sandboxserver "miren.dev/runtime/servers/sandbox"
Expand Down Expand Up @@ -45,16 +47,18 @@ func TestAppPoolSizeSelectsOnlyActiveService(t *testing.T) {
server.Server().ExposeValue("dev.miren.runtime/app", app_v1alpha.AdaptCrud(&appserver.AppInfo{Log: slog.Default(), EC: store.Client}))
server.Server().ExposeValue("dev.miren.runtime/sandbox-pools", compute_v1alpha.AdaptSandboxPools(sandboxserver.NewPoolServer(store.Client)))
var stdout bytes.Buffer
ctx := &Context{Context: t.Context(), Log: slog.Default(), Stdout: &stdout, Stderr: io.Discard}
ctx.Config.ServerAddress = server.ListenAddr()
base := []string{"--config", filepath.Join(t.TempDir(), "config.yaml"), "--server-address", server.ListenAddr(), "--app", "shop"}
run := func(size, service string, json bool) error {
stdout.Reset()
return AppPoolSize(ctx, struct {
AppCentric
FormatOptions
Size string `position:"0" usage:"Desired instance count (omit to show current size)"`
Service string `short:"s" long:"service" description:"Service to scale" default:"web"`
}{AppCentric: AppCentric{App: "shop"}, FormatOptions: FormatOptions{JSON: json}, Size: size, Service: service})
args := append(slices.Clone(base), "--service", service)
if json {
args = append(args, "--json")
}
if size != "" {
args = append(args, "--", size)
}
out, err := RunCommand(AppPoolSize, args...)
stdout = out.Stdout
return err
}
require.NoError(t, run("5", "web", true))
var result map[string]any
Expand Down Expand Up @@ -141,30 +145,27 @@ func TestSandboxPoolCommandsWithoutEntityAccess(t *testing.T) {
// exclusively through the safe operation-specific boundary.
server.Server().ExposeValue("dev.miren.runtime/sandbox-pools", compute_v1alpha.AdaptSandboxPools(sandboxserver.NewPoolServer(ec)))
var stdout bytes.Buffer
ctx := &Context{Context: t.Context(), Log: slog.Default(), Stdout: &stdout, Stderr: io.Discard}
ctx.Config.ServerAddress = server.ListenAddr()
list := func(format FormatOptions) error {
stdout.Reset()
return SandboxPoolList(ctx, struct {
FormatOptions
ConfigCentric
}{FormatOptions: format})
base := []string{"--config", filepath.Join(t.TempDir(), "config.yaml"), "--server-address", server.ListenAddr()}
list := func(format ...string) error {
out, err := RunCommand(SandboxPoolList, append(slices.Clone(base), format...)...)
stdout = out.Stdout
return err
}
setDesired := func(id, desired string, raw bool) error {
stdout.Reset()
return SandboxPoolSetDesired(ctx, struct {
RawID bool `long:"raw-id" description:"Use the provided ID as-is without adding the pool/ prefix"`
PoolID string `position:"0" usage:"Pool ID (e.g., pool-CUSkT8J58BmgkDeGyPP2e or pool/pool-CUSkT8J58BmgkDeGyPP2e)" required:"true"`
Desired string `position:"1" usage:"Desired instance count (absolute number, +N to increase, or -N to decrease)" required:"true"`
ConfigCentric
}{PoolID: id, Desired: desired, RawID: raw})
args := slices.Clone(base)
if raw {
args = append(args, "--raw-id")
}
out, err := RunCommand(SandboxPoolSetDesired, append(args, "--", id, desired)...)
stdout = out.Stdout
return err
}
require.NoError(t, list(FormatOptions{}))
for _, text := range []string{"ID", "VERSION", "SERVICE", "MODE", "DESIRED", "CURRENT", "READY", "CREATED", "UPDATED", ent.Entity().ShortId(), verEnt.Entity().ShortId(), "web", "fixed"} {
require.NoError(t, list())
for _, text := range []string{"ID", "APP", "VERSION", "SERVICE", "MODE", "DESIRED", "CURRENT", "READY", "CREATED", "UPDATED", ent.Entity().ShortId(), "shop", verEnt.Entity().ShortId(), "web", "fixed"} {
require.Contains(t, stdout.String(), text)
}
for _, format := range []FormatOptions{{JSON: true}, {Format: "json"}} {
require.NoError(t, list(format))
for _, format := range [][]string{{"--json"}, {"--format", "json"}} {
require.NoError(t, list(format...))
var items []map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &items))
require.Len(t, items, 1)
Expand Down Expand Up @@ -223,12 +224,64 @@ func TestSandboxPoolCommandsWithoutEntityAccess(t *testing.T) {
require.EqualValues(t, 1, pool.DesiredInstances)
require.NoError(t, ec.Delete(t.Context(), rawID))
require.NoError(t, ec.Delete(t.Context(), poolID))
require.NoError(t, list(FormatOptions{}))
require.NoError(t, list())
require.Equal(t, "No sandbox pools found\n", stdout.String())
require.NoError(t, list(FormatOptions{JSON: true}))
require.NoError(t, list("--json"))
require.Equal(t, "[]\n", stdout.String())
}

func TestSandboxPoolListFilters(t *testing.T) {
inmem, cleanup := testutils.NewInMemEntityServer(t)
t.Cleanup(cleanup)
ec := inmem.Client
for _, app := range []string{"shop", "blog"} {
appID, err := ec.Create(t.Context(), app, &core_v1alpha.App{})
require.NoError(t, err)
versionID, err := ec.Create(t.Context(), app+"-v1", &core_v1alpha.AppVersion{App: appID})
require.NoError(t, err)
for _, service := range []string{"web", "worker"} {
poolID := entity.Id("pool/" + app + "-" + service)
require.NoError(t, ec.UpdateAttrs(t.Context(), poolID, (&compute_v1alpha.SandboxPool{
App: appID, Service: service, SandboxSpec: compute_v1alpha.SandboxSpec{Version: versionID},
}).Encode(), entity.Ref(entity.DBId, poolID)))
}
}
// Addon pools name their app only in a label, with no owner or service.
addonID, err := ec.Create(t.Context(), "shop-db", &compute_v1alpha.SandboxPool{}, entityserver.WithLabels(types.LabelSet("addon", "miren-postgresql", "app", "shop")))
require.NoError(t, err)
addon := addonID.String()
server, err := rpc.NewState(t.Context(), rpc.WithBindAddr("localhost:0"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, server.Close()) })
server.Server().ExposeValue("dev.miren.runtime/sandbox-pools", compute_v1alpha.AdaptSandboxPools(sandboxserver.NewPoolServer(ec)))
args := []string{"--config", filepath.Join(t.TempDir(), "config.yaml"), "--server-address", server.ListenAddr(), "--format", "json"}
list := func(extra ...string) []string {
out, err := RunCommand(SandboxPoolList, append(args, extra...)...)
require.NoError(t, err)
var items []sandboxPoolListEntry
require.NoError(t, json.Unmarshal(out.Stdout.Bytes(), &items))
ids := make([]string, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
return ids
}
require.ElementsMatch(t, []string{"pool/shop-web", "pool/shop-worker", "pool/blog-web", "pool/blog-worker", addon}, list())
require.ElementsMatch(t, []string{"pool/shop-web", "pool/shop-worker", addon}, list("--app", "shop"))
require.ElementsMatch(t, []string{"pool/shop-worker", "pool/blog-worker"}, list("--service", "worker"))
require.Equal(t, []string{"pool/blog-web"}, list("--app", "blog", "--service", "web"))
require.Empty(t, list("--app", "missing"))

table := args[:len(args)-2]
out, err := RunCommand(SandboxPoolList, table...)
require.NoError(t, err)
require.Contains(t, out.Stdout.String(), "APP")
require.Contains(t, out.Stdout.String(), "blog")
out, err = RunCommand(SandboxPoolList, append(slices.Clone(table), "--app", "missing")...)
require.NoError(t, err)
require.Equal(t, "No sandbox pools found for app \"missing\"\n", out.Stdout.String())
}

type deniedPoolServer struct{}

func (deniedPoolServer) List(context.Context, *compute_v1alpha.SandboxPoolsList) error {
Expand All @@ -248,33 +301,18 @@ func TestSandboxPoolCommandsDoNotFallback(t *testing.T) {
if available {
server.Server().ExposeValue("dev.miren.runtime/sandbox-pools", compute_v1alpha.AdaptSandboxPools(deniedPoolServer{}))
}
var stdout bytes.Buffer
ctx := &Context{Context: t.Context(), Log: slog.Default(), Stdout: &stdout, Stderr: io.Discard}
ctx.Config.ServerAddress = server.ListenAddr()
err = SandboxPoolList(ctx, struct {
FormatOptions
ConfigCentric
}{})
require.Error(t, err)
if available {
require.ErrorContains(t, err, "unauthorized")
} else {
require.ErrorContains(t, err, "entities")
}
require.Empty(t, stdout.String())
err = SandboxPoolSetDesired(ctx, struct {
RawID bool `long:"raw-id" description:"Use the provided ID as-is without adding the pool/ prefix"`
PoolID string `position:"0" usage:"Pool ID (e.g., pool-CUSkT8J58BmgkDeGyPP2e or pool/pool-CUSkT8J58BmgkDeGyPP2e)" required:"true"`
Desired string `position:"1" usage:"Desired instance count (absolute number, +N to increase, or -N to decrease)" required:"true"`
ConfigCentric
}{PoolID: "pool-1", Desired: "+2"})
require.Error(t, err)
if available {
require.ErrorContains(t, err, "unauthorized")
} else {
require.ErrorContains(t, err, "entities")
base := []string{"--config", filepath.Join(t.TempDir(), "config.yaml"), "--server-address", server.ListenAddr()}
check := func(out *CommandOutput, err error) {
require.Error(t, err)
if available {
require.ErrorContains(t, err, "unauthorized")
} else {
require.ErrorContains(t, err, "entities")
}
require.Empty(t, out.Stdout.String())
}
require.Empty(t, stdout.String())
check(RunCommand(SandboxPoolList, base...))
check(RunCommand(SandboxPoolSetDesired, append(base, "pool-1", "+2")...))
})
}
}
2 changes: 2 additions & 0 deletions docs/docs/command/debug-sandbox-pool-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ miren debug sandbox-pool list [flags]

## Flags

- `--app` — Only show pools belonging to this app
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--service` — Only show pools of this service (e.g. web, worker)

## Global Options

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/command/sandbox-pool-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ miren sandbox-pool list [flags]

## Flags

- `--app` — Only show pools belonging to this app
- `--cluster, -C` — Cluster name
- `--config` — Path to the config file
- `--format` — Output format (text, json) (default: `text`)
- `--json` — Shorthand for --format json
- `--service` — Only show pools of this service (e.g. web, worker)

## Global Options

Expand Down
Loading
Loading