Skip to content
Open
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
75 changes: 65 additions & 10 deletions cli/cmd/sudo/project/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (

"github.com/rilldata/rill/cli/pkg/cmdutil"
adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1"
"github.com/rilldata/rill/runtime"
"github.com/spf13/cobra"
)

func EditCmd(ch *cmdutil.Helper) *cobra.Command {
var prodSlots, devSlots int
var prodVersion string
var overrideDiskGB int64
var cloudEditingDisabled bool

editCmd := &cobra.Command{
Use: "edit <org> <project>",
Expand All @@ -26,37 +28,38 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command {
SuperuserForceAccess: true,
}

isEditRequested := false
isProjectEditRequested := false
if cmd.Flags().Changed("prod-slots") {
if prodSlots <= 0 {
return fmt.Errorf("--prod-slots must be greater than zero")
}
prodSlotsInt64 := int64(prodSlots)
req.ProdSlots = &prodSlotsInt64
isEditRequested = true
isProjectEditRequested = true
}
if cmd.Flags().Changed("prod-version") {
req.ProdVersion = &prodVersion
isEditRequested = true
isProjectEditRequested = true
}
if cmd.Flags().Changed("dev-slots") {
if devSlots <= 0 {
return fmt.Errorf("--dev-slots must be greater than zero")
}
devSlotsInt64 := int64(devSlots)
req.DevSlots = &devSlotsInt64
isEditRequested = true
isProjectEditRequested = true
}
if cmd.Flags().Changed("override-disk-gb") {
if overrideDiskGB < 0 {
return fmt.Errorf("--override-disk-gb must be >= 0 (use 0 to clear the override)")
}
v := overrideDiskGB
req.OverrideDiskGb = &v
isEditRequested = true
isProjectEditRequested = true
}

if !isEditRequested {
isCloudEditingEditRequested := cmd.Flags().Changed("cloud-editing-disabled")
if !isProjectEditRequested && !isCloudEditingEditRequested {
ch.Printf("No edit requested\n")
return nil
}
Expand All @@ -66,13 +69,43 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command {
return err
}

updatedProj, err := client.UpdateProject(ctx, req)
if err != nil {
return err
var updatedProject *adminv1.Project
if isProjectEditRequested {
res, err := client.UpdateProject(ctx, req)
if err != nil {
return err
}
updatedProject = res.Project
}

if isCloudEditingEditRequested {
res, err := client.GetProject(ctx, &adminv1.GetProjectRequest{
Org: args[0],
Project: args[1],
SuperuserForceAccess: true,
})
if err != nil {
return err
}

annotations, changed := setCloudEditingDisabledAnnotation(res.Project.Annotations, cloudEditingDisabled)
if changed {
updatedAnnotations, err := client.SudoUpdateAnnotations(ctx, &adminv1.SudoUpdateAnnotationsRequest{
Org: args[0],
Project: args[1],
Annotations: annotations,
})
Comment on lines +93 to +97
if err != nil {
return err
}
updatedProject = updatedAnnotations.Project
} else {
updatedProject = res.Project
}
}

ch.PrintfSuccess("Updated project\n")
ch.PrintProjects([]*adminv1.Project{updatedProj.Project})
ch.PrintProjects([]*adminv1.Project{updatedProject})

return nil
},
Expand All @@ -82,5 +115,27 @@ func EditCmd(ch *cmdutil.Helper) *cobra.Command {
editCmd.Flags().IntVar(&devSlots, "dev-slots", 0, "Slots to allocate for dev deployments")
editCmd.Flags().StringVar(&prodVersion, "prod-version", "", "Rill version for production deployment")
editCmd.Flags().Int64Var(&overrideDiskGB, "override-disk-gb", 0, "Override disk size in GB for prod and dev deployments (0 clears the override)")
editCmd.Flags().BoolVar(&cloudEditingDisabled, "cloud-editing-disabled", false, "Hide cloud editing in the UI even when enabled in rill.yaml")
return editCmd
}

func setCloudEditingDisabledAnnotation(annotations map[string]string, disabled bool) (map[string]string, bool) {
res := make(map[string]string, len(annotations)+1)
for k, v := range annotations {
res[k] = v
}

if disabled {
if res[runtime.CloudEditingDisabledAnnotation] == "true" {
return res, false
}
res[runtime.CloudEditingDisabledAnnotation] = "true"
return res, true
}

if _, ok := res[runtime.CloudEditingDisabledAnnotation]; !ok {
return res, false
}
delete(res, runtime.CloudEditingDisabledAnnotation)
return res, true
}
16 changes: 16 additions & 0 deletions runtime/feature_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"fmt"
"strconv"
"strings"

"github.com/iancoleman/strcase"
Expand All @@ -11,6 +12,10 @@ import (
"golang.org/x/exp/maps"
)

// CloudEditingDisabledAnnotation is an admin-managed project annotation that
// disables the cloud editing UI regardless of the cloud_editing feature flag.
const CloudEditingDisabledAnnotation = "cloud_editing_disabled"

// FeatureFlags finds and resolves the feature flags for the given instance ID and claims.
// It's designed for use in the backend. Use runtime.ResolveFeatureFlags for resolving flags that will be exposed to the UI.
func (r *Runtime) FeatureFlags(ctx context.Context, instanceID string, claims *SecurityClaims) (map[string]bool, error) {
Expand Down Expand Up @@ -116,6 +121,17 @@ func ResolveFeatureFlags(inst *drivers.Instance, userAttributes map[string]any,
featureFlags[k] = bv
}

// Admin-managed disables take precedence over project-configured feature
// flags. This is intentionally a UI-only control; backend permissions for
// editable deployments are enforced independently.
cloudEditingKey := "cloud_editing"
if camelCase {
cloudEditingKey = "cloudEditing"
}
if disabled, _ := strconv.ParseBool(inst.Annotations[CloudEditingDisabledAnnotation]); disabled {
featureFlags[cloudEditingKey] = false
}

// Apply feature flag dependencies:
// If chat is disabled, dashboard_chat should also be disabled
chatKey := "chat"
Expand Down
37 changes: 37 additions & 0 deletions runtime/feature_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,40 @@ func Test_ResolveFeatureFlags(t *testing.T) {
})
}
}

func Test_ResolveFeatureFlags_CloudEditingDisabledAnnotation(t *testing.T) {
tests := []struct {
name string
annotationValue string
expected bool
}{
{name: "annotation absent", expected: true},
{name: "annotation false", annotationValue: "false", expected: true},
{name: "annotation true", annotationValue: "true", expected: false},
{name: "annotation true case insensitive", annotationValue: "TRUE", expected: false},
{name: "annotation invalid", annotationValue: "invalid", expected: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
annotations := map[string]string{}
if test.annotationValue != "" {
annotations[CloudEditingDisabledAnnotation] = test.annotationValue
}

for _, camelCase := range []bool{false, true} {
featureFlags, err := ResolveFeatureFlags(&drivers.Instance{
FeatureFlags: map[string]string{"cloud_editing": "true"},
Annotations: annotations,
}, nil, camelCase)
require.NoError(t, err)

key := "cloud_editing"
if camelCase {
key = "cloudEditing"
}
require.Equal(t, test.expected, featureFlags[key])
}
})
}
}
Loading