diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go index 796d0baf432..8db9c033311 100644 --- a/cli/cmd/auth/login.go +++ b/cli/cmd/auth/login.go @@ -2,13 +2,17 @@ package auth import ( "context" + "errors" + "fmt" "strings" + "time" "github.com/rilldata/rill/cli/pkg/browser" "github.com/rilldata/rill/cli/pkg/cmdutil" "github.com/rilldata/rill/cli/pkg/deviceauth" "github.com/rilldata/rill/cli/pkg/dotrill" adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + "github.com/rilldata/rill/runtime/pkg/activity" "github.com/spf13/cobra" ) @@ -92,6 +96,30 @@ func Login(ctx context.Context, ch *cmdutil.Helper, redirectURL string) error { return nil } +func LoginWithTelemetry(ctx context.Context, ch *cmdutil.Helper, redirectURL string) error { + ch.PrintfBold("Please log in or sign up for Rill. Opening browser...\n") + time.Sleep(2 * time.Second) + + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventLoginStart) + + if err := Login(ctx, ch, redirectURL); err != nil { + if errors.Is(err, deviceauth.ErrAuthenticationTimedout) { + ch.PrintfWarn("Rill login has timed out as the code was not confirmed in the browser.\n") + ch.PrintfWarn("Run the command again.\n") + return nil + } else if errors.Is(err, deviceauth.ErrCodeRejected) { + ch.PrintfError("Login failed: Confirmation code rejected\n") + return nil + } + return fmt.Errorf("login failed: %w", err) + } + + // The cmdutil.Helper automatically detects the login and will add the user's ID to the telemetry. + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventLoginSuccess) + + return nil +} + func SelectOrgFlow(ctx context.Context, ch *cmdutil.Helper, interactive bool) error { client, err := ch.Client() if err != nil { @@ -105,7 +133,7 @@ func SelectOrgFlow(ctx context.Context, ch *cmdutil.Helper, interactive bool) er if len(res.Organizations) == 0 { if interactive { - ch.PrintfWarn("You are not part of an org. Run `rill org create` or `rill deploy` to create one.\n") + ch.PrintfWarn("You are not part of an org. Run `rill org create` to create one.\n") } return nil } diff --git a/cli/cmd/deploy/deploy.go b/cli/cmd/deploy/deploy.go index 31a913405d6..8a20d2623f7 100644 --- a/cli/cmd/deploy/deploy.go +++ b/cli/cmd/deploy/deploy.go @@ -1,1115 +1,21 @@ package deploy import ( - "context" - "errors" - "fmt" - "net/url" - "path/filepath" - "regexp" - "slices" - "strings" - "time" - - "github.com/AlecAivazis/survey/v2" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" - "github.com/go-git/go-git/v5/plumbing" - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/google/go-github/v50/github" - "github.com/rilldata/rill/admin/client" - "github.com/rilldata/rill/admin/pkg/urlutil" - "github.com/rilldata/rill/cli/cmd/auth" - "github.com/rilldata/rill/cli/cmd/org" - "github.com/rilldata/rill/cli/pkg/browser" "github.com/rilldata/rill/cli/pkg/cmdutil" - "github.com/rilldata/rill/cli/pkg/deviceauth" - "github.com/rilldata/rill/cli/pkg/dotrill" - "github.com/rilldata/rill/cli/pkg/dotrillcloud" - "github.com/rilldata/rill/cli/pkg/gitutil" - "github.com/rilldata/rill/cli/pkg/printer" - adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" - "github.com/rilldata/rill/runtime/compilers/rillv1" - "github.com/rilldata/rill/runtime/compilers/rillv1beta" - "github.com/rilldata/rill/runtime/pkg/activity" - "github.com/rilldata/rill/runtime/pkg/fileutil" "github.com/spf13/cobra" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" -) - -var ( - errInvalidProject = errors.New("invalid project") - nonSlugRegex = regexp.MustCompile(`[^\w-]`) -) - -const ( - pollTimeout = 10 * time.Minute - pollInterval = 5 * time.Second ) // DeployCmd is the guided tour for deploying rill projects to rill cloud. func DeployCmd(ch *cmdutil.Helper) *cobra.Command { - opts := &Options{} - deployCmd := &cobra.Command{ - Use: "deploy", + Use: "deploy []", Short: "Deploy project to Rill Cloud", RunE: func(cmd *cobra.Command, args []string) error { - return DeployFlow(cmd.Context(), ch, opts) - }, - } - - deployCmd.Flags().SortFlags = false - deployCmd.Flags().StringVar(&opts.GitPath, "path", ".", "Path to project repository (default: current directory)") // This can also be a remote .git URL (undocumented feature) - deployCmd.Flags().StringVar(&opts.SubPath, "subpath", "", "Relative path to project in the repository (for monorepos)") - deployCmd.Flags().StringVar(&opts.RemoteName, "remote", "", "Remote name (default: first Git remote)") - deployCmd.Flags().StringVar(&ch.Org, "org", ch.Org, "Org to deploy project in") - deployCmd.Flags().StringVar(&opts.Name, "project", "", "Project name (default: Git repo name)") - deployCmd.Flags().StringVar(&opts.Description, "description", "", "Project description") - deployCmd.Flags().BoolVar(&opts.Public, "public", false, "Make dashboards publicly accessible") - deployCmd.Flags().StringVar(&opts.Provisioner, "provisioner", "", "Project provisioner") - deployCmd.Flags().StringVar(&opts.ProdVersion, "prod-version", "latest", "Rill version (default: the latest release version)") - deployCmd.Flags().StringVar(&opts.ProdBranch, "prod-branch", "", "Git branch to deploy from (default: the default Git branch)") - deployCmd.Flags().IntVar(&opts.Slots, "prod-slots", 2, "Slots to allocate for production deployments") - deployCmd.Flags().BoolVarP(&opts.Upload, "upload", "u", false, "Upload project files to Rill managed storage instead of github") - if !ch.IsDev() { - if err := deployCmd.Flags().MarkHidden("prod-slots"); err != nil { - panic(err) - } - } - - // 2024-02-19: We have deprecated configuration of the OLAP DB using flags in favor of using rill.yaml. - // When the migration is complete, we can remove the flags as well as the admin-server support for them. - deployCmd.Flags().StringVar(&opts.DBDriver, "prod-db-driver", "duckdb", "Database driver") - deployCmd.Flags().StringVar(&opts.DBDSN, "prod-db-dsn", "", "Database driver configuration") - if err := deployCmd.Flags().MarkHidden("prod-db-driver"); err != nil { - panic(err) - } - if err := deployCmd.Flags().MarkHidden("prod-db-dsn"); err != nil { - panic(err) - } - - deployCmd.MarkFlagsMutuallyExclusive("upload", "subpath") - deployCmd.MarkFlagsMutuallyExclusive("upload", "remote") - deployCmd.MarkFlagsMutuallyExclusive("upload", "prod-branch") - return deployCmd -} - -type Options struct { - GitPath string - SubPath string - RemoteName string - Name string - Description string - Public bool - Provisioner string - ProdVersion string - ProdBranch string - DBDriver string - DBDSN string - Slots int - // Upload repo to rill managed storage instead of GitHub. - Upload bool -} - -func DeployFlow(ctx context.Context, ch *cmdutil.Helper, opts *Options) error { - // user chhose one-time uploads specifically - if opts.Upload { - return deployWithUploadFlow(ctx, ch, opts) - } - // The gitPath can be either a local path or a remote .git URL. - // Determine which it is. - var isLocalGitPath bool - var githubURL string - if opts.GitPath != "" { - u, err := url.Parse(opts.GitPath) - if err != nil || u.Scheme == "" { - isLocalGitPath = true - } else { - githubURL, err = gitutil.RemoteToGithubURL(opts.GitPath) - if err != nil { - return fmt.Errorf("failed to parse path as a Github remote: %w", err) - } - } - } - - // If the Git path is local, we'll do some extra steps to infer the githubURL. - var localGitPath, localProjectPath string - if isLocalGitPath { - var err error - localGitPath, localProjectPath, err = validateLocalProject(ctx, ch, opts) - if err != nil { - if errors.Is(err, errInvalidProject) { - return nil - } - return err - } - - // Extract the Git remote and infer the githubURL. - var remote *gitutil.Remote - remote, githubURL, err = gitutil.ExtractGitRemote(localGitPath, opts.RemoteName, false) - if err != nil { - // first check if user wants to connect to Github or use one time uploads - ch.Print("No git remote was found.\n") - ch.Print("You can connect to Github or use one-time uploads to deploy your project.\n") - // TODO : add a link to docs that explains difference between one time upload and github connection. - ok, confirmErr := cmdutil.ConfirmPrompt("Do you want to use one-time uploads?", "", true) - if confirmErr != nil { - return confirmErr - } - if ok { - opts.GitPath = localProjectPath - return deployWithUploadFlow(ctx, ch, opts) - } - - // It's not a valid remote for Github. We navigate user to login and then create repo for them. - silent := false - if !ch.IsAuthenticated() { - err := loginWithTelemetryAndGithubRedirect(ctx, ch, "") - if err != nil { - return fmt.Errorf("login failed with error: %w", err) - } - silent = true - } - if !errors.Is(err, gitutil.ErrGitRemoteNotFound) && !errors.Is(err, git.ErrRepositoryNotExists) { - return err - } - - if err := createGithubRepoFlow(ctx, ch, localGitPath, silent); err != nil { - return err - } - // In the rest of the flow we still check for the github access. - // It just adds some delay and no user action should be required and handles any improbable edge case where we don't have access to newly created repository. - // Also keeps the code clean. - remote, githubURL, err = gitutil.ExtractGitRemote(localGitPath, opts.RemoteName, false) - if err != nil { - return err - } - } - - // Error if the repository is not in sync with the remote - ok, err := repoInSyncFlow(ch, localGitPath, opts.ProdBranch, remote.Name) - if err != nil { - return err - } - if !ok { - ch.PrintfBold("You can run `rill deploy` again when you have pushed your local changes to the remote.\n") - return nil - } - } - - // We now have a githubURL. - - // Extract Github account and repo name from the githubURL - ghAccount, ghRepo, ok := gitutil.SplitGithubURL(githubURL) - if !ok { - ch.PrintfError("Invalid Github URL %q\n", githubURL) - return nil - } - - // If user is not authenticated, run login flow. - // To prevent opening the browser twice, we make it directly redirect to the Github flow. - silentGitFlow := false - if !ch.IsAuthenticated() { - silentGitFlow = true - if err := loginWithTelemetryAndGithubRedirect(ctx, ch, githubURL); err != nil { - return err - } - } - - adminClient, err := ch.Client() - if err != nil { - return err - } - - // Run flow for access to the Github remote (if necessary) - ghRes, err := githubFlow(ctx, ch, githubURL, silentGitFlow) - if err != nil { - return fmt.Errorf("failed Github flow: %w", err) - } - - if opts.ProdBranch == "" { - opts.ProdBranch = ghRes.DefaultBranch - } - - // If no project name was provided, default to Git repo name - if opts.Name == "" { - opts.Name = ghRepo - } - - // Set a default org for the user if necessary - // (If user is not in an org, we'll create one based on their Github account later in the flow.) - if ch.Org == "" { - if err := setDefaultOrg(ctx, adminClient, ch); err != nil { - return err - } - } - - // If no default org is set by now, it means the user is not in an org yet. - // We create a default org based on their Github account name. - if ch.Org == "" { - err := createOrgFlow(ctx, ch, ghAccount) - if err != nil { - return fmt.Errorf("org creation failed with error: %w", err) - } - ch.PrintfSuccess("Created org %q. Run `rill org edit` to change name if required.\n\n", ch.Org) - } else { - ch.PrintfBold("Using org %q.\n\n", ch.Org) - } - - // Check if a project matching githubURL already exists in this org - projects, err := ch.ProjectNamesByGithubURL(ctx, ch.Org, githubURL, opts.SubPath) - if err == nil && len(projects) != 0 { // ignoring error since this is just for a confirmation prompt - for _, p := range projects { - if strings.EqualFold(opts.Name, p) { - ch.PrintfWarn("Can't deploy project %q.\n", opts.Name) - ch.PrintfWarn("It is connected to Github and continuously deploys when you commit to %q\n", githubURL) - ch.PrintfWarn("If you want to deploy to a new project, use `rill deploy --project new-name`\n") - return nil - } - } - } - - // Create the project (automatically deploys prod branch) - res, err := createProjectFlow(ctx, ch, &adminv1.CreateProjectRequest{ - OrganizationName: ch.Org, - Name: opts.Name, - Description: opts.Description, - Provisioner: opts.Provisioner, - ProdVersion: opts.ProdVersion, - ProdOlapDriver: opts.DBDriver, - ProdOlapDsn: opts.DBDSN, - ProdSlots: int64(opts.Slots), - Subpath: opts.SubPath, - ProdBranch: opts.ProdBranch, - Public: opts.Public, - GithubUrl: githubURL, - }) - if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { - ch.PrintfError("You do not have the permissions needed to create a project in org %q. Please reach out to your Rill admin.\n", ch.Org) - return nil - } - return fmt.Errorf("create project failed with error %w", err) - } - - if localProjectPath != "" { - err = dotrillcloud.SetAll(localProjectPath, ch.AdminURL(), &dotrillcloud.Config{ - ProjectID: res.Project.Id, - }) - if err != nil { - return err - } - } - - // Success! - ch.PrintfSuccess("Created project \"%s/%s\". Use `rill project rename` to change name if required.\n\n", ch.Org, res.Project.Name) - ch.PrintfSuccess("Rill projects deploy continuously when you push changes to Github.\n") - - // If the Git path is local, we can parse the project and check if credentials are available for the connectors used by the project. - if isLocalGitPath { - variablesFlow(ctx, ch, localProjectPath, opts.SubPath, opts.Name) - } - - // Open browser - if res.Project.FrontendUrl != "" { - ch.PrintfSuccess("Your project can be accessed at: %s\n", res.Project.FrontendUrl) - if ch.Interactive { - ch.PrintfSuccess("Opening project in browser...\n") - time.Sleep(3 * time.Second) - _ = browser.Open(res.Project.FrontendUrl) - } - } - - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) - - return nil -} - -func deployWithUploadFlow(ctx context.Context, ch *cmdutil.Helper, opts *Options) error { - // If user is not authenticated, run login flow. - if !ch.IsAuthenticated() { - if err := loginWithTelemetry(ctx, ch, ""); err != nil { - return err - } - } - _, localProjectPath, err := validateLocalProject(ctx, ch, opts) - if err != nil { - return err - } - // If no project name was provided, default to dir name - if opts.Name == "" { - opts.Name = filepath.Base(localProjectPath) - } - - // Set a default org for the user if necessary - // (If user is not in an org, we'll create one based on their user name later in the flow.) - adminClient, err := ch.Client() - if err != nil { - return err - } - if ch.Org == "" { - if err := setDefaultOrg(ctx, adminClient, ch); err != nil { - return err - } - } - - // If no default org is set, it means the user is not in an org yet. - // We create a default org based on the user name. - if ch.Org == "" { - user, err := adminClient.GetCurrentUser(ctx, &adminv1.GetCurrentUserRequest{}) - if err != nil { - return err - } - // email can have other characters like . and + what to do ? - username, _, _ := strings.Cut(user.User.Email, "@") - username = nonSlugRegex.ReplaceAllString(username, "-") - err = createOrgFlow(ctx, ch, username) - if err != nil { - return fmt.Errorf("org creation failed with error: %w", err) - } - ch.PrintfSuccess("Created org %q. Run `rill org edit` to change name if required.\n\n", ch.Org) - } else { - ch.PrintfBold("Using org %q.\n\n", ch.Org) - } - - // get repo for current project - repo, _, err := cmdutil.RepoForProjectPath(localProjectPath) - if err != nil { - return err - } - - // check if the project with name already exists - projectExists, err := projectExists(ctx, ch, ch.Org, opts.Name) - if err != nil { - return err - } - if projectExists { - ch.Printer.Println("Found existing project. Starting re-upload.") - assetID, err := cmdutil.UploadRepo(ctx, repo, ch, ch.Org, opts.Name) - if err != nil { - return err - } - printer.ColorGreenBold.Printf("All files uploaded successfully.\n\n") - // Update the project - // Silently ignores other flags like description etc which are handled with project update. - res, err := adminClient.UpdateProject(ctx, &adminv1.UpdateProjectRequest{ - OrganizationName: ch.Org, - Name: opts.Name, - ArchiveAssetId: &assetID, - }) - if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { - ch.PrintfError("You do not have the permissions needed to update a project in org %q. Please reach out to your Rill admin.\n", ch.Org) - return nil - } - return fmt.Errorf("update project failed with error %w", err) - } - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) - ch.PrintfSuccess("Updated project \"%s/%s\".\n\n", ch.Org, res.Project.Name) - return nil - } - - // create a tar archive of the project and upload it - ch.Printer.Println("Starting upload.") - assetID, err := cmdutil.UploadRepo(ctx, repo, ch, ch.Org, opts.Name) - if err != nil { - return err - } - printer.ColorGreenBold.Printf("All files uploaded successfully.\n\n") - - // Create the project - res, err := adminClient.CreateProject(ctx, &adminv1.CreateProjectRequest{ - OrganizationName: ch.Org, - Name: opts.Name, - Description: opts.Description, - Provisioner: opts.Provisioner, - ProdVersion: opts.ProdVersion, - ProdOlapDriver: opts.DBDriver, - ProdOlapDsn: opts.DBDSN, - ProdSlots: int64(opts.Slots), - Public: opts.Public, - ArchiveAssetId: assetID, - }) - if err != nil { - if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { - ch.PrintfError("You do not have the permissions needed to create a project in org %q. Please reach out to your Rill admin.\n", ch.Org) - return nil - } - return fmt.Errorf("create project failed with error %w", err) - } - - err = dotrillcloud.SetAll(localProjectPath, ch.AdminURL(), &dotrillcloud.Config{ - ProjectID: res.Project.Id, - }) - if err != nil { - return err - } - - // Success! - ch.PrintfSuccess("Created project \"%s/%s\". Use `rill project rename` to change name if required.\n\n", ch.Org, res.Project.Name) - - // we parse the project and check if credentials are available for the connectors used by the project. - variablesFlow(ctx, ch, localProjectPath, opts.SubPath, opts.Name) - - // Open browser - if res.Project.FrontendUrl != "" { - ch.PrintfSuccess("Your project can be accessed at: %s\n", res.Project.FrontendUrl) - if ch.Interactive { - ch.PrintfSuccess("Opening project in browser...\n") - time.Sleep(3 * time.Second) - _ = browser.Open(res.Project.FrontendUrl) - } - } - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) - return nil -} - -func validateLocalProject(ctx context.Context, ch *cmdutil.Helper, opts *Options) (string, string, error) { - var localGitPath string - var err error - if opts.GitPath != "" { - localGitPath, err = fileutil.ExpandHome(opts.GitPath) - if err != nil { - return "", "", err - } - } - localGitPath, err = filepath.Abs(localGitPath) - if err != nil { - return "", "", err - } - - var localProjectPath string - if opts.SubPath == "" { - localProjectPath = localGitPath - } else { - localProjectPath = filepath.Join(localGitPath, opts.SubPath) - } - - // Verify that localProjectPath contains a Rill project. - if rillv1beta.HasRillProject(localProjectPath) { - return localGitPath, localProjectPath, nil - } - // If not, we still navigate user to login and then fail afterwards. - if !ch.IsAuthenticated() { - err := loginWithTelemetry(ctx, ch, "") - if err != nil { - ch.PrintfWarn("Login failed with error: %s\n", err.Error()) - } - fmt.Println() - } - - ch.PrintfWarn("Directory %q doesn't contain a valid Rill project.\n", localProjectPath) - ch.PrintfWarn("Run `rill deploy` from a Rill project directory or use `--path` to pass a project path.\n") - ch.PrintfWarn("Run `rill start` to initialize a new Rill project.\n") - return "", "", errInvalidProject -} - -// setDefaultOrg sets a default org for the user if user is part of any org. -func setDefaultOrg(ctx context.Context, c *client.Client, ch *cmdutil.Helper) error { - res, err := c.ListOrganizations(ctx, &adminv1.ListOrganizationsRequest{}) - if err != nil { - return fmt.Errorf("listing orgs failed: %w", err) - } - - if len(res.Organizations) == 1 { - ch.Org = res.Organizations[0].Name - if err := dotrill.SetDefaultOrg(ch.Org); err != nil { - return err - } - } else if len(res.Organizations) > 1 { - orgName, err := org.SwitchSelectFlow(res.Organizations) - if err != nil { - return fmt.Errorf("org selection failed %w", err) - } - - ch.Org = orgName - if err := dotrill.SetDefaultOrg(ch.Org); err != nil { - return err - } - } - return nil -} - -func loginWithTelemetryAndGithubRedirect(ctx context.Context, ch *cmdutil.Helper, remote string) error { - // NOTE: This is temporary until we migrate to a server that can host HTTP and gRPC on the same port. - authURL := ch.AdminURL() - if strings.Contains(authURL, "http://localhost:9090") { - authURL = "http://localhost:8080" - } - - var qry map[string]string - if remote != "" { - qry = map[string]string{"remote": remote} - } - - redirectURL, err := urlutil.WithQuery(urlutil.MustJoinURL(authURL, "github", "post-auth-redirect"), qry) - if err != nil { - return err - } - return loginWithTelemetry(ctx, ch, redirectURL) -} - -func loginWithTelemetry(ctx context.Context, ch *cmdutil.Helper, redirectURL string) error { - ch.PrintfBold("Please log in or sign up for Rill. Opening browser...\n") - time.Sleep(2 * time.Second) - - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventLoginStart) - - if err := auth.Login(ctx, ch, redirectURL); err != nil { - if errors.Is(err, deviceauth.ErrAuthenticationTimedout) { - ch.PrintfWarn("Rill login has timed out as the code was not confirmed in the browser.\n") - ch.PrintfWarn("Run `rill deploy` again.\n") - return nil - } else if errors.Is(err, deviceauth.ErrCodeRejected) { - ch.PrintfError("Login failed: Confirmation code rejected\n") - return nil - } - return fmt.Errorf("login failed: %w", err) - } - - // The cmdutil.Helper automatically detects the login and will add the user's ID to the telemetry. - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventLoginSuccess) - - return nil -} - -func createGithubRepoFlow(ctx context.Context, ch *cmdutil.Helper, localGitPath string, silent bool) error { - // Get the admin client - c, err := ch.Client() - if err != nil { - return err - } - - res, err := c.GetGithubUserStatus(ctx, &adminv1.GetGithubUserStatusRequest{}) - if err != nil { - return err - } + ch.Println("\nThis command is no longer supported. Please start rill developer and visit http://localhost:9009/deploy to deploy the project") - if !res.HasAccess { - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedStart) - - if res.GrantAccessUrl != "" { - // Print instructions to grant access - time.Sleep(3 * time.Second) - if silent { - ch.Print("If the browser did not redirect, ") - } - ch.Print("Open this URL in your browser to grant Rill access to Github:\n\n") - ch.Print("\t" + res.GrantAccessUrl + "\n\n") - - // Open browser if possible - if !silent { - _ = browser.Open(res.GrantAccessUrl) - } - } - } - - // Poll for permission granted - pollCtx, cancel := context.WithTimeout(ctx, pollTimeout) - defer cancel() - var pollRes *adminv1.GetGithubUserStatusResponse - for { - select { - case <-pollCtx.Done(): - return pollCtx.Err() - case <-time.After(pollInterval): - // Ready to check again. - } - - // Poll for access to the Github's user account - pollRes, err = c.GetGithubUserStatus(ctx, &adminv1.GetGithubUserStatusRequest{}) - if err != nil { - return err - } - if pollRes.HasAccess { - break - } - // Sleep and poll again - } - - // Emit success telemetry - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedSuccess) - - // get orgs on which rill github app is installed with write permission - var candidateOrgs []string - if pollRes.UserInstallationPermission == adminv1.GithubPermission_GITHUB_PERMISSION_WRITE { - candidateOrgs = append(candidateOrgs, pollRes.Account) - } - for o, p := range pollRes.OrganizationInstallationPermissions { - if p == adminv1.GithubPermission_GITHUB_PERMISSION_WRITE { - candidateOrgs = append(candidateOrgs, o) - } - } - - repoOwner := "" - if len(candidateOrgs) == 0 { - ch.PrintfWarn("\nRill does not have permissions to create a repository on your Github account. Visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) - return nil - } else if len(candidateOrgs) == 1 { - repoOwner = candidateOrgs[0] - ok, err := cmdutil.ConfirmPrompt(fmt.Sprintf("Rill will create a new repository in the Github account %q. Do you want to continue?", repoOwner), "", true) - if err != nil { - return err - } - if !ok { - ch.PrintfWarn("\nIf you want to deploy to another Github account, visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) return nil - } - } else { - repoOwner, err = cmdutil.SelectPrompt("Select a Github account for the new repository", candidateOrgs, candidateOrgs[0]) - if err != nil { - ch.PrintfWarn("\nIf you want to deploy to another Github account, visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) - return err - } - } - - // create and verify - githubRepository, err := createGithubRepository(ctx, ch, pollRes, localGitPath, repoOwner) - if err != nil { - return err - } - - printer.ColorGreenBold.Printf("\nSuccessfully created repository on %q\n\n", *githubRepository.HTMLURL) - ch.Print("Pushing local project to Github\n\n") - // init git repo - repo, err := git.PlainInitWithOptions(localGitPath, &git.PlainInitOptions{ - InitOptions: git.InitOptions{ - DefaultBranch: plumbing.NewBranchReferenceName("main"), - }, - Bare: false, - }) - if err != nil { - if !errors.Is(err, git.ErrRepositoryAlreadyExists) { - return fmt.Errorf("failed to init git repo: %w", err) - } - repo, err = git.PlainOpen(localGitPath) - if err != nil { - return fmt.Errorf("failed to open git repo: %w", err) - } - } - - wt, err := repo.Worktree() - if err != nil { - return fmt.Errorf("failed to get worktree: %w", err) - } - - // git add . - if err := wt.AddWithOptions(&git.AddOptions{All: true}); err != nil { - return fmt.Errorf("failed to add files to git: %w", err) - } - - // git commit -m - _, err = wt.Commit("Auto committed by Rill", &git.CommitOptions{All: true}) - if err != nil { - if !errors.Is(err, git.ErrEmptyCommit) { - return fmt.Errorf("failed to commit files to git: %w", err) - } - } - - // Create the remote - _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{*githubRepository.HTMLURL}}) - if err != nil { - return fmt.Errorf("failed to create remote: %w", err) - } - - // push the changes - if err := repo.PushContext(ctx, &git.PushOptions{Auth: &githttp.BasicAuth{Username: "x-access-token", Password: pollRes.AccessToken}}); err != nil { - return fmt.Errorf("failed to push to remote %q : %w", *githubRepository.HTMLURL, err) - } - - ch.Print("Successfully pushed your local project to Github\n\n") - return nil -} - -func createGithubRepository(ctx context.Context, ch *cmdutil.Helper, pollRes *adminv1.GetGithubUserStatusResponse, localGitPath, repoOwner string) (*github.Repository, error) { - githubClient := github.NewTokenClient(ctx, pollRes.AccessToken) - - defaultBranch := "main" - if repoOwner == pollRes.Account { - repoOwner = "" - } - repoName := filepath.Base(localGitPath) - private := true - - var githubRepo *github.Repository - var err error - for i := 1; i <= 10; i++ { - githubRepo, _, err = githubClient.Repositories.Create(ctx, repoOwner, &github.Repository{Name: &repoName, DefaultBranch: &defaultBranch, Private: &private}) - if err == nil { - break - } - if strings.Contains(err.Error(), "authentication") || strings.Contains(err.Error(), "credentials") { - // The users who installed app before we started including repo:write permissions need to accept permissions - // and then only we can create repositories. - return nil, fmt.Errorf("rill app does not have permissions to create github repository. Visit `https://github.com/settings/installations` to accept new permissions or reinstall app and try again") - } - - if !strings.Contains(err.Error(), "name already exists") { - return nil, fmt.Errorf("failed to create repository: %w", err) - } - - ch.Printf("Repository name %q is already taken\n", repoName) - repoName, err = cmdutil.InputPrompt("Please provide alternate name", "") - if err != nil { - return nil, err - } - } - if err != nil { - return nil, fmt.Errorf("failed to create repository: %w", err) - } - - // the create repo API does not wait for repo creation to be fully processed on server. Need to verify by making a get call in a loop - if repoOwner == "" { - repoOwner = pollRes.Account - } - - ch.Print("\nRequest submitted for creating repository. Checking completion status\n") - pollCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - for { - select { - case <-pollCtx.Done(): - return nil, pollCtx.Err() - case <-time.After(2 * time.Second): - // Ready to check again. - } - _, _, err := githubClient.Repositories.Get(ctx, repoOwner, repoName) - if err == nil { - break - } - } - - return githubRepo, nil -} - -func githubFlow(ctx context.Context, ch *cmdutil.Helper, githubURL string, silent bool) (*adminv1.GetGithubRepoStatusResponse, error) { - // Get the admin client - c, err := ch.Client() - if err != nil { - return nil, err - } - - // Check for access to the Github repo - res, err := c.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{ - GithubUrl: githubURL, - }) - if err != nil { - return nil, err - } - - // If the user has not already granted access, open browser and poll for access - if !res.HasAccess { - // Emit start telemetry - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedStart) - - // Print instructions to grant access - if !silent { - ch.Print("Rill projects deploy continuously when you push changes to Github.\n") - ch.Print("You need to grant Rill read only access to your repository on Github.\n\n") - time.Sleep(3 * time.Second) - ch.Print("Open this URL in your browser to grant Rill access to Github:\n\n") - ch.Print("\t" + res.GrantAccessUrl + "\n\n") - - // Open browser if possible - _ = browser.Open(res.GrantAccessUrl) - } else { - ch.Printf("Polling for Github access for: %q\n", githubURL) - ch.Printf("If the browser did not redirect, visit this URL to grant access: %q\n\n", res.GrantAccessUrl) - } - - // Poll for permission granted - pollCtx, cancel := context.WithTimeout(ctx, pollTimeout) - defer cancel() - for { - select { - case <-pollCtx.Done(): - return nil, pollCtx.Err() - case <-time.After(pollInterval): - // Ready to check again. - } - - // Poll for access to the Github URL - pollRes, err := c.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{ - GithubUrl: githubURL, - }) - if err != nil { - return nil, err - } - - if pollRes.HasAccess { - // Emit success telemetry - ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedSuccess) - - _, ghRepo, _ := gitutil.SplitGithubURL(githubURL) - ch.PrintfSuccess("You have connected to the %q project in Github.\n", ghRepo) - return pollRes, nil - } - - // Sleep and poll again - } - } - - return res, nil -} - -func createOrgFlow(ctx context.Context, ch *cmdutil.Helper, defaultName string) error { - c, err := ch.Client() - if err != nil { - return err - } - - res, err := c.CreateOrganization(ctx, &adminv1.CreateOrganizationRequest{ - Name: defaultName, - }) - if err != nil { - if !errMsgContains(err, "an org with that name already exists") { - return err - } - - ch.PrintfWarn("Rill organizations are derived from the owner of your Github repository.\n") - ch.PrintfWarn("The %q organization associated with your Github repository already exists.\n", defaultName) - ch.PrintfWarn("Contact your Rill admin to be added to your org or create a new organization below.\n") - - name, err := orgNamePrompt(ctx, ch) - if err != nil { - return err - } - - res, err = c.CreateOrganization(ctx, &adminv1.CreateOrganizationRequest{ - Name: name, - }) - if err != nil { - return err - } - } - - // Switching to the created org - ch.Org = res.Organization.Name - err = dotrill.SetDefaultOrg(ch.Org) - if err != nil { - return err - } - - return nil -} - -func orgNamePrompt(ctx context.Context, ch *cmdutil.Helper) (string, error) { - qs := []*survey.Question{ - { - Name: "name", - Prompt: &survey.Input{ - Message: "Enter an org name", - }, - Validate: func(any interface{}) error { - // Validate org name doesn't exist already - name := any.(string) - if name == "" { - return fmt.Errorf("empty name") - } - - exists, err := orgExists(ctx, ch, name) - if err != nil { - return fmt.Errorf("org name %q is already taken", name) - } - - if exists { - // this should always be true but adding this check from completeness POV - return fmt.Errorf("org with name %q already exists", name) - } - return nil - }, }, } - name := "" - if err := survey.Ask(qs, &name); err != nil { - return "", err - } - - return name, nil -} - -func orgExists(ctx context.Context, ch *cmdutil.Helper, name string) (bool, error) { - c, err := ch.Client() - if err != nil { - return false, err - } - - _, err = c.GetOrganization(ctx, &adminv1.GetOrganizationRequest{Name: name}) - if err != nil { - if st, ok := status.FromError(err); ok { - if st.Code() == codes.NotFound { - return false, nil - } - } - return false, err - } - return true, nil -} - -func createProjectFlow(ctx context.Context, ch *cmdutil.Helper, req *adminv1.CreateProjectRequest) (*adminv1.CreateProjectResponse, error) { - // Get the admin client - c, err := ch.Client() - if err != nil { - return nil, err - } - - // Create the project (automatically deploys prod branch) - res, err := c.CreateProject(ctx, req) - if err != nil { - if !errMsgContains(err, "a project with that name already exists in the org") { - return nil, err - } - - ch.PrintfWarn("Rill project names are derived from your Github repository name.\n") - ch.PrintfWarn("The %q project already exists under org %q. Please enter a different name.\n", req.Name, req.OrganizationName) - - // project name already exists, prompt for project name and create project with new name again - name, err := projectNamePrompt(ctx, ch, req.OrganizationName) - if err != nil { - return nil, err - } - - req.Name = name - return c.CreateProject(ctx, req) - } - return res, err -} - -func variablesFlow(ctx context.Context, ch *cmdutil.Helper, gitPath, subPath, projectName string) { - // Parse the project's connectors - repo, instanceID, err := cmdutil.RepoForProjectPath(gitPath) - if err != nil { - return - } - parser, err := rillv1.Parse(ctx, repo, instanceID, "prod", "duckdb") - if err != nil { - return - } - connectors := parser.AnalyzeConnectors(ctx) - for _, c := range connectors { - if c.Err != nil { - return - } - } - - // Remove the default DuckDB connector we always add - for i, c := range connectors { - if c.Name == "duckdb" { - connectors = slices.Delete(connectors, i, i+1) - break - } - } - - // Exit early if all connectors can be used anonymously - foundNotAnonymous := false - for _, c := range connectors { - if !c.AnonymousAccess { - foundNotAnonymous = true - } - } - if !foundNotAnonymous { - return - } - - ch.PrintfWarn("\nCould not access all connectors. Rill requires credentials for the following connectors:\n\n") - for _, c := range connectors { - if c.AnonymousAccess { - continue - } - fmt.Printf(" - %s", c.Name) - if len(c.Resources) == 1 { - fmt.Printf(" (used by %s)", c.Resources[0].Name.Name) - } else if len(c.Resources) > 1 { - fmt.Printf(" (used by %s and others)", c.Resources[0].Name.Name) - } - fmt.Print("\n") - } - if subPath == "" { - ch.PrintfWarn("\nRun `rill env configure --project %s` to provide credentials.\n\n", projectName) - } else { - ch.PrintfWarn("\nRun `rill env configure --project %s` from directory `%s` to provide credentials.\n\n", projectName, gitPath) - } - time.Sleep(2 * time.Second) -} - -func repoInSyncFlow(ch *cmdutil.Helper, gitPath, branch, remoteName string) (bool, error) { - syncStatus, err := gitutil.GetSyncStatus(gitPath, branch, remoteName) - if err != nil { - // ignore errors since check is best effort and can fail in multiple cases - return true, nil - } - - switch syncStatus { - case gitutil.SyncStatusUnspecified: - return true, nil - case gitutil.SyncStatusSynced: - return true, nil - case gitutil.SyncStatusModified: - ch.PrintfWarn("Some files have been locally modified. These changes will not be present in the deployed project.\n") - case gitutil.SyncStatusAhead: - ch.PrintfWarn("Local commits are not pushed to remote yet. These changes will not be present in the deployed project.\n") - } - - return cmdutil.ConfirmPrompt("Do you want to continue", "", true) -} - -func projectNamePrompt(ctx context.Context, ch *cmdutil.Helper, orgName string) (string, error) { - questions := []*survey.Question{ - { - Name: "name", - Prompt: &survey.Input{ - Message: "Enter a project name", - }, - Validate: func(any interface{}) error { - name := any.(string) - if name == "" { - return fmt.Errorf("empty name") - } - exists, err := projectExists(ctx, ch, orgName, name) - if err != nil { - return fmt.Errorf("project already exists at %s/%s", orgName, name) - } - if exists { - // this should always be true but adding this check from completeness POV - return fmt.Errorf("project with name %q already exists in the org", name) - } - return nil - }, - }, - } - - name := "" - if err := survey.Ask(questions, &name); err != nil { - return "", err - } - - return name, nil -} - -func projectExists(ctx context.Context, ch *cmdutil.Helper, orgName, projectName string) (bool, error) { - c, err := ch.Client() - if err != nil { - return false, err - } - - _, err = c.GetProject(ctx, &adminv1.GetProjectRequest{OrganizationName: orgName, Name: projectName}) - if err != nil { - if st, ok := status.FromError(err); ok { - if st.Code() == codes.NotFound { - return false, nil - } - } - return false, err - } - return true, nil -} - -func errMsgContains(err error, msg string) bool { - if st, ok := status.FromError(err); ok && st != nil { - return strings.Contains(st.Message(), msg) - } - return false + return deployCmd } diff --git a/cli/cmd/devtool/seed.go b/cli/cmd/devtool/seed.go index 18b0257beac..abf5785e29b 100644 --- a/cli/cmd/devtool/seed.go +++ b/cli/cmd/devtool/seed.go @@ -3,7 +3,7 @@ package devtool import ( "fmt" - "github.com/rilldata/rill/cli/cmd/deploy" + "github.com/rilldata/rill/cli/cmd/project" "github.com/rilldata/rill/cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -19,12 +19,11 @@ func SeedCmd(ch *cmdutil.Helper) *cobra.Command { return fmt.Errorf("seeding not available for preset %q", preset) } - return deploy.DeployFlow(cmd.Context(), ch, &deploy.Options{ + return project.ConnectGithubFlow(cmd.Context(), ch, &project.DeployOpts{ GitPath: "https://github.com/rilldata/rill-examples.git", SubPath: "rill-openrtb-prog-ads", Name: "rill-openrtb-prog-ads", ProdVersion: "latest", - DBDriver: "duckdb", Slots: 2, }) }, diff --git a/cli/cmd/org/switch.go b/cli/cmd/org/switch.go index 4dfb59a835b..d32859a489c 100644 --- a/cli/cmd/org/switch.go +++ b/cli/cmd/org/switch.go @@ -1,6 +1,7 @@ package org import ( + "context" "fmt" "github.com/rilldata/rill/cli/pkg/cmdutil" @@ -75,3 +76,34 @@ func SwitchSelectFlow(orgs []*adminv1.Organization) (string, error) { return cmdutil.SelectPrompt("Select default org.", orgNames, org) } + +// SetDefaultOrg sets a default org for the user if user is part of any org. +func SetDefaultOrg(ctx context.Context, ch *cmdutil.Helper) error { + c, err := ch.Client() + if err != nil { + return err + } + + res, err := c.ListOrganizations(ctx, &adminv1.ListOrganizationsRequest{}) + if err != nil { + return fmt.Errorf("listing orgs failed: %w", err) + } + + if len(res.Organizations) == 1 { + ch.Org = res.Organizations[0].Name + if err := dotrill.SetDefaultOrg(ch.Org); err != nil { + return err + } + } else if len(res.Organizations) > 1 { + orgName, err := SwitchSelectFlow(res.Organizations) + if err != nil { + return fmt.Errorf("org selection failed %w", err) + } + + ch.Org = orgName + if err := dotrill.SetDefaultOrg(ch.Org); err != nil { + return err + } + } + return nil +} diff --git a/cli/cmd/project/connect_github.go b/cli/cmd/project/connect_github.go new file mode 100644 index 00000000000..4f831ae430e --- /dev/null +++ b/cli/cmd/project/connect_github.go @@ -0,0 +1,652 @@ +package project + +import ( + "context" + "errors" + "fmt" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/google/go-github/v50/github" + "github.com/rilldata/rill/admin/pkg/urlutil" + "github.com/rilldata/rill/cli/cmd/auth" + "github.com/rilldata/rill/cli/cmd/org" + "github.com/rilldata/rill/cli/pkg/browser" + "github.com/rilldata/rill/cli/pkg/cmdutil" + "github.com/rilldata/rill/cli/pkg/dotrillcloud" + "github.com/rilldata/rill/cli/pkg/gitutil" + "github.com/rilldata/rill/cli/pkg/local" + "github.com/rilldata/rill/cli/pkg/printer" + adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + "github.com/rilldata/rill/runtime/pkg/activity" + "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + pollTimeout = 10 * time.Minute + pollInterval = 5 * time.Second +) + +func GitPushCmd(ch *cmdutil.Helper) *cobra.Command { + opts := &DeployOpts{} + + deployCmd := &cobra.Command{ + Use: "connect-github", + Short: "Deploy project to Rill Cloud by pulling project files from a git repository", + RunE: func(cmd *cobra.Command, args []string) error { + return ConnectGithubFlow(cmd.Context(), ch, opts) + }, + } + + deployCmd.Flags().SortFlags = false + deployCmd.Flags().StringVar(&opts.GitPath, "path", ".", "Path to project repository (default: current directory)") // This can also be a remote .git URL (undocumented feature) + deployCmd.Flags().StringVar(&opts.SubPath, "subpath", "", "Relative path to project in the repository (for monorepos)") + deployCmd.Flags().StringVar(&opts.RemoteName, "remote", "", "Remote name (default: first Git remote)") + deployCmd.Flags().StringVar(&ch.Org, "org", ch.Org, "Org to deploy project in") + deployCmd.Flags().StringVar(&opts.Name, "name", "", "Project name (default: Git repo name)") + deployCmd.Flags().StringVar(&opts.Description, "description", "", "Project description") + deployCmd.Flags().BoolVar(&opts.Public, "public", false, "Make dashboards publicly accessible") + deployCmd.Flags().StringVar(&opts.Provisioner, "provisioner", "", "Project provisioner") + deployCmd.Flags().StringVar(&opts.ProdVersion, "prod-version", "latest", "Rill version (default: the latest release version)") + deployCmd.Flags().StringVar(&opts.ProdBranch, "prod-branch", "", "Git branch to deploy from (default: the default Git branch)") + deployCmd.Flags().IntVar(&opts.Slots, "prod-slots", 2, "Slots to allocate for production deployments") + if !ch.IsDev() { + if err := deployCmd.Flags().MarkHidden("prod-slots"); err != nil { + panic(err) + } + } + + return deployCmd +} + +func ConnectGithubFlow(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts) error { + // The gitPath can be either a local path or a remote .git URL. + // Determine which it is. + var isLocalGitPath bool + var githubURL string + if opts.GitPath != "" { + u, err := url.Parse(opts.GitPath) + if err != nil || u.Scheme == "" { + isLocalGitPath = true + } else { + githubURL, err = gitutil.RemoteToGithubURL(opts.GitPath) + if err != nil { + return fmt.Errorf("failed to parse path as a Github remote: %w", err) + } + } + } + + // If the Git path is local, we'll do some extra steps to infer the githubURL. + var localGitPath, localProjectPath string + if isLocalGitPath { + var err error + localGitPath, localProjectPath, err = ValidateLocalProject(ctx, ch, opts.GitPath, opts.SubPath) + if err != nil { + if errors.Is(err, ErrInvalidProject) { + return nil + } + return err + } + + // Extract the Git remote and infer the githubURL. + var remote *gitutil.Remote + remote, githubURL, err = gitutil.ExtractGitRemote(localGitPath, opts.RemoteName, false) + if err != nil { + // first check if user wants to connect to Github or use one time uploads + ch.Print("No git remote was found.\n") + ok, confirmErr := cmdutil.ConfirmPrompt("Do you want to create a repo?", "", true) + if confirmErr != nil { + return confirmErr + } + if !ok { + return nil + } + + // It's not a valid remote for Github. We navigate user to login and then create repo for them. + silent := false + if !ch.IsAuthenticated() { + err := loginWithTelemetryAndGithubRedirect(ctx, ch, "") + if err != nil { + return fmt.Errorf("login failed with error: %w", err) + } + silent = true + } + if !errors.Is(err, gitutil.ErrGitRemoteNotFound) && !errors.Is(err, git.ErrRepositoryNotExists) { + return err + } + + if err := createGithubRepoFlow(ctx, ch, localGitPath, silent); err != nil { + return err + } + // In the rest of the flow we still check for the github access. + // It just adds some delay and no user action should be required and handles any improbable edge case where we don't have access to newly created repository. + // Also keeps the code clean. + remote, githubURL, err = gitutil.ExtractGitRemote(localGitPath, opts.RemoteName, false) + if err != nil { + return err + } + } + + // Error if the repository is not in sync with the remote + ok, err := repoInSyncFlow(ch, localGitPath, opts.ProdBranch, remote.Name) + if err != nil { + return err + } + if !ok { + ch.PrintfBold("You can run `rill project connect-github` again when you have pushed your local changes to the remote.\n") + return nil + } + } + + // We now have a githubURL. + + // Extract Github account and repo name from the githubURL + ghAccount, ghRepo, ok := gitutil.SplitGithubURL(githubURL) + if !ok { + ch.PrintfError("Invalid Github URL %q\n", githubURL) + return nil + } + + // If user is not authenticated, run login flow. + // To prevent opening the browser twice, we make it directly redirect to the Github flow. + silentGitFlow := false + if !ch.IsAuthenticated() { + silentGitFlow = true + if err := loginWithTelemetryAndGithubRedirect(ctx, ch, githubURL); err != nil { + return err + } + } + + // Run flow for access to the Github remote (if necessary) + ghRes, err := githubFlow(ctx, ch, githubURL, silentGitFlow) + if err != nil { + return fmt.Errorf("failed Github flow: %w", err) + } + + if opts.ProdBranch == "" { + opts.ProdBranch = ghRes.DefaultBranch + } + + // If no project name was provided, default to Git repo name + if opts.Name == "" { + opts.Name = ghRepo + } + + // Set a default org for the user if necessary + // (If user is not in an org, we'll create one based on their Github account later in the flow.) + if ch.Org == "" { + if err := org.SetDefaultOrg(ctx, ch); err != nil { + return err + } + } + + // If no default org is set by now, it means the user is not in an org yet. + // We create a default org based on their Github account name. + if ch.Org == "" { + err := createOrgFlow(ctx, ch, ghAccount) + if err != nil { + return fmt.Errorf("org creation failed with error: %w", err) + } + ch.PrintfSuccess("Created org %q. Run `rill org edit` to change name if required.\n\n", ch.Org) + } else { + ch.PrintfBold("Using org %q.\n\n", ch.Org) + } + + // Check if a project matching githubURL already exists in this org + projects, err := ch.ProjectNamesByGithubURL(ctx, ch.Org, githubURL, opts.SubPath) + if err == nil && len(projects) != 0 { // ignoring error since this is just for a confirmation prompt + for _, p := range projects { + if strings.EqualFold(opts.Name, p) { + ch.PrintfWarn("Can't deploy project %q.\n", opts.Name) + ch.PrintfWarn("It is connected to Github and continuously deploys when you commit to %q\n", githubURL) + ch.PrintfWarn("If you want to deploy to a new project, use `rill project connect-github --name new-name`\n") + return nil + } + } + } + + // Create the project (automatically deploys prod branch) + res, err := createProjectFlow(ctx, ch, &adminv1.CreateProjectRequest{ + OrganizationName: ch.Org, + Name: opts.Name, + Description: opts.Description, + Provisioner: opts.Provisioner, + ProdVersion: opts.ProdVersion, + ProdOlapDriver: local.DefaultOLAPDriver, + ProdOlapDsn: local.DefaultOLAPDSN, + ProdSlots: int64(opts.Slots), + Subpath: opts.SubPath, + ProdBranch: opts.ProdBranch, + Public: opts.Public, + GithubUrl: githubURL, + }) + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { + ch.PrintfError("You do not have the permissions needed to create a project in org %q. Please reach out to your Rill admin.\n", ch.Org) + return nil + } + return fmt.Errorf("create project failed with error %w", err) + } + + if localProjectPath != "" { + err = dotrillcloud.SetAll(localProjectPath, ch.AdminURL(), &dotrillcloud.Config{ + ProjectID: res.Project.Id, + }) + if err != nil { + return err + } + } + + // Success! + ch.PrintfSuccess("Created project \"%s/%s\". Use `rill project rename` to change name if required.\n\n", ch.Org, res.Project.Name) + ch.PrintfSuccess("Rill projects deploy continuously when you push changes to Github.\n") + + // If the Git path is local, we can parse the project and check if credentials are available for the connectors used by the project. + if isLocalGitPath { + variablesFlow(ctx, ch, localProjectPath, opts.SubPath, opts.Name) + } + + // Open browser + if res.Project.FrontendUrl != "" { + ch.PrintfSuccess("Your project can be accessed at: %s\n", res.Project.FrontendUrl) + if ch.Interactive { + ch.PrintfSuccess("Opening project in browser...\n") + time.Sleep(3 * time.Second) + _ = browser.Open(res.Project.FrontendUrl) + } + } + + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) + + return nil +} + +func loginWithTelemetryAndGithubRedirect(ctx context.Context, ch *cmdutil.Helper, remote string) error { + // NOTE: This is temporary until we migrate to a server that can host HTTP and gRPC on the same port. + authURL := ch.AdminURL() + if strings.Contains(authURL, "http://localhost:9090") { + authURL = "http://localhost:8080" + } + + var qry map[string]string + if remote != "" { + qry = map[string]string{"remote": remote} + } + + redirectURL, err := urlutil.WithQuery(urlutil.MustJoinURL(authURL, "github", "post-auth-redirect"), qry) + if err != nil { + return err + } + return auth.LoginWithTelemetry(ctx, ch, redirectURL) +} + +func createGithubRepoFlow(ctx context.Context, ch *cmdutil.Helper, localGitPath string, silent bool) error { + // Get the admin client + c, err := ch.Client() + if err != nil { + return err + } + + res, err := c.GetGithubUserStatus(ctx, &adminv1.GetGithubUserStatusRequest{}) + if err != nil { + return err + } + + if !res.HasAccess { + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedStart) + + if res.GrantAccessUrl != "" { + // Print instructions to grant access + time.Sleep(3 * time.Second) + if silent { + ch.Print("If the browser did not redirect, ") + } + ch.Print("Open this URL in your browser to grant Rill access to Github:\n\n") + ch.Print("\t" + res.GrantAccessUrl + "\n\n") + + // Open browser if possible + if !silent { + _ = browser.Open(res.GrantAccessUrl) + } + } + } + + // Poll for permission granted + pollCtx, cancel := context.WithTimeout(ctx, pollTimeout) + defer cancel() + var pollRes *adminv1.GetGithubUserStatusResponse + for { + select { + case <-pollCtx.Done(): + return pollCtx.Err() + case <-time.After(pollInterval): + // Ready to check again. + } + + // Poll for access to the Github's user account + pollRes, err = c.GetGithubUserStatus(ctx, &adminv1.GetGithubUserStatusRequest{}) + if err != nil { + return err + } + if pollRes.HasAccess { + break + } + // Sleep and poll again + } + + // Emit success telemetry + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedSuccess) + + // get orgs on which rill github app is installed with write permission + var candidateOrgs []string + if pollRes.UserInstallationPermission == adminv1.GithubPermission_GITHUB_PERMISSION_WRITE { + candidateOrgs = append(candidateOrgs, pollRes.Account) + } + for o, p := range pollRes.OrganizationInstallationPermissions { + if p == adminv1.GithubPermission_GITHUB_PERMISSION_WRITE { + candidateOrgs = append(candidateOrgs, o) + } + } + + repoOwner := "" + if len(candidateOrgs) == 0 { + ch.PrintfWarn("\nRill does not have permissions to create a repository on your Github account. Visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) + return nil + } else if len(candidateOrgs) == 1 { + repoOwner = candidateOrgs[0] + ok, err := cmdutil.ConfirmPrompt(fmt.Sprintf("Rill will create a new repository in the Github account %q. Do you want to continue?", repoOwner), "", true) + if err != nil { + return err + } + if !ok { + ch.PrintfWarn("\nIf you want to deploy to another Github account, visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) + return nil + } + } else { + repoOwner, err = cmdutil.SelectPrompt("Select a Github account for the new repository", candidateOrgs, candidateOrgs[0]) + if err != nil { + ch.PrintfWarn("\nIf you want to deploy to another Github account, visit this URL to grant access: %s\n", pollRes.GrantAccessUrl) + return err + } + } + + // create and verify + githubRepository, err := createGithubRepository(ctx, ch, pollRes, localGitPath, repoOwner) + if err != nil { + return err + } + + printer.ColorGreenBold.Printf("\nSuccessfully created repository on %q\n\n", *githubRepository.HTMLURL) + ch.Print("Pushing local project to Github\n\n") + // init git repo + repo, err := git.PlainInitWithOptions(localGitPath, &git.PlainInitOptions{ + InitOptions: git.InitOptions{ + DefaultBranch: plumbing.NewBranchReferenceName("main"), + }, + Bare: false, + }) + if err != nil { + if !errors.Is(err, git.ErrRepositoryAlreadyExists) { + return fmt.Errorf("failed to init git repo: %w", err) + } + repo, err = git.PlainOpen(localGitPath) + if err != nil { + return fmt.Errorf("failed to open git repo: %w", err) + } + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + // git add . + if err := wt.AddWithOptions(&git.AddOptions{All: true}); err != nil { + return fmt.Errorf("failed to add files to git: %w", err) + } + + // git commit -m + _, err = wt.Commit("Auto committed by Rill", &git.CommitOptions{All: true}) + if err != nil { + if !errors.Is(err, git.ErrEmptyCommit) { + return fmt.Errorf("failed to commit files to git: %w", err) + } + } + + // Create the remote + _, err = repo.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{*githubRepository.HTMLURL}}) + if err != nil { + return fmt.Errorf("failed to create remote: %w", err) + } + + // push the changes + if err := repo.PushContext(ctx, &git.PushOptions{Auth: &githttp.BasicAuth{Username: "x-access-token", Password: pollRes.AccessToken}}); err != nil { + return fmt.Errorf("failed to push to remote %q : %w", *githubRepository.HTMLURL, err) + } + + ch.Print("Successfully pushed your local project to Github\n\n") + return nil +} + +func createGithubRepository(ctx context.Context, ch *cmdutil.Helper, pollRes *adminv1.GetGithubUserStatusResponse, localGitPath, repoOwner string) (*github.Repository, error) { + githubClient := github.NewTokenClient(ctx, pollRes.AccessToken) + + defaultBranch := "main" + if repoOwner == pollRes.Account { + repoOwner = "" + } + repoName := filepath.Base(localGitPath) + private := true + + var githubRepo *github.Repository + var err error + for i := 1; i <= 10; i++ { + githubRepo, _, err = githubClient.Repositories.Create(ctx, repoOwner, &github.Repository{Name: &repoName, DefaultBranch: &defaultBranch, Private: &private}) + if err == nil { + break + } + if strings.Contains(err.Error(), "authentication") || strings.Contains(err.Error(), "credentials") { + // The users who installed app before we started including repo:write permissions need to accept permissions + // and then only we can create repositories. + return nil, fmt.Errorf("rill app does not have permissions to create github repository. Visit `https://github.com/settings/installations` to accept new permissions or reinstall app and try again") + } + + if !strings.Contains(err.Error(), "name already exists") { + return nil, fmt.Errorf("failed to create repository: %w", err) + } + + ch.Printf("Repository name %q is already taken\n", repoName) + repoName, err = cmdutil.InputPrompt("Please provide alternate name", "") + if err != nil { + return nil, err + } + } + if err != nil { + return nil, fmt.Errorf("failed to create repository: %w", err) + } + + // the create repo API does not wait for repo creation to be fully processed on server. Need to verify by making a get call in a loop + if repoOwner == "" { + repoOwner = pollRes.Account + } + + ch.Print("\nRequest submitted for creating repository. Checking completion status\n") + pollCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + for { + select { + case <-pollCtx.Done(): + return nil, pollCtx.Err() + case <-time.After(2 * time.Second): + // Ready to check again. + } + _, _, err := githubClient.Repositories.Get(ctx, repoOwner, repoName) + if err == nil { + break + } + } + + return githubRepo, nil +} + +func githubFlow(ctx context.Context, ch *cmdutil.Helper, githubURL string, silent bool) (*adminv1.GetGithubRepoStatusResponse, error) { + // Get the admin client + c, err := ch.Client() + if err != nil { + return nil, err + } + + // Check for access to the Github repo + res, err := c.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{ + GithubUrl: githubURL, + }) + if err != nil { + return nil, err + } + + // If the user has not already granted access, open browser and poll for access + if !res.HasAccess { + // Emit start telemetry + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedStart) + + // Print instructions to grant access + if !silent { + ch.Print("Rill projects deploy continuously when you push changes to Github.\n") + ch.Print("You need to grant Rill read only access to your repository on Github.\n\n") + time.Sleep(3 * time.Second) + ch.Print("Open this URL in your browser to grant Rill access to Github:\n\n") + ch.Print("\t" + res.GrantAccessUrl + "\n\n") + + // Open browser if possible + _ = browser.Open(res.GrantAccessUrl) + } else { + ch.Printf("Polling for Github access for: %q\n", githubURL) + ch.Printf("If the browser did not redirect, visit this URL to grant access: %q\n\n", res.GrantAccessUrl) + } + + // Poll for permission granted + pollCtx, cancel := context.WithTimeout(ctx, pollTimeout) + defer cancel() + for { + select { + case <-pollCtx.Done(): + return nil, pollCtx.Err() + case <-time.After(pollInterval): + // Ready to check again. + } + + // Poll for access to the Github URL + pollRes, err := c.GetGithubRepoStatus(ctx, &adminv1.GetGithubRepoStatusRequest{ + GithubUrl: githubURL, + }) + if err != nil { + return nil, err + } + + if pollRes.HasAccess { + // Emit success telemetry + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventGithubConnectedSuccess) + + _, ghRepo, _ := gitutil.SplitGithubURL(githubURL) + ch.PrintfSuccess("You have connected to the %q project in Github.\n", ghRepo) + return pollRes, nil + } + + // Sleep and poll again + } + } + + return res, nil +} + +func createProjectFlow(ctx context.Context, ch *cmdutil.Helper, req *adminv1.CreateProjectRequest) (*adminv1.CreateProjectResponse, error) { + // Get the admin client + c, err := ch.Client() + if err != nil { + return nil, err + } + + // Create the project (automatically deploys prod branch) + res, err := c.CreateProject(ctx, req) + if err != nil { + if !errMsgContains(err, "a project with that name already exists in the org") { + return nil, err + } + + ch.PrintfWarn("Rill project names are derived from your Github repository name.\n") + ch.PrintfWarn("The %q project already exists under org %q. Please enter a different name.\n", req.Name, req.OrganizationName) + + // project name already exists, prompt for project name and create project with new name again + name, err := projectNamePrompt(ctx, ch, req.OrganizationName) + if err != nil { + return nil, err + } + + req.Name = name + return c.CreateProject(ctx, req) + } + return res, err +} + +func repoInSyncFlow(ch *cmdutil.Helper, gitPath, branch, remoteName string) (bool, error) { + syncStatus, err := gitutil.GetSyncStatus(gitPath, branch, remoteName) + if err != nil { + // ignore errors since check is best effort and can fail in multiple cases + return true, nil + } + + switch syncStatus { + case gitutil.SyncStatusUnspecified: + return true, nil + case gitutil.SyncStatusSynced: + return true, nil + case gitutil.SyncStatusModified: + ch.PrintfWarn("Some files have been locally modified. These changes will not be present in the deployed project.\n") + case gitutil.SyncStatusAhead: + ch.PrintfWarn("Local commits are not pushed to remote yet. These changes will not be present in the deployed project.\n") + } + + return cmdutil.ConfirmPrompt("Do you want to continue", "", true) +} + +func projectNamePrompt(ctx context.Context, ch *cmdutil.Helper, orgName string) (string, error) { + questions := []*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{ + Message: "Enter a project name", + }, + Validate: func(any interface{}) error { + name := any.(string) + if name == "" { + return fmt.Errorf("empty name") + } + exists, err := projectExists(ctx, ch, orgName, name) + if err != nil { + return fmt.Errorf("project already exists at %s/%s", orgName, name) + } + if exists { + // this should always be true but adding this check from completeness POV + return fmt.Errorf("project with name %q already exists in the org", name) + } + return nil + }, + }, + } + + name := "" + if err := survey.Ask(questions, &name); err != nil { + return "", err + } + + return name, nil +} diff --git a/cli/cmd/project/project.go b/cli/cmd/project/project.go index 85054b1e1b6..2a33cdc5f08 100644 --- a/cli/cmd/project/project.go +++ b/cli/cmd/project/project.go @@ -29,6 +29,8 @@ func ProjectCmd(ch *cmdutil.Helper) *cobra.Command { projectCmd.AddCommand(DescribeCmd(ch)) projectCmd.AddCommand(RefreshCmd(ch)) projectCmd.AddCommand(JwtCmd(ch)) + projectCmd.AddCommand(GitPushCmd(ch)) + projectCmd.AddCommand(UploadCmd(ch)) return projectCmd } diff --git a/cli/cmd/project/upload.go b/cli/cmd/project/upload.go new file mode 100644 index 00000000000..43526b56b95 --- /dev/null +++ b/cli/cmd/project/upload.go @@ -0,0 +1,435 @@ +package project + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/rilldata/rill/cli/cmd/auth" + "github.com/rilldata/rill/cli/cmd/org" + "github.com/rilldata/rill/cli/pkg/browser" + "github.com/rilldata/rill/cli/pkg/cmdutil" + "github.com/rilldata/rill/cli/pkg/dotrill" + "github.com/rilldata/rill/cli/pkg/dotrillcloud" + "github.com/rilldata/rill/cli/pkg/local" + "github.com/rilldata/rill/cli/pkg/printer" + adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1" + "github.com/rilldata/rill/runtime/compilers/rillv1" + "github.com/rilldata/rill/runtime/compilers/rillv1beta" + "github.com/rilldata/rill/runtime/pkg/activity" + "github.com/rilldata/rill/runtime/pkg/fileutil" + "github.com/spf13/cobra" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var ( + nonSlugRegex = regexp.MustCompile(`[^\w-]`) + ErrInvalidProject = errors.New("invalid project") +) + +type DeployOpts struct { + GitPath string + SubPath string + RemoteName string + Name string + Description string + Public bool + Provisioner string + ProdVersion string + ProdBranch string + Slots int +} + +func UploadCmd(ch *cmdutil.Helper) *cobra.Command { + opts := &DeployOpts{} + + deployCmd := &cobra.Command{ + Use: "upload", + Short: "Deploy project to Rill Cloud by uploading the project files", + RunE: func(cmd *cobra.Command, args []string) error { + return deployWithUploadFlow(cmd.Context(), ch, opts) + }, + } + + deployCmd.Flags().SortFlags = false + deployCmd.Flags().StringVar(&opts.GitPath, "path", ".", "Path to project repository (default: current directory)") // This can also be a remote .git URL (undocumented feature) + deployCmd.Flags().StringVar(&opts.SubPath, "subpath", "", "Relative path to project in the repository (for monorepos)") + deployCmd.Flags().StringVar(&ch.Org, "org", ch.Org, "Org to deploy project in") + deployCmd.Flags().StringVar(&opts.Name, "project", "", "Project name (default: Git repo name)") + deployCmd.Flags().StringVar(&opts.Description, "description", "", "Project description") + deployCmd.Flags().BoolVar(&opts.Public, "public", false, "Make dashboards publicly accessible") + deployCmd.Flags().StringVar(&opts.Provisioner, "provisioner", "", "Project provisioner") + deployCmd.Flags().StringVar(&opts.ProdVersion, "prod-version", "latest", "Rill version (default: the latest release version)") + deployCmd.Flags().StringVar(&opts.ProdBranch, "prod-branch", "", "Git branch to deploy from (default: the default Git branch)") + deployCmd.Flags().IntVar(&opts.Slots, "prod-slots", 2, "Slots to allocate for production deployments") + if !ch.IsDev() { + if err := deployCmd.Flags().MarkHidden("prod-slots"); err != nil { + panic(err) + } + } + + return deployCmd +} + +func ValidateLocalProject(ctx context.Context, ch *cmdutil.Helper, gitPath, subPath string) (string, string, error) { + var localGitPath string + var err error + if gitPath != "" { + localGitPath, err = fileutil.ExpandHome(gitPath) + if err != nil { + return "", "", err + } + } + localGitPath, err = filepath.Abs(localGitPath) + if err != nil { + return "", "", err + } + + var localProjectPath string + if subPath == "" { + localProjectPath = localGitPath + } else { + localProjectPath = filepath.Join(localGitPath, subPath) + } + + // Verify that localProjectPath contains a Rill project. + if rillv1beta.HasRillProject(localProjectPath) { + return localGitPath, localProjectPath, nil + } + // If not, we still navigate user to login and then fail afterwards. + if !ch.IsAuthenticated() { + err := auth.LoginWithTelemetry(ctx, ch, "") + if err != nil { + ch.PrintfWarn("Login failed with error: %s\n", err.Error()) + } + fmt.Println() + } + + ch.PrintfWarn("Directory %q doesn't contain a valid Rill project.\n", localProjectPath) + ch.PrintfWarn("Run `rill project upload` from a Rill project directory or use `--path` to pass a project path.\n") + ch.PrintfWarn("Run `rill start` to initialize a new Rill project.\n") + return "", "", ErrInvalidProject +} + +func deployWithUploadFlow(ctx context.Context, ch *cmdutil.Helper, opts *DeployOpts) error { + // If user is not authenticated, run login flow. + if !ch.IsAuthenticated() { + if err := auth.LoginWithTelemetry(ctx, ch, ""); err != nil { + return err + } + } + _, localProjectPath, err := ValidateLocalProject(ctx, ch, opts.GitPath, opts.SubPath) + if err != nil { + return err + } + // If no project name was provided, default to dir name + if opts.Name == "" { + opts.Name = filepath.Base(localProjectPath) + } + + // Set a default org for the user if necessary + // (If user is not in an org, we'll create one based on their user name later in the flow.) + adminClient, err := ch.Client() + if err != nil { + return err + } + if ch.Org == "" { + if err := org.SetDefaultOrg(ctx, ch); err != nil { + return err + } + } + + // If no default org is set, it means the user is not in an org yet. + // We create a default org based on the user name. + if ch.Org == "" { + user, err := adminClient.GetCurrentUser(ctx, &adminv1.GetCurrentUserRequest{}) + if err != nil { + return err + } + // email can have other characters like . and + what to do ? + username, _, _ := strings.Cut(user.User.Email, "@") + username = nonSlugRegex.ReplaceAllString(username, "-") + err = createOrgFlow(ctx, ch, username) + if err != nil { + return fmt.Errorf("org creation failed with error: %w", err) + } + ch.PrintfSuccess("Created org %q. Run `rill org edit` to change name if required.\n\n", ch.Org) + } else { + ch.PrintfBold("Using org %q.\n\n", ch.Org) + } + + // get repo for current project + repo, _, err := cmdutil.RepoForProjectPath(localProjectPath) + if err != nil { + return err + } + + // check if the project with name already exists + projectExists, err := projectExists(ctx, ch, ch.Org, opts.Name) + if err != nil { + return err + } + if projectExists { + ch.Printer.Println("Found existing project. Starting re-upload.") + assetID, err := cmdutil.UploadRepo(ctx, repo, ch, ch.Org, opts.Name) + if err != nil { + return err + } + printer.ColorGreenBold.Printf("All files uploaded successfully.\n\n") + // Update the project + // Silently ignores other flags like description etc which are handled with project update. + res, err := adminClient.UpdateProject(ctx, &adminv1.UpdateProjectRequest{ + OrganizationName: ch.Org, + Name: opts.Name, + ArchiveAssetId: &assetID, + }) + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { + ch.PrintfError("You do not have the permissions needed to update a project in org %q. Please reach out to your Rill admin.\n", ch.Org) + return nil + } + return fmt.Errorf("update project failed with error %w", err) + } + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) + ch.PrintfSuccess("Updated project \"%s/%s\".\n\n", ch.Org, res.Project.Name) + return nil + } + + // create a tar archive of the project and upload it + ch.Printer.Println("Starting upload.") + assetID, err := cmdutil.UploadRepo(ctx, repo, ch, ch.Org, opts.Name) + if err != nil { + return err + } + printer.ColorGreenBold.Printf("All files uploaded successfully.\n\n") + + // Create the project + res, err := adminClient.CreateProject(ctx, &adminv1.CreateProjectRequest{ + OrganizationName: ch.Org, + Name: opts.Name, + Description: opts.Description, + Provisioner: opts.Provisioner, + ProdVersion: opts.ProdVersion, + ProdOlapDriver: local.DefaultOLAPDriver, + ProdOlapDsn: local.DefaultOLAPDSN, + ProdSlots: int64(opts.Slots), + Public: opts.Public, + ArchiveAssetId: assetID, + }) + if err != nil { + if s, ok := status.FromError(err); ok && s.Code() == codes.PermissionDenied { + ch.PrintfError("You do not have the permissions needed to create a project in org %q. Please reach out to your Rill admin.\n", ch.Org) + return nil + } + return fmt.Errorf("create project failed with error %w", err) + } + + err = dotrillcloud.SetAll(localProjectPath, ch.AdminURL(), &dotrillcloud.Config{ + ProjectID: res.Project.Id, + }) + if err != nil { + return err + } + + // Success! + ch.PrintfSuccess("Created project \"%s/%s\". Use `rill project rename` to change name if required.\n\n", ch.Org, res.Project.Name) + + // we parse the project and check if credentials are available for the connectors used by the project. + variablesFlow(ctx, ch, localProjectPath, opts.SubPath, opts.Name) + + // Open browser + if res.Project.FrontendUrl != "" { + ch.PrintfSuccess("Your project can be accessed at: %s\n", res.Project.FrontendUrl) + if ch.Interactive { + ch.PrintfSuccess("Opening project in browser...\n") + time.Sleep(3 * time.Second) + _ = browser.Open(res.Project.FrontendUrl) + } + } + ch.Telemetry(ctx).RecordBehavioralLegacy(activity.BehavioralEventDeploySuccess) + return nil +} + +func variablesFlow(ctx context.Context, ch *cmdutil.Helper, gitPath, subPath, projectName string) { + // Parse the project's connectors + repo, instanceID, err := cmdutil.RepoForProjectPath(gitPath) + if err != nil { + return + } + parser, err := rillv1.Parse(ctx, repo, instanceID, "prod", "duckdb") + if err != nil { + return + } + connectors := parser.AnalyzeConnectors(ctx) + for _, c := range connectors { + if c.Err != nil { + return + } + } + + // Remove the default DuckDB connector we always add + for i, c := range connectors { + if c.Name == "duckdb" { + connectors = slices.Delete(connectors, i, i+1) + break + } + } + + // Exit early if all connectors can be used anonymously + foundNotAnonymous := false + for _, c := range connectors { + if !c.AnonymousAccess { + foundNotAnonymous = true + } + } + if !foundNotAnonymous { + return + } + + ch.PrintfWarn("\nCould not access all connectors. Rill requires credentials for the following connectors:\n\n") + for _, c := range connectors { + if c.AnonymousAccess { + continue + } + fmt.Printf(" - %s", c.Name) + if len(c.Resources) == 1 { + fmt.Printf(" (used by %s)", c.Resources[0].Name.Name) + } else if len(c.Resources) > 1 { + fmt.Printf(" (used by %s and others)", c.Resources[0].Name.Name) + } + fmt.Print("\n") + } + if subPath == "" { + ch.PrintfWarn("\nRun `rill env configure --project %s` to provide credentials.\n\n", projectName) + } else { + ch.PrintfWarn("\nRun `rill env configure --project %s` from directory `%s` to provide credentials.\n\n", projectName, gitPath) + } + time.Sleep(2 * time.Second) +} + +func createOrgFlow(ctx context.Context, ch *cmdutil.Helper, defaultName string) error { + c, err := ch.Client() + if err != nil { + return err + } + + res, err := c.CreateOrganization(ctx, &adminv1.CreateOrganizationRequest{ + Name: defaultName, + }) + if err != nil { + if !errMsgContains(err, "an org with that name already exists") { + return err + } + + ch.PrintfWarn("Rill organizations are derived from the owner of your Github repository.\n") + ch.PrintfWarn("The %q organization associated with your Github repository already exists.\n", defaultName) + ch.PrintfWarn("Contact your Rill admin to be added to your org or create a new organization below.\n") + + name, err := orgNamePrompt(ctx, ch) + if err != nil { + return err + } + + res, err = c.CreateOrganization(ctx, &adminv1.CreateOrganizationRequest{ + Name: name, + }) + if err != nil { + return err + } + } + + // Switching to the created org + ch.Org = res.Organization.Name + err = dotrill.SetDefaultOrg(ch.Org) + if err != nil { + return err + } + + return nil +} + +func orgNamePrompt(ctx context.Context, ch *cmdutil.Helper) (string, error) { + qs := []*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{ + Message: "Enter an org name", + }, + Validate: func(any interface{}) error { + // Validate org name doesn't exist already + name := any.(string) + if name == "" { + return fmt.Errorf("empty name") + } + + exists, err := orgExists(ctx, ch, name) + if err != nil { + return fmt.Errorf("org name %q is already taken", name) + } + + if exists { + // this should always be true but adding this check from completeness POV + return fmt.Errorf("org with name %q already exists", name) + } + return nil + }, + }, + } + + name := "" + if err := survey.Ask(qs, &name); err != nil { + return "", err + } + + return name, nil +} + +func orgExists(ctx context.Context, ch *cmdutil.Helper, name string) (bool, error) { + c, err := ch.Client() + if err != nil { + return false, err + } + + _, err = c.GetOrganization(ctx, &adminv1.GetOrganizationRequest{Name: name}) + if err != nil { + if st, ok := status.FromError(err); ok { + if st.Code() == codes.NotFound { + return false, nil + } + } + return false, err + } + return true, nil +} + +func projectExists(ctx context.Context, ch *cmdutil.Helper, orgName, projectName string) (bool, error) { + c, err := ch.Client() + if err != nil { + return false, err + } + + _, err = c.GetProject(ctx, &adminv1.GetProjectRequest{OrganizationName: orgName, Name: projectName}) + if err != nil { + if st, ok := status.FromError(err); ok { + if st.Code() == codes.NotFound { + return false, nil + } + } + return false, err + } + return true, nil +} + +func errMsgContains(err error, msg string) bool { + if st, ok := status.FromError(err); ok && st != nil { + return strings.Contains(st.Message(), msg) + } + return false +} diff --git a/cli/pkg/cmdutil/helper.go b/cli/pkg/cmdutil/helper.go index 33c5a18a40b..783119d8318 100644 --- a/cli/pkg/cmdutil/helper.go +++ b/cli/pkg/cmdutil/helper.go @@ -222,7 +222,7 @@ func (h *Helper) Telemetry(ctx context.Context) *activity.Client { return h.activityClient } -// CurrentUser fetches the ID of the current user. +// CurrentUserID fetches the ID of the current user. // It caches the result in ~/.rill, along with a hash of the current admin token for cache invalidation in case of login/logout. func (h *Helper) CurrentUserID(ctx context.Context) (string, error) { if h.AdminToken() == "" { diff --git a/cli/pkg/local/app.go b/cli/pkg/local/app.go index 6dc1ad90413..1099fe22c4e 100644 --- a/cli/pkg/local/app.go +++ b/cli/pkg/local/app.go @@ -400,7 +400,7 @@ func (a *App) Serve(httpPort, grpcPort int, enableUI, openBrowser, readonly bool } // Open the browser when health check succeeds - go a.pollServer(ctx, httpPort, enableUI && openBrowser, secure) + go a.PollServer(ctx, httpPort, enableUI && openBrowser, secure) // Run the server err = group.Wait() @@ -411,7 +411,7 @@ func (a *App) Serve(httpPort, grpcPort int, enableUI, openBrowser, readonly bool return nil } -func (a *App) pollServer(ctx context.Context, httpPort int, openOnHealthy, secure bool) { +func (a *App) PollServer(ctx context.Context, httpPort int, openOnHealthy, secure bool) { client := &http.Client{Timeout: time.Second} scheme := "http" diff --git a/docs/docs/deploy/existing-project/deploy-from-ci.md b/docs/docs/deploy/existing-project/deploy-from-ci.md index d40fd641ed8..9d13eeb54d2 100644 --- a/docs/docs/deploy/existing-project/deploy-from-ci.md +++ b/docs/docs/deploy/existing-project/deploy-from-ci.md @@ -31,7 +31,7 @@ deploy-rill-cloud: script: - curl -L -o $HOME/rill.zip https://cdn.rilldata.com/rill/latest/rill_linux_amd64.zip - unzip -d $HOME $HOME/rill.zip - - $HOME/rill deploy --upload --org my-org-name --project my-project-name --interactive=false --api-token $RILL_SERVICE_TOKEN + - $HOME/rill project upload --org my-org-name --name my-project-name --interactive=false --api-token $RILL_SERVICE_TOKEN ``` Your Rill project should now automatically deploy to `ui.rilldata.com/my-org-name/my-project-name` each time changes are pushed to Gitlab! diff --git a/docs/docs/deploy/existing-project/github-101.md b/docs/docs/deploy/existing-project/github-101.md index 91e413c91f2..7db98c7083c 100644 --- a/docs/docs/deploy/existing-project/github-101.md +++ b/docs/docs/deploy/existing-project/github-101.md @@ -76,7 +76,7 @@ For any larger changes, we would strongly suggest developing locally to see the ## Deploying Rill -Now that your dashboards should be fully synced to GitHub, version controlled, and available to be edited by others. To make the dashboard fully cloud-enabled, you can return to your terminal and run ```rill deploy```. With everything synced, you should now be able to create a [new organization](/manage/project-management#organization) within Rill Cloud and push your dashboard to the cloud for shared collaboration. Any future changes should automatically be present in your deployed dashboards on [Rill Cloud](https://ui.rilldata.com) once committed and new dashboards will appear automatically as well (no deploy command needed). More details on [deployment here](../existing-project/existing-project.md). +Now that your dashboards should be fully synced to GitHub, version controlled, and available to be edited by others. To make the dashboard fully cloud-enabled, you can return to your terminal and run ```rill project connect-github```. With everything synced, you should now be able to create a [new organization](/manage/project-management#organization) within Rill Cloud and push your dashboard to the cloud for shared collaboration. Any future changes should automatically be present in your deployed dashboards on [Rill Cloud](https://ui.rilldata.com) once committed and new dashboards will appear automatically as well (no deploy command needed). More details on [deployment here](../existing-project/existing-project.md). :::info Have further questions? We'd love to hear from you! diff --git a/docs/docs/home/get-started.md b/docs/docs/home/get-started.md index dd357f2d825..b6cf7feed63 100644 --- a/docs/docs/home/get-started.md +++ b/docs/docs/home/get-started.md @@ -57,7 +57,7 @@ Once complete, you can deploy any Rill project with a dashboard to an authentica 2. Setup continuous deployment from Github to Rill Cloud: ``` cd my-rill-project - rill deploy + rill project upload ``` ## Share your dashboard diff --git a/docs/docs/reference/connectors/athena.md b/docs/docs/reference/connectors/athena.md index 32ed3e84742..ffbf2bc8957 100644 --- a/docs/docs/reference/connectors/athena.md +++ b/docs/docs/reference/connectors/athena.md @@ -48,7 +48,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires you to explicitly provide an access key and secret for an AWS service account with access to Athena used in your project. +When deploying a project to Rill Cloud, Rill requires you to explicitly provide an access key and secret for an AWS service account with access to Athena used in your project. If you subsequently add sources that require new credentials (or if you had simply input the wrong credentials during the initial deploy), you can update the credentials used by Rill Cloud by running: ``` diff --git a/docs/docs/reference/connectors/azure.md b/docs/docs/reference/connectors/azure.md index 26ce8d6cd7b..768b314b56d 100644 --- a/docs/docs/reference/connectors/azure.md +++ b/docs/docs/reference/connectors/azure.md @@ -84,7 +84,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires either an Azure Blob Storage connection string, Azure Storage Key, or Azure Storage SAS token to be explicitly provided for the Azure Blob Storage containers used in your project. +When deploying a project to Rill Cloud, Rill requires either an Azure Blob Storage connection string, Azure Storage Key, or Azure Storage SAS token to be explicitly provided for the Azure Blob Storage containers used in your project. When you first deploy a project using `rill deploy`, you will be prompted to provide credentials for the remote sources in your project that require authentication. diff --git a/docs/docs/reference/connectors/bigquery.md b/docs/docs/reference/connectors/bigquery.md index 9e6d89f42ff..2e438ccdb2f 100644 --- a/docs/docs/reference/connectors/bigquery.md +++ b/docs/docs/reference/connectors/bigquery.md @@ -31,7 +31,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires you to explicitly provide a JSON key file for a Google Cloud service account with access to BigQuery used in your project. +When deploying a project to Rill Cloud, Rill requires you to explicitly provide a JSON key file for a Google Cloud service account with access to BigQuery used in your project. When you first deploy a project using `rill deploy`, you will be prompted to provide credentials for the remote sources in your project that require authentication. diff --git a/docs/docs/reference/connectors/gcs.md b/docs/docs/reference/connectors/gcs.md index 015613c52c1..cb4747c6ba8 100644 --- a/docs/docs/reference/connectors/gcs.md +++ b/docs/docs/reference/connectors/gcs.md @@ -49,7 +49,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires a JSON key file to be explicitly provided for a Google Cloud service account with appropriate read access / permissions to the buckets used in your project. +When deploying a project to Rill Cloud, Rill requires a JSON key file to be explicitly provided for a Google Cloud service account with appropriate read access / permissions to the buckets used in your project. When you first deploy a project using `rill deploy`, you will be prompted to provide credentials for the remote sources in your project that require authentication. diff --git a/docs/docs/reference/connectors/motherduck.md b/docs/docs/reference/connectors/motherduck.md index 90d2abbd1f6..8977cb3eaa8 100644 --- a/docs/docs/reference/connectors/motherduck.md +++ b/docs/docs/reference/connectors/motherduck.md @@ -42,7 +42,7 @@ If you plan to deploy a project containing a DuckDB source to Rill Cloud, it is ### Cloud deployment -Once a project with a DuckDB source has been deployed using `rill deploy`, Rill Cloud will need to be able to have access to and retrieve the underlying persisted database file. In most cases, this means that the corresponding DuckDB database file should be included within a directory in your Git repository, which will allow you to specify a relative path in your source definition (from the project root). +Once a project with a DuckDB source has been deployed using, Rill Cloud will need to be able to have access to and retrieve the underlying persisted database file. In most cases, this means that the corresponding DuckDB database file should be included within a directory in your Git repository, which will allow you to specify a relative path in your source definition (from the project root). :::warning When Using An External DuckDB Database @@ -80,7 +80,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ### Cloud deployment -Once a project with a MotherDuck source has been deployed using `rill deploy`, Rill requires you to explicitly provide the motherduck token using the following command: +Once a project with a MotherDuck source has been deployed, Rill requires you to explicitly provide the motherduck token using the following command: ``` rill env configure diff --git a/docs/docs/reference/connectors/mysql.md b/docs/docs/reference/connectors/mysql.md index 4b341e698a1..ac0bebca88c 100644 --- a/docs/docs/reference/connectors/mysql.md +++ b/docs/docs/reference/connectors/mysql.md @@ -64,7 +64,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -Once a project with a MySQL source has been deployed using `rill deploy`, Rill requires you to explicitly provide the connection string using the following command: +Once a project with a MySQL source has been deployed, Rill requires you to explicitly provide the connection string using the following command: ``` rill env configure diff --git a/docs/docs/reference/connectors/postgres.md b/docs/docs/reference/connectors/postgres.md index ad903543c9e..402b94f6aaa 100644 --- a/docs/docs/reference/connectors/postgres.md +++ b/docs/docs/reference/connectors/postgres.md @@ -62,7 +62,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -Once a project with a PostgreSQL source has been deployed using `rill deploy`, Rill requires you to explicitly provide the connection string using the following command: +Once a project with a PostgreSQL source has been deployed, Rill requires you to explicitly provide the connection string using the following command: ``` rill env configure diff --git a/docs/docs/reference/connectors/redshift.md b/docs/docs/reference/connectors/redshift.md index 5a1b120c5f9..2e670ec9e57 100644 --- a/docs/docs/reference/connectors/redshift.md +++ b/docs/docs/reference/connectors/redshift.md @@ -48,7 +48,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires you to explicitly provide an access key and secret for an AWS service account with access to the Redshift database used in your project. +When deploying a project to Rill Cloud, Rill requires you to explicitly provide an access key and secret for an AWS service account with access to the Redshift database used in your project. When you first deploy a project using `rill deploy`, you will be prompted to provide credentials for the remote sources in your project that require authentication. If you subsequently add sources that require new credentials (or if you had simply input the wrong credentials during the initial deploy), you can update the credentials used by Rill Cloud by running: ``` diff --git a/docs/docs/reference/connectors/s3.md b/docs/docs/reference/connectors/s3.md index 8f85fca3f10..2d7404dc185 100644 --- a/docs/docs/reference/connectors/s3.md +++ b/docs/docs/reference/connectors/s3.md @@ -51,7 +51,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -When deploying a project to Rill Cloud (i.e. `rill deploy`), Rill requires an access and secret key to be explicitly provided for an AWS service account with appropriate read access / permissions to the S3 buckets used in your project. +When deploying a project to Rill Cloud, Rill requires an access and secret key to be explicitly provided for an AWS service account with appropriate read access / permissions to the S3 buckets used in your project. When you first deploy a project using `rill deploy`, you will be prompted to provide credentials for the remote sources in your project that require authentication. diff --git a/docs/docs/reference/connectors/salesforce.md b/docs/docs/reference/connectors/salesforce.md index 92ccbb194cf..d6a35c6e684 100644 --- a/docs/docs/reference/connectors/salesforce.md +++ b/docs/docs/reference/connectors/salesforce.md @@ -51,7 +51,7 @@ If this project has already been deployed to Rill Cloud and credentials have bee ## Cloud deployment -Once a project having a Salesforce source has been deployed using `rill deploy`, Rill requires you to explicitly provide the credentials using the following command: +Once a project having a Salesforce source has been deployed, Rill requires you to explicitly provide the credentials using the following command: ``` rill env configure diff --git a/web-common/src/metrics/initMetrics.ts b/web-common/src/metrics/initMetrics.ts index ecbc16901c8..4e2a5c1d140 100644 --- a/web-common/src/metrics/initMetrics.ts +++ b/web-common/src/metrics/initMetrics.ts @@ -6,7 +6,7 @@ import { BehaviourEventFactory } from "@rilldata/web-common/metrics/service/Beha import { MetricsService } from "@rilldata/web-common/metrics/service/MetricsService"; import { ProductHealthEventFactory } from "@rilldata/web-common/metrics/service/ProductHealthEventFactory"; import { RillIntakeClient } from "@rilldata/web-common/metrics/service/RillIntakeClient"; -import type { V1RuntimeGetConfig } from "@rilldata/web-common/runtime-client/manual-clients"; +import { GetMetadataResponse } from "@rilldata/web-common/proto/gen/rill/local/v1/api_pb"; import { get } from "svelte/store"; import { ActiveEventHandler } from "./ActiveEventHandler"; import { collectCommonUserFields } from "./collectCommonUserFields"; @@ -18,7 +18,7 @@ export let actionEvent: ActiveEventHandler; export let behaviourEvent: BehaviourEventHandler; export let errorEventHandler: ErrorEventHandler; -export async function initMetrics(localConfig: V1RuntimeGetConfig) { +export async function initMetrics(localConfig: GetMetadataResponse) { metricsService = new MetricsService(new RillIntakeClient(), [ new ProductHealthEventFactory(), new BehaviourEventFactory(), @@ -32,7 +32,7 @@ export async function initMetrics(localConfig: V1RuntimeGetConfig) { errorEventHandler = new ErrorEventHandler( metricsService, commonUserMetrics, - localConfig.is_dev, + localConfig.isDev, () => mapScreenName(get(page)), ); } diff --git a/web-common/src/metrics/service/MetricsService.ts b/web-common/src/metrics/service/MetricsService.ts index 55094e1f6b5..fe212ce9eed 100644 --- a/web-common/src/metrics/service/MetricsService.ts +++ b/web-common/src/metrics/service/MetricsService.ts @@ -4,7 +4,7 @@ import type { PickActionFunctions, } from "@rilldata/web-common/metrics/service/ServiceBase"; import { getActionMethods } from "@rilldata/web-common/metrics/service/ServiceBase"; -import type { V1RuntimeGetConfig } from "@rilldata/web-common/runtime-client/manual-clients"; +import { GetMetadataResponse } from "@rilldata/web-common/proto/gen/rill/local/v1/api_pb"; import MD5 from "crypto-js/md5"; import { v4 as uuidv4 } from "uuid"; import type { BehaviourEventFactory } from "./BehaviourEventFactory"; @@ -55,19 +55,19 @@ export class MetricsService }); } - public loadLocalFields(localConfig: V1RuntimeGetConfig) { - const projectPathParts = localConfig.project_path.split("/"); + public loadLocalFields(localConfig: GetMetadataResponse) { + const projectPathParts = localConfig.projectPath.split("/"); this.commonFields = { service_name: "web-local", app_name: "rill-developer", - install_id: localConfig.install_id, + install_id: localConfig.installId, client_id: this.getOrSetClientID(), - build_id: localConfig.build_commit, + build_id: localConfig.buildCommit, version: localConfig.version, - is_dev: localConfig.is_dev, + is_dev: localConfig.isDev, project_id: MD5(projectPathParts[projectPathParts.length - 1]).toString(), - user_id: localConfig.user_id, - analytics_enabled: localConfig.analytics_enabled, + user_id: localConfig.userId, + analytics_enabled: localConfig.analyticsEnabled, mode: localConfig.readonly ? "read-only" : "edit", }; } @@ -108,7 +108,7 @@ export class MetricsService let clientId = localStorage.getItem(ClientIDStorageKey); if (clientId) return clientId; - clientId = uuidv4() as string; + clientId = uuidv4(); localStorage.setItem(ClientIDStorageKey, clientId); return clientId; } diff --git a/web-common/src/runtime-client/manual-clients.ts b/web-common/src/runtime-client/manual-clients.ts index 0e7c36a2e4b..62ee1402b12 100644 --- a/web-common/src/runtime-client/manual-clients.ts +++ b/web-common/src/runtime-client/manual-clients.ts @@ -2,26 +2,6 @@ import httpClient from "@rilldata/web-common/runtime-client/http-client"; -export type V1RuntimeGetConfig = { - instance_id: string; - grpc_port: number; - install_id: string; - project_path: string; - user_id: string; - version: string; - build_commit: string; - is_dev: boolean; - analytics_enabled: boolean; - readonly: boolean; -}; -export const runtimeServiceGetConfig = - async (): Promise => { - return httpClient({ - url: "/local/config", - method: "GET", - }); - }; - export const runtimeServiceFileUpload = async ( instanceId: string, filePath: string, diff --git a/web-local/src/routes/+layout.svelte b/web-local/src/routes/+layout.svelte index 22ebebd9f7b..6d740a2fca1 100644 --- a/web-local/src/routes/+layout.svelte +++ b/web-local/src/routes/+layout.svelte @@ -2,12 +2,12 @@ import { initPylonWidget } from "@rilldata/web-common/features/help/initPylonWidget"; import { RillTheme } from "@rilldata/web-common/layout"; import { featureFlags } from "@rilldata/web-common/features/feature-flags"; + import { localServiceGetMetadata } from "@rilldata/web-common/runtime-client/local-service"; import { initializeNodeStoreContexts } from "@rilldata/web-local/lib/application-state-stores/initialize-node-store-contexts"; import { errorEventHandler } from "@rilldata/web-common/metrics/initMetrics"; import type { Query } from "@tanstack/query-core"; import { QueryClientProvider } from "@tanstack/svelte-query"; import type { AxiosError } from "axios"; - import { runtimeServiceGetConfig } from "@rilldata/web-common/runtime-client/manual-clients"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; import type { ApplicationBuildMetadata } from "@rilldata/web-common/layout/build-metadata"; import { initMetrics } from "@rilldata/web-common/metrics/initMetrics"; @@ -33,9 +33,8 @@ initPylonWidget(); let removeJavascriptListeners: () => void; - onMount(async () => { - const config = await runtimeServiceGetConfig(); + const config = await localServiceGetMetadata(); await initMetrics(config); removeJavascriptListeners = errorEventHandler.addJavascriptErrorListeners(); @@ -44,7 +43,7 @@ appBuildMetaStore.set({ version: config.version, - commitHash: config.build_commit, + commitHash: config.buildCommit, }); });