From c7ec870fa40b2cdc1f911ab4070a7c6df242f19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Egelund-M=C3=BCller?= Date: Thu, 27 Aug 2026 16:16:35 +0200 Subject: [PATCH 1/3] Add `rill sudo project dump-instances` --- cli/cmd/sudo/project/dump_instances.go | 213 +++++++++++++++++++++++++ cli/cmd/sudo/project/project.go | 1 + 2 files changed, 214 insertions(+) create mode 100644 cli/cmd/sudo/project/dump_instances.go diff --git a/cli/cmd/sudo/project/dump_instances.go b/cli/cmd/sudo/project/dump_instances.go new file mode 100644 index 000000000000..b955305701be --- /dev/null +++ b/cli/cmd/sudo/project/dump_instances.go @@ -0,0 +1,213 @@ +package project + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + + "github.com/rilldata/rill/admin/client" + "github.com/rilldata/rill/cli/pkg/cmdutil" + adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" + runtimeclient "github.com/rilldata/rill/runtime/client" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" +) + +// dumpInstancesConcurrency bounds how many runtimes we query in parallel. +const dumpInstancesConcurrency = 16 + +func DumpInstances(ch *cmdutil.Helper) *cobra.Command { + var pageSize uint32 + var pageToken string + var annotations map[string]string + var sensitive bool + + dumpCmd := &cobra.Command{ + Use: "dump-instances []", + Args: cobra.MaximumNArgs(1), + Short: "Dump runtime instance info for projects by pattern", + Long: "Dump the runtime instance of each matching project, including its feature flags, connectors, variables and annotations.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + c, err := ch.Client() + if err != nil { + return err + } + + pattern := "%" + if len(args) > 0 { + pattern = args[0] + } + + res, err := c.SearchProjectNames(ctx, &adminv1.SearchProjectNamesRequest{ + NamePattern: pattern, + Annotations: annotations, + PageSize: pageSize, + PageToken: pageToken, + }) + if err != nil { + return err + } + + if len(res.Names) == 0 { + ch.PrintfWarn("No projects found\n") + return nil + } + + var m sync.Mutex + failedProjects := map[string]error{} + var instances []*projectInstance + grp, ctx := errgroup.WithContext(ctx) + grp.SetLimit(dumpInstancesConcurrency) + for _, name := range res.Names { + org, project, ok := strings.Cut(name, "/") + if !ok { + continue + } + + grp.Go(func() error { + inst, err := instanceForProject(ctx, c, org, project, sensitive) + if err != nil { + m.Lock() + failedProjects[name] = err + m.Unlock() + return nil + } + if inst == nil { + // The project has no deployment, e.g. because it's hibernated. + return nil + } + m.Lock() + instances = append(instances, inst) + m.Unlock() + return nil + }) + } + + err = grp.Wait() + if err != nil { + return err + } + + printInstances(ch, instances) + + for name, err := range failedProjects { + ch.Println() + ch.PrintfWarn("Failed to dump instance for project %v: %s\n", name, err) + } + if res.NextPageToken != "" { + ch.Println() + ch.Printf("Next page token: %s\n", res.NextPageToken) + } + + return nil + }, + } + dumpCmd.Flags().StringToStringVar(&annotations, "annotation", nil, "Annotations to filter projects by (supports wildcard values)") + dumpCmd.Flags().Uint32Var(&pageSize, "page-size", 1000, "Number of projects to return per page") + dumpCmd.Flags().StringVar(&pageToken, "page-token", "", "Pagination token") + dumpCmd.Flags().BoolVar(&sensitive, "sensitive", false, "Include sensitive values, such as connector configs and variables") + + return dumpCmd +} + +// projectInstance pairs a runtime instance with the project it belongs to. +type projectInstance struct { + Org string + Project string + RuntimeHost string + Instance *runtimev1.Instance +} + +func instanceForProject(ctx context.Context, c *client.Client, org, project string, sensitive bool) (*projectInstance, error) { + proj, err := c.GetProject(ctx, &adminv1.GetProjectRequest{ + Org: org, + Project: project, + SuperuserForceAccess: true, + IssueSuperuserToken: true, + }) + if err != nil { + return nil, err + } + + depl := proj.Deployment + if depl == nil { + return nil, nil + } + + rt, err := runtimeclient.New(depl.RuntimeHost, proj.Jwt) + if err != nil { + return nil, fmt.Errorf("failed to connect to runtime: %w", err) + } + defer rt.Close() + + res, err := rt.GetInstance(ctx, &runtimev1.GetInstanceRequest{ + InstanceId: depl.RuntimeInstanceId, + Sensitive: sensitive, + }) + if err != nil { + msg := err.Error() + if s, ok := status.FromError(err); ok { + msg = s.Message() + } + return nil, fmt.Errorf("runtime error, failed to get instance: %v", msg) + } + + return &projectInstance{ + Org: org, + Project: project, + RuntimeHost: depl.RuntimeHost, + Instance: res.Instance, + }, nil +} + +func printInstances(ch *cmdutil.Helper, instances []*projectInstance) { + if len(instances) == 0 { + ch.PrintfWarn("No instances found\n") + return + } + + // The instances are collected concurrently, so sort them for a stable output. + sort.Slice(instances, func(i, j int) bool { + if instances[i].Org != instances[j].Org { + return instances[i].Org < instances[j].Org + } + return instances[i].Project < instances[j].Project + }) + + // Use the proto field names so keys match the API, e.g. "feature_flags" instead of "featureFlags". + marshaler := protojson.MarshalOptions{UseProtoNames: true} + + rows := make([]map[string]any, 0, len(instances)) + for _, pi := range instances { + rowJSON, err := marshaler.Marshal(pi.Instance) + if err != nil { + ch.PrintfWarn("Failed to marshal instance for org %v, project %v: %v\n", pi.Org, pi.Project, err) + continue + } + row := make(map[string]any) + err = json.Unmarshal(rowJSON, &row) + if err != nil { + ch.PrintfWarn("Failed to unmarshal instance for org %v, project %v: %v\n", pi.Org, pi.Project, err) + continue + } + + row["org"] = pi.Org + row["project"] = pi.Project + row["runtime_host"] = pi.RuntimeHost + rows = append(rows, row) + } + + jsonData, err := json.MarshalIndent(rows, "", " ") + if err != nil { + ch.PrintfWarn("Failed to marshal instances: %v\n", err) + return + } + fmt.Println(string(jsonData)) +} diff --git a/cli/cmd/sudo/project/project.go b/cli/cmd/sudo/project/project.go index 4d910ae69e5e..8c17e40d0c74 100644 --- a/cli/cmd/sudo/project/project.go +++ b/cli/cmd/sudo/project/project.go @@ -17,6 +17,7 @@ func ProjectCmd(ch *cmdutil.Helper) *cobra.Command { projectCmd.AddCommand(HibernateCmd(ch)) projectCmd.AddCommand(ResetCmd(ch)) projectCmd.AddCommand(DumpResources(ch)) + projectCmd.AddCommand(DumpInstances(ch)) return projectCmd } From 43b3bea4086cc3a8af422e5f3491ea53df038660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Egelund-M=C3=BCller?= Date: Fri, 28 Aug 2026 17:29:22 +0200 Subject: [PATCH 2/3] Truncate runtime error messages in dump-instances --- cli/cmd/sudo/project/dump_instances.go | 31 +++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/cli/cmd/sudo/project/dump_instances.go b/cli/cmd/sudo/project/dump_instances.go index b955305701be..9acc8c258f6f 100644 --- a/cli/cmd/sudo/project/dump_instances.go +++ b/cli/cmd/sudo/project/dump_instances.go @@ -19,8 +19,15 @@ import ( "google.golang.org/protobuf/encoding/protojson" ) -// dumpInstancesConcurrency bounds how many runtimes we query in parallel. -const dumpInstancesConcurrency = 16 +const ( + // dumpInstancesConcurrency bounds how many runtimes we query in parallel. + dumpInstancesConcurrency = 16 + + // dumpInstancesMaxErrLen truncates error messages from the runtime. + // A deployment pointing at a decommissioned runtime host returns an HTML error page, + // which gRPC embeds in the status message and would otherwise flood the output. + dumpInstancesMaxErrLen = 200 +) func DumpInstances(ch *cmdutil.Helper) *cobra.Command { var pageSize uint32 @@ -61,7 +68,7 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { } var m sync.Mutex - failedProjects := map[string]error{} + failedProjects := map[string]string{} var instances []*projectInstance grp, ctx := errgroup.WithContext(ctx) grp.SetLimit(dumpInstancesConcurrency) @@ -75,7 +82,7 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { inst, err := instanceForProject(ctx, c, org, project, sensitive) if err != nil { m.Lock() - failedProjects[name] = err + failedProjects[name] = shortErrMsg(err) m.Unlock() return nil } @@ -97,9 +104,11 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { printInstances(ch, instances) - for name, err := range failedProjects { + if len(failedProjects) > 0 { ch.Println() - ch.PrintfWarn("Failed to dump instance for project %v: %s\n", name, err) + } + for name, msg := range failedProjects { + ch.PrintfWarn("Failed to dump instance for project %v: %s\n", name, msg) } if res.NextPageToken != "" { ch.Println() @@ -211,3 +220,13 @@ func printInstances(ch *cmdutil.Helper, instances []*projectInstance) { } fmt.Println(string(jsonData)) } + +// shortErrMsg formats an error as a single truncated line. +func shortErrMsg(err error) string { + msg := strings.Join(strings.Fields(err.Error()), " ") + if len(msg) > dumpInstancesMaxErrLen { + // ToValidUTF8 drops a rune that the cut may have split in half. + msg = strings.ToValidUTF8(msg[:dumpInstancesMaxErrLen], "") + "..." + } + return msg +} From 5e71c49e9790f634a556e7530647c1204c22a22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Egelund-M=C3=BCller?= Date: Fri, 28 Aug 2026 17:32:33 +0200 Subject: [PATCH 3/3] Skip projects without a running deployment in dump-instances --- cli/cmd/sudo/project/dump_instances.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cli/cmd/sudo/project/dump_instances.go b/cli/cmd/sudo/project/dump_instances.go index 9acc8c258f6f..3f0a1f7bfcdf 100644 --- a/cli/cmd/sudo/project/dump_instances.go +++ b/cli/cmd/sudo/project/dump_instances.go @@ -68,6 +68,7 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { } var m sync.Mutex + var skipped int failedProjects := map[string]string{} var instances []*projectInstance grp, ctx := errgroup.WithContext(ctx) @@ -87,7 +88,9 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { return nil } if inst == nil { - // The project has no deployment, e.g. because it's hibernated. + m.Lock() + skipped++ + m.Unlock() return nil } m.Lock() @@ -104,6 +107,10 @@ func DumpInstances(ch *cmdutil.Helper) *cobra.Command { printInstances(ch, instances) + if skipped > 0 { + ch.Println() + ch.Printf("Skipped %d project(s) without a running deployment\n", skipped) + } if len(failedProjects) > 0 { ch.Println() } @@ -145,8 +152,11 @@ func instanceForProject(ctx context.Context, c *client.Client, org, project stri return nil, err } + // Skip projects without a running deployment. + // A stopped deployment still carries a runtime host and instance ID, but its runtime has been hibernated or deprovisioned, + // so dialing it fails. Note that hibernating a project clears its primary deployment, but stopping a deployment does not. depl := proj.Deployment - if depl == nil { + if depl == nil || depl.Status != adminv1.DeploymentStatus_DEPLOYMENT_STATUS_RUNNING { return nil, nil }