diff --git a/admin/database/database.go b/admin/database/database.go index cf15a5530ec..257202f1037 100644 --- a/admin/database/database.go +++ b/admin/database/database.go @@ -293,6 +293,7 @@ type DB interface { DeleteProjectVariables(ctx context.Context, projectID, environment string, vars []string) error FindProvisionerResourcesForDeployment(ctx context.Context, deploymentID string) ([]*ProvisionerResource, error) + FindProvisionerResourceByName(ctx context.Context, deploymentID, typ, name string) (*ProvisionerResource, error) InsertProvisionerResource(ctx context.Context, opts *InsertProvisionerResourceOptions) (*ProvisionerResource, error) UpdateProvisionerResource(ctx context.Context, id string, opts *UpdateProvisionerResourceOptions) (*ProvisionerResource, error) DeleteProvisionerResource(ctx context.Context, id string) error @@ -783,26 +784,28 @@ type OrganizationRole struct { // ProjectRole represents roles for projects. type ProjectRole struct { - ID string - Name string - ReadProject bool `db:"read_project"` - ManageProject bool `db:"manage_project"` - ReadProd bool `db:"read_prod"` - ReadProdStatus bool `db:"read_prod_status"` - ManageProd bool `db:"manage_prod"` - ReadDev bool `db:"read_dev"` - ReadDevStatus bool `db:"read_dev_status"` - ManageDev bool `db:"manage_dev"` - ReadProjectMembers bool `db:"read_project_members"` - ManageProjectMembers bool `db:"manage_project_members"` - CreateMagicAuthTokens bool `db:"create_magic_auth_tokens"` - ManageMagicAuthTokens bool `db:"manage_magic_auth_tokens"` - CreateReports bool `db:"create_reports"` - ManageReports bool `db:"manage_reports"` - CreateAlerts bool `db:"create_alerts"` - ManageAlerts bool `db:"manage_alerts"` - CreateBookmarks bool `db:"create_bookmarks"` - ManageBookmarks bool `db:"manage_bookmarks"` + ID string + Name string + ReadProject bool `db:"read_project"` + ManageProject bool `db:"manage_project"` + ReadProd bool `db:"read_prod"` + ReadProdStatus bool `db:"read_prod_status"` + ManageProd bool `db:"manage_prod"` + ReadDev bool `db:"read_dev"` + ReadDevStatus bool `db:"read_dev_status"` + ManageDev bool `db:"manage_dev"` + ReadProvisionerResources bool `db:"read_provisioner_resources"` + ManageProvisionerResources bool `db:"manage_provisioner_resources"` + ReadProjectMembers bool `db:"read_project_members"` + ManageProjectMembers bool `db:"manage_project_members"` + CreateMagicAuthTokens bool `db:"create_magic_auth_tokens"` + ManageMagicAuthTokens bool `db:"manage_magic_auth_tokens"` + CreateReports bool `db:"create_reports"` + ManageReports bool `db:"manage_reports"` + CreateAlerts bool `db:"create_alerts"` + ManageAlerts bool `db:"manage_alerts"` + CreateBookmarks bool `db:"create_bookmarks"` + ManageBookmarks bool `db:"manage_bookmarks"` } // MemberUser is a convenience type used for display-friendly representation of an org or project member. diff --git a/admin/database/postgres/migrations/0055.sql b/admin/database/postgres/migrations/0055.sql new file mode 100644 index 00000000000..fe4836ee2eb --- /dev/null +++ b/admin/database/postgres/migrations/0055.sql @@ -0,0 +1,5 @@ +ALTER TABLE project_roles ADD read_provisioner_resources BOOLEAN DEFAULT false NOT NULL; +UPDATE project_roles SET read_provisioner_resources = read_prod_status; + +ALTER TABLE project_roles ADD manage_provisioner_resources BOOLEAN DEFAULT false NOT NULL; +UPDATE project_roles SET manage_provisioner_resources = manage_prod; diff --git a/admin/database/postgres/postgres.go b/admin/database/postgres/postgres.go index f535a56beb1..14da4b9f025 100644 --- a/admin/database/postgres/postgres.go +++ b/admin/database/postgres/postgres.go @@ -2232,6 +2232,15 @@ func (c *connection) FindProvisionerResourcesForDeployment(ctx context.Context, return c.provisionerResourcesFromDTOs(res) } +func (c *connection) FindProvisionerResourceByName(ctx context.Context, deploymentID, typ, name string) (*database.ProvisionerResource, error) { + res := &provisionerResourceDTO{} + err := c.getDB(ctx).QueryRowxContext(ctx, `SELECT * FROM provisioner_resources WHERE deployment_id = $1 AND "type" = $2 AND name = $3`, deploymentID, typ, name).StructScan(res) + if err != nil { + return nil, parseErr("provisioner resource", err) + } + return c.provisionerResourceFromDTO(res) +} + func (c *connection) InsertProvisionerResource(ctx context.Context, opts *database.InsertProvisionerResourceOptions) (*database.ProvisionerResource, error) { if err := database.Validate(opts); err != nil { return nil, err diff --git a/admin/deployments.go b/admin/deployments.go index 6bcd7592151..81181ea7cea 100644 --- a/admin/deployments.go +++ b/admin/deployments.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/hashicorp/go-version" "github.com/rilldata/rill/admin/database" "github.com/rilldata/rill/admin/provisioner" @@ -478,15 +477,10 @@ type provisionRuntimeOptions struct { } func (s *Service) provisionRuntime(ctx context.Context, opts *provisionRuntimeOptions) (*database.ProvisionerResource, error) { - // Get provisioner from the set. // Use default if no provisioner is specified. if opts.Provisioner == "" { opts.Provisioner = s.opts.DefaultProvisioner } - p, ok := s.ProvisionerSet[opts.Provisioner] - if !ok { - return nil, fmt.Errorf("provisioner: %q is not in the provisioner set", opts.Provisioner) - } // Create provisioner args args := &provisioner.RuntimeArgs{ @@ -494,91 +488,31 @@ func (s *Service) provisionRuntime(ctx context.Context, opts *provisionRuntimeOp Version: opts.Version, } - // Attempt to find an existing provisioned runtime for the deployment - pr, ok, err := s.findProvisionedRuntimeResource(ctx, opts.DeploymentID) - if err != nil { - return nil, err - } - if ok && pr.Provisioner != opts.Provisioner { - return nil, fmt.Errorf("provisioner: cannot change provisioner from %q to %q for deployment %q", pr.Provisioner, opts.Provisioner, opts.DeploymentID) - } - - // If we didn't find an existing DB entry, create one - if !ok { - pr, err = s.DB.InsertProvisionerResource(ctx, &database.InsertProvisionerResourceOptions{ - ID: uuid.New().String(), - DeploymentID: opts.DeploymentID, - Type: string(provisioner.ResourceTypeRuntime), - Name: "", // Not giving runtime resources a name since there should only be one per deployment. - Status: database.ProvisionerResourceStatusPending, - StatusMessage: "Provisioning...", - Provisioner: opts.Provisioner, - Args: args.AsMap(), - State: nil, // Will be populated after provisioning - Config: nil, // Will be populated after provisioning - }) - if err != nil { - return nil, err - } - } - - // Provision the runtime - r := &provisioner.Resource{ - ID: pr.ID, - Type: provisioner.ResourceTypeRuntime, - State: pr.State, // Empty if inserting - Config: pr.Config, // Empty if inserting - } - r, err = p.Provision(ctx, r, &provisioner.ResourceOptions{ - Args: args.AsMap(), - Annotations: opts.Annotations, - RillVersion: s.resolveRillVersion(), - }) - if err != nil { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - _, _ = s.DB.UpdateProvisionerResource(ctx, pr.ID, &database.UpdateProvisionerResourceOptions{ - Status: database.ProvisionerResourceStatusError, - StatusMessage: fmt.Sprintf("Failed provisioning runtime: %v", err), - Args: pr.Args, - State: pr.State, - Config: pr.Config, - }) - return nil, err - } - - // Update the provisioner resource - pr, err = s.DB.UpdateProvisionerResource(ctx, pr.ID, &database.UpdateProvisionerResourceOptions{ - Status: database.ProvisionerResourceStatusOK, - StatusMessage: "", - Args: args.AsMap(), - State: r.State, - Config: r.Config, + // Call into the generic provision function + pr, err := s.Provision(ctx, &ProvisionOptions{ + DeploymentID: opts.DeploymentID, + Type: provisioner.ResourceTypeRuntime, + Name: "", // Not giving runtime resources a name since there should only be one per deployment. + Provisioner: opts.Provisioner, + Args: args.AsMap(), + Annotations: opts.Annotations, }) if err != nil { return nil, err } - // Await the runtime to be ready - err = p.AwaitReady(ctx, r) - if err != nil { - return nil, err - } - return pr, nil } func (s *Service) findProvisionedRuntimeResource(ctx context.Context, deploymentID string) (*database.ProvisionerResource, bool, error) { - prs, err := s.DB.FindProvisionerResourcesForDeployment(ctx, deploymentID) + pr, err := s.DB.FindProvisionerResourceByName(ctx, deploymentID, string(provisioner.ResourceTypeRuntime), "") if err != nil { - return nil, false, err - } - for _, val := range prs { - if provisioner.ResourceType(val.Type) == provisioner.ResourceTypeRuntime { - return val, true, nil + if errors.Is(err, database.ErrNotFound) { + return nil, false, nil } + return nil, false, err } - return nil, false, nil + return pr, true, nil } func (s *Service) resolveRillVersion() string { diff --git a/admin/permissions.go b/admin/permissions.go index ecbb45fa084..58abca136ce 100644 --- a/admin/permissions.go +++ b/admin/permissions.go @@ -104,24 +104,26 @@ func (s *Service) ProjectPermissionsForUser(ctx context.Context, projectID, user // ManageProjects permission on the org gives full access to all projects in the org (only org admins have this) if orgPerms.ManageProjects { return &adminv1.ProjectPermissions{ - ReadProject: true, - ManageProject: true, - ReadProd: true, - ReadProdStatus: true, - ManageProd: true, - ReadDev: true, - ReadDevStatus: true, - ManageDev: true, - ReadProjectMembers: true, - ManageProjectMembers: true, - CreateMagicAuthTokens: true, - ManageMagicAuthTokens: true, - CreateReports: true, - ManageReports: true, - CreateAlerts: true, - ManageAlerts: true, - CreateBookmarks: true, - ManageBookmarks: true, + ReadProject: true, + ManageProject: true, + ReadProd: true, + ReadProdStatus: true, + ManageProd: true, + ReadDev: true, + ReadDevStatus: true, + ManageDev: true, + ReadProvisionerResources: true, + ManageProvisionerResources: true, + ReadProjectMembers: true, + ManageProjectMembers: true, + CreateMagicAuthTokens: true, + ManageMagicAuthTokens: true, + CreateReports: true, + ManageReports: true, + CreateAlerts: true, + ManageAlerts: true, + CreateBookmarks: true, + ManageBookmarks: true, }, nil } @@ -143,24 +145,26 @@ func (s *Service) ProjectPermissionsForUser(ctx context.Context, projectID, user func (s *Service) ProjectPermissionsForService(ctx context.Context, projectID, serviceID string, orgPerms *adminv1.OrganizationPermissions) (*adminv1.ProjectPermissions, error) { if orgPerms.ManageProjects { return &adminv1.ProjectPermissions{ - ReadProject: true, - ManageProject: true, - ReadProd: true, - ReadProdStatus: true, - ManageProd: true, - ReadDev: true, - ReadDevStatus: true, - ManageDev: true, - ReadProjectMembers: true, - ManageProjectMembers: true, - CreateMagicAuthTokens: true, - ManageMagicAuthTokens: true, - CreateReports: true, - ManageReports: true, - CreateAlerts: true, - ManageAlerts: true, - CreateBookmarks: true, - ManageBookmarks: true, + ReadProject: true, + ManageProject: true, + ReadProd: true, + ReadProdStatus: true, + ManageProd: true, + ReadDev: true, + ReadDevStatus: true, + ManageDev: true, + ReadProvisionerResources: true, + ManageProvisionerResources: true, + ReadProjectMembers: true, + ManageProjectMembers: true, + CreateMagicAuthTokens: true, + ManageMagicAuthTokens: true, + CreateReports: true, + ManageReports: true, + CreateAlerts: true, + ManageAlerts: true, + CreateBookmarks: true, + ManageBookmarks: true, }, nil } @@ -178,24 +182,26 @@ func (s *Service) ProjectPermissionsForDeployment(ctx context.Context, projectID // Deployments get full read and no write permissions on the project they belong to if projectID == depl.ProjectID { return &adminv1.ProjectPermissions{ - ReadProject: true, - ManageProject: false, - ReadProd: true, - ReadProdStatus: true, - ManageProd: false, - ReadDev: true, - ReadDevStatus: true, - ManageDev: false, - ReadProjectMembers: true, - ManageProjectMembers: false, - CreateMagicAuthTokens: false, - ManageMagicAuthTokens: false, - CreateReports: false, - ManageReports: false, - CreateAlerts: false, - ManageAlerts: false, - CreateBookmarks: false, - ManageBookmarks: false, + ReadProject: true, + ManageProject: false, + ReadProd: true, + ReadProdStatus: true, + ManageProd: false, + ReadDev: true, + ReadDevStatus: true, + ManageDev: false, + ReadProvisionerResources: true, + ManageProvisionerResources: true, + ReadProjectMembers: true, + ManageProjectMembers: false, + CreateMagicAuthTokens: false, + ManageMagicAuthTokens: false, + CreateReports: false, + ManageReports: false, + CreateAlerts: false, + ManageAlerts: false, + CreateBookmarks: false, + ManageBookmarks: false, }, nil } @@ -211,24 +217,26 @@ func (s *Service) ProjectPermissionsForMagicAuthToken(ctx context.Context, proje // Grant basic read access to the project and its prod deployment return &adminv1.ProjectPermissions{ - ReadProject: true, - ManageProject: false, - ReadProd: true, - ReadProdStatus: false, - ManageProd: false, - ReadDev: false, - ReadDevStatus: false, - ManageDev: false, - ReadProjectMembers: false, - ManageProjectMembers: false, - CreateMagicAuthTokens: false, - ManageMagicAuthTokens: false, - CreateReports: false, - ManageReports: false, - CreateAlerts: false, - ManageAlerts: false, - CreateBookmarks: false, - ManageBookmarks: false, + ReadProject: true, + ManageProject: false, + ReadProd: true, + ReadProdStatus: false, + ManageProd: false, + ReadDev: false, + ReadDevStatus: false, + ManageDev: false, + ReadProvisionerResources: false, + ManageProvisionerResources: false, + ReadProjectMembers: false, + ManageProjectMembers: false, + CreateMagicAuthTokens: false, + ManageMagicAuthTokens: false, + CreateReports: false, + ManageReports: false, + CreateAlerts: false, + ManageAlerts: false, + CreateBookmarks: false, + ManageBookmarks: false, }, nil } @@ -246,23 +254,25 @@ func unionOrgRoles(a *adminv1.OrganizationPermissions, b *database.OrganizationR func unionProjectRoles(a *adminv1.ProjectPermissions, b *database.ProjectRole) *adminv1.ProjectPermissions { return &adminv1.ProjectPermissions{ - ReadProject: a.ReadProject || b.ReadProject, - ManageProject: a.ManageProject || b.ManageProject, - ReadProd: a.ReadProd || b.ReadProd, - ReadProdStatus: a.ReadProdStatus || b.ReadProdStatus, - ManageProd: a.ManageProd || b.ManageProd, - ReadDev: a.ReadDev || b.ReadDev, - ReadDevStatus: a.ReadDevStatus || b.ReadDevStatus, - ManageDev: a.ManageDev || b.ManageDev, - ReadProjectMembers: a.ReadProjectMembers || b.ReadProjectMembers, - ManageProjectMembers: a.ManageProjectMembers || b.ManageProjectMembers, - CreateMagicAuthTokens: a.CreateMagicAuthTokens || b.CreateMagicAuthTokens, - ManageMagicAuthTokens: a.ManageMagicAuthTokens || b.ManageMagicAuthTokens, - CreateReports: a.CreateReports || b.CreateReports, - ManageReports: a.ManageReports || b.ManageReports, - CreateAlerts: a.CreateAlerts || b.CreateAlerts, - ManageAlerts: a.ManageAlerts || b.ManageAlerts, - CreateBookmarks: a.CreateBookmarks || b.CreateBookmarks, - ManageBookmarks: a.ManageBookmarks || b.ManageBookmarks, + ReadProject: a.ReadProject || b.ReadProject, + ManageProject: a.ManageProject || b.ManageProject, + ReadProd: a.ReadProd || b.ReadProd, + ReadProdStatus: a.ReadProdStatus || b.ReadProdStatus, + ManageProd: a.ManageProd || b.ManageProd, + ReadDev: a.ReadDev || b.ReadDev, + ReadDevStatus: a.ReadDevStatus || b.ReadDevStatus, + ManageDev: a.ManageDev || b.ManageDev, + ReadProvisionerResources: a.ReadProvisionerResources || b.ReadProvisionerResources, + ManageProvisionerResources: a.ManageProvisionerResources || b.ManageProvisionerResources, + ReadProjectMembers: a.ReadProjectMembers || b.ReadProjectMembers, + ManageProjectMembers: a.ManageProjectMembers || b.ManageProjectMembers, + CreateMagicAuthTokens: a.CreateMagicAuthTokens || b.CreateMagicAuthTokens, + ManageMagicAuthTokens: a.ManageMagicAuthTokens || b.ManageMagicAuthTokens, + CreateReports: a.CreateReports || b.CreateReports, + ManageReports: a.ManageReports || b.ManageReports, + CreateAlerts: a.CreateAlerts || b.CreateAlerts, + ManageAlerts: a.ManageAlerts || b.ManageAlerts, + CreateBookmarks: a.CreateBookmarks || b.CreateBookmarks, + ManageBookmarks: a.ManageBookmarks || b.ManageBookmarks, } } diff --git a/admin/provision.go b/admin/provision.go new file mode 100644 index 00000000000..864846faba9 --- /dev/null +++ b/admin/provision.go @@ -0,0 +1,143 @@ +package admin + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/rilldata/rill/admin/database" + "github.com/rilldata/rill/admin/provisioner" +) + +type ProvisionOptions struct { + DeploymentID string + Type provisioner.ResourceType + Name string + Provisioner string + Args map[string]any + Annotations map[string]string +} + +func (s *Service) Provision(ctx context.Context, opts *ProvisionOptions) (*database.ProvisionerResource, error) { + // Attempt to find an existing provisioned resource + pr, err := s.DB.FindProvisionerResourceByName(ctx, opts.DeploymentID, string(opts.Type), opts.Name) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return nil, err + } + + // Find the provisioner to use + var provisionerName string + var p provisioner.Provisioner + if pr != nil { + if opts.Provisioner != "" && opts.Provisioner != pr.Provisioner { + return nil, fmt.Errorf("provisioner: cannot change provisioner from %q to %q for deployment %q", provisionerName, opts.Provisioner, opts.DeploymentID) + } + + var ok bool + provisionerName = pr.Provisioner + p, ok = s.ProvisionerSet[provisionerName] + if !ok { + return nil, fmt.Errorf("provisioner: previous provisioner %q is no longer in the provisioner set", provisionerName) + } + + if !p.Supports(opts.Type) { + return nil, fmt.Errorf("provisioner: previous provisioner %q no longer supports resource type %q", provisionerName, opts.Type) + } + } else if opts.Provisioner != "" { + provisionerName = opts.Provisioner + var ok bool + p, ok = s.ProvisionerSet[provisionerName] + if !ok { + return nil, fmt.Errorf("provisioner: the requested provisioner %q is not in the provisioner set", provisionerName) + } + + if !p.Supports(opts.Type) { + return nil, fmt.Errorf("provisioner: the requested provisioner %q does not support resource type %q", provisionerName, opts.Type) + } + } else { + for n, candidate := range s.ProvisionerSet { + if candidate.Supports(opts.Type) { + provisionerName = n + p = candidate + break + } + } + if p == nil { + return nil, fmt.Errorf("provisioner: no provisioner available that supports resource type %q", opts.Type) + } + } + + // Insert a pending provisioner resource if it doesn't exist + if pr == nil { + pr, err = s.DB.InsertProvisionerResource(ctx, &database.InsertProvisionerResourceOptions{ + ID: uuid.New().String(), + DeploymentID: opts.DeploymentID, + Type: string(opts.Type), + Name: opts.Name, + Status: database.ProvisionerResourceStatusPending, + StatusMessage: "Provisioning...", + Provisioner: provisionerName, + Args: opts.Args, + State: nil, // Will be populated after provisioning + Config: nil, // Will be populated after provisioning + }) + if err != nil { + if !errors.Is(err, database.ErrNotUnique) { + return nil, err + } + + // The resource must have been created concurrently by another process, so we try to find it again. + pr, err = s.DB.FindProvisionerResourceByName(ctx, opts.DeploymentID, string(opts.Type), opts.Name) + if err != nil { + return nil, fmt.Errorf("failed to find expected provisioner resource: %w", err) + } + } + } + + // Provision the resource + r := &provisioner.Resource{ + ID: pr.ID, + Type: opts.Type, + State: pr.State, // Empty if inserting + Config: pr.Config, // Empty if inserting + } + r, err = p.Provision(ctx, r, &provisioner.ResourceOptions{ + Args: opts.Args, + Annotations: opts.Annotations, + RillVersion: s.resolveRillVersion(), + }) + if err != nil { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + _, _ = s.DB.UpdateProvisionerResource(ctx, pr.ID, &database.UpdateProvisionerResourceOptions{ + Status: database.ProvisionerResourceStatusError, + StatusMessage: fmt.Sprintf("Failed provisioning: %v", err), + Args: pr.Args, + State: pr.State, + Config: pr.Config, + }) + return nil, err + } + + // Update the provisioner resource + pr, err = s.DB.UpdateProvisionerResource(ctx, pr.ID, &database.UpdateProvisionerResourceOptions{ + Status: database.ProvisionerResourceStatusOK, + StatusMessage: "", + Args: opts.Args, + State: r.State, + Config: r.Config, + }) + if err != nil { + return nil, err + } + + // Await the resource to be ready + err = p.AwaitReady(ctx, r) + if err != nil { + return nil, err + } + + return pr, nil +} diff --git a/admin/provisioner/clickhousestatic/provisioner.go b/admin/provisioner/clickhousestatic/provisioner.go index 1432e3c8649..ebee9a55a1f 100644 --- a/admin/provisioner/clickhousestatic/provisioner.go +++ b/admin/provisioner/clickhousestatic/provisioner.go @@ -60,16 +60,15 @@ func (p *Provisioner) Type() string { return "clickhouse-static" } +func (p *Provisioner) Supports(rt provisioner.ResourceType) bool { + return rt == provisioner.ResourceTypeClickHouse +} + func (p *Provisioner) Close() error { return p.ch.Close() } func (p *Provisioner) Provision(ctx context.Context, r *provisioner.Resource, opts *provisioner.ResourceOptions) (*provisioner.Resource, error) { - // Can only provision clickhouse resources - if r.Type != provisioner.ResourceTypeClickHouse { - return nil, provisioner.ErrResourceTypeNotSupported - } - // Parse the resource's config (in case it's an update/check) cfg, err := provisioner.NewClickhouseConfig(r.Config) if err != nil { @@ -224,7 +223,7 @@ func (p *Provisioner) pingWithResourceDSN(ctx context.Context, dsn string) error _, err = db.ExecContext(ctx, "SELECT 1") if err != nil { - return fmt.Errorf("failed to execute query on tenant: %w", err) + return fmt.Errorf("failed to execute query: %w", err) } return nil diff --git a/admin/provisioner/kubernetes/kubernetes.go b/admin/provisioner/kubernetes/kubernetes.go index f6c350c3f4e..220fa95f17e 100644 --- a/admin/provisioner/kubernetes/kubernetes.go +++ b/admin/provisioner/kubernetes/kubernetes.go @@ -144,16 +144,15 @@ func (p *KubernetesProvisioner) Type() string { return "kubernetes" } +func (p *KubernetesProvisioner) Supports(rt provisioner.ResourceType) bool { + return rt == provisioner.ResourceTypeRuntime +} + func (p *KubernetesProvisioner) Close() error { return nil } func (p *KubernetesProvisioner) Provision(ctx context.Context, r *provisioner.Resource, opts *provisioner.ResourceOptions) (*provisioner.Resource, error) { - // Can only provision runtime resources - if r.Type != provisioner.ResourceTypeRuntime { - return nil, provisioner.ErrResourceTypeNotSupported - } - // Parse args args, err := provisioner.NewRuntimeArgs(opts.Args) if err != nil { diff --git a/admin/provisioner/provisioner.go b/admin/provisioner/provisioner.go index 80a68089e76..c4b8b943961 100644 --- a/admin/provisioner/provisioner.go +++ b/admin/provisioner/provisioner.go @@ -3,18 +3,12 @@ package provisioner import ( "context" "encoding/json" - "errors" "fmt" "github.com/rilldata/rill/admin/database" "go.uber.org/zap" ) -// ErrResourceTypeNotSupported should be returned by Provision if the provisioner does not support the requested resource type. -// -// By checking for this error, we can iterate over the chain of provisioners until we find a provisioner capable of provisioning the requested service. -var ErrResourceTypeNotSupported = errors.New("provisioner: resource type not supported") - // ProvisionerInitializer creates a new provisioner. type ProvisionerInitializer func(specJSON []byte, db database.DB, logger *zap.Logger) (Provisioner, error) @@ -35,6 +29,8 @@ type Provisioner interface { Type() string // Close is called when the provisioner is no longer needed. Close() error + // Supports indicates if it can provision the resource type. + Supports(rt ResourceType) bool // Provision provisions a new resource. // It may be called multiple times for the same ID if: // - the initial provision is interrupted, or diff --git a/admin/provisioner/resources.go b/admin/provisioner/resources.go index ab2ba43b39d..f545473c052 100644 --- a/admin/provisioner/resources.go +++ b/admin/provisioner/resources.go @@ -14,6 +14,14 @@ const ( ResourceTypeClickHouse ResourceType = "clickhouse" ) +func (r ResourceType) Valid() bool { + switch r { + case ResourceTypeRuntime, ResourceTypeClickHouse: + return true + } + return false +} + // RuntimeArgs describe the expected arguments for provisioning a runtime resource. type RuntimeArgs struct { Slots int `mapstructure:"slots"` diff --git a/admin/provisioner/static/static.go b/admin/provisioner/static/static.go index f2d276aabd3..841cd429019 100644 --- a/admin/provisioner/static/static.go +++ b/admin/provisioner/static/static.go @@ -62,16 +62,15 @@ func (p *StaticProvisioner) Type() string { return "static" } +func (p *StaticProvisioner) Supports(rt provisioner.ResourceType) bool { + return rt == provisioner.ResourceTypeRuntime +} + func (p *StaticProvisioner) Close() error { return nil } func (p *StaticProvisioner) Provision(ctx context.Context, r *provisioner.Resource, opts *provisioner.ResourceOptions) (*provisioner.Resource, error) { - // Can only provision runtime resources - if r.Type != provisioner.ResourceTypeRuntime { - return nil, provisioner.ErrResourceTypeNotSupported - } - // Parse args args, err := provisioner.NewRuntimeArgs(opts.Args) if err != nil { diff --git a/admin/server/projects.go b/admin/server/projects.go index abce4a28d74..1d36b477be5 100644 --- a/admin/server/projects.go +++ b/admin/server/projects.go @@ -121,6 +121,7 @@ func (s *Server) GetProject(ctx context.Context, req *adminv1.GetProjectRequest) permissions.ReadProdStatus = true permissions.ReadDev = true permissions.ReadDevStatus = true + permissions.ReadProvisionerResources = true permissions.ReadProjectMembers = true } diff --git a/admin/server/provision.go b/admin/server/provision.go new file mode 100644 index 00000000000..eed08a4ec36 --- /dev/null +++ b/admin/server/provision.go @@ -0,0 +1,108 @@ +package server + +import ( + "context" + "errors" + + "github.com/rilldata/rill/admin" + "github.com/rilldata/rill/admin/database" + "github.com/rilldata/rill/admin/provisioner" + "github.com/rilldata/rill/admin/server/auth" + adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + "github.com/rilldata/rill/runtime/pkg/observability" + "go.opentelemetry.io/otel/attribute" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +func (s *Server) Provision(ctx context.Context, req *adminv1.ProvisionRequest) (*adminv1.ProvisionResponse, error) { + observability.AddRequestAttributes(ctx, + attribute.String("args.deployment_id", req.DeploymentId), + attribute.String("args.type", req.Type), + attribute.String("args.name", req.Name), + ) + + // If the deployment ID is not provided, attempt to infer it from the access token. + claims := auth.GetClaims(ctx) + if req.DeploymentId == "" { + if claims.OwnerType() == auth.OwnerTypeDeployment { + req.DeploymentId = claims.OwnerID() + } else { + return nil, status.Error(codes.InvalidArgument, "missing deployment_id") + } + } + + depl, err := s.admin.DB.FindDeployment(ctx, req.DeploymentId) + if err != nil { + return nil, err + } + + proj, err := s.admin.DB.FindProject(ctx, depl.ProjectID) + if err != nil { + return nil, err + } + + permissions := auth.GetClaims(ctx).ProjectPermissions(ctx, proj.OrganizationID, proj.ID) + if !permissions.ManageProvisionerResources { + return nil, status.Error(codes.PermissionDenied, "not allowed to manage provisioner resources") + } + + // If the resource is OK, return it immediately. + res, err := s.admin.DB.FindProvisionerResourceByName(ctx, depl.ID, req.Type, req.Name) + if err != nil && !errors.Is(err, database.ErrNotFound) { + return nil, err + } + if res != nil && res.Status == database.ProvisionerResourceStatusOK { + return &adminv1.ProvisionResponse{ + Resource: provisionerResourceToPB(res), + }, nil + } + + // Try or retry provisioning the resource. + org, err := s.admin.DB.FindOrganization(ctx, proj.OrganizationID) + if err != nil { + return nil, err + } + typ := provisioner.ResourceType(req.Type) + if !typ.Valid() { + return nil, status.Errorf(codes.InvalidArgument, "invalid type %q", req.Type) + } + annotations := s.admin.NewDeploymentAnnotations(org, proj) + res, err = s.admin.Provision(ctx, &admin.ProvisionOptions{ + DeploymentID: depl.ID, + Type: typ, + Name: req.Name, + Provisioner: "", // Means it should find a suitable provisioner + Args: req.Args.AsMap(), + Annotations: annotations.ToMap(), + }) + if err != nil { + return nil, err + } + + return &adminv1.ProvisionResponse{ + Resource: provisionerResourceToPB(res), + }, nil +} + +func provisionerResourceToPB(i *database.ProvisionerResource) *adminv1.ProvisionerResource { + argsPB, err := structpb.NewStruct(i.Args) + if err != nil { + panic(err) + } + + configPB, err := structpb.NewStruct(i.Config) + if err != nil { + panic(err) + } + + return &adminv1.ProvisionerResource{ + Id: i.ID, + DeploymentId: i.DeploymentID, + Type: i.Type, + Name: i.Name, + Args: argsPB, + Config: configPB, + } +} diff --git a/docs/docs/manage/roles-permissions.md b/docs/docs/manage/roles-permissions.md index 21146f5c234..a289282368e 100644 --- a/docs/docs/manage/roles-permissions.md +++ b/docs/docs/manage/roles-permissions.md @@ -32,23 +32,25 @@ There are two roles available at the organization-level: **Viewer** and **Admin* There are two roles available at the project-level: **Viewer** and **Admin**. -| Permission | Description | Viewer | Admin | -| :------------------------- | :--------------------------------------------------------- | -----: | ----: | -| `read_project` | View basic info about the project | ✔ | ✔ | -| `manage_project` | Change project settings | | ✔ | -| `read_prod` | View dashboards deployed from the production (main) branch | ✔ | ✔ | -| `read_prod_status` | View logs for the production deployment | | ✔ | -| `manage_prod` | Trigger actions on the production deployment | | ✔ | -| `read_project_members` | View members of the project | | ✔ | -| `manage_project_members` | Add, remove or change roles of project members | | ✔ | -| `create_magic_auth_tokens` | Create shareable URLs | | ✔ | -| `manage_magic_auth_tokens` | Remove shareable URLs created by others | | ✔ | -| `create_reports` | Create and edit new scheduled reports | ✔ | ✔ | -| `manage_reports` | Edit and change scheduled reports created by others | | ✔ | -| `create_alerts` | Create and edit new alerts | ✔ | ✔ | -| `manage_alerts` | Edit and change alerts created by others | | ✔ | -| `create_bookmarks` | Create and edit new bookmarks | ✔ | ✔ | -| `manage_bookmarks` | Edit and change bookmarks created by others | | ✔ | +| Permission | Description | Viewer | Admin | +| :----------------------------- | :--------------------------------------------------------- | -----: | ----: | +| `read_project` | View basic info about the project | ✔ | ✔ | +| `manage_project` | Change project settings | | ✔ | +| `read_prod` | View dashboards deployed from the production (main) branch | ✔ | ✔ | +| `read_prod_status` | View logs for the production deployment | | ✔ | +| `manage_prod` | Trigger actions on the production deployment | | ✔ | +| `read_provisioner_resources` | View managed resources for the project | | ✔ | +| `manage_provisioner_resources` | Add or remove managed resources for the project | | ✔ | +| `read_project_members` | View members of the project | | ✔ | +| `manage_project_members` | Add, remove or change roles of project members | | ✔ | +| `create_magic_auth_tokens` | Create shareable URLs | | ✔ | +| `manage_magic_auth_tokens` | Remove shareable URLs created by others | | ✔ | +| `create_reports` | Create and edit new scheduled reports | ✔ | ✔ | +| `manage_reports` | Edit and change scheduled reports created by others | | ✔ | +| `create_alerts` | Create and edit new alerts | ✔ | ✔ | +| `manage_alerts` | Edit and change alerts created by others | | ✔ | +| `create_bookmarks` | Create and edit new bookmarks | ✔ | ✔ | +| `manage_bookmarks` | Edit and change bookmarks created by others | | ✔ |