diff --git a/cmd/otfd/main.go b/cmd/otfd/main.go index 44f0ce279..1ba5864b5 100644 --- a/cmd/otfd/main.go +++ b/cmd/otfd/main.go @@ -10,6 +10,8 @@ import ( "github.com/leg100/otf/internal/agent" "github.com/leg100/otf/internal/authenticator" "github.com/leg100/otf/internal/daemon" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/gitlab" "github.com/leg100/otf/internal/logr" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -81,21 +83,19 @@ func parseFlags(ctx context.Context, args []string, out io.Writer) error { cmd.Flags().BoolVar(&cfg.EnableRequestLogging, "log-http-requests", false, "Log HTTP requests") cmd.Flags().BoolVar(&cfg.DevMode, "dev-mode", false, "Enable developer mode.") - cmd.Flags().StringVar(&cfg.Github.Hostname, "github-hostname", cfg.Github.Hostname, "github hostname") - cmd.Flags().BoolVar(&cfg.Github.SkipTLSVerification, "github-skip-tls-verification", false, "Skip github TLS verification") - cmd.Flags().StringVar(&cfg.Github.OAuthConfig.ClientID, "github-client-id", "", "github client ID") - cmd.Flags().StringVar(&cfg.Github.OAuthConfig.ClientSecret, "github-client-secret", "", "github client secret") + cmd.Flags().StringVar(&cfg.GithubHostname, "github-hostname", github.DefaultHostname, "github hostname") + cmd.Flags().StringVar(&cfg.GithubClientID, "github-client-id", "", "github client ID") + cmd.Flags().StringVar(&cfg.GithubClientSecret, "github-client-secret", "", "github client secret") - cmd.Flags().StringVar(&cfg.Gitlab.Hostname, "gitlab-hostname", cfg.Gitlab.Hostname, "gitlab hostname") - cmd.Flags().BoolVar(&cfg.Gitlab.SkipTLSVerification, "gitlab-skip-tls-verification", false, "Skip gitlab TLS verification") - cmd.Flags().StringVar(&cfg.Gitlab.OAuthConfig.ClientID, "gitlab-client-id", "", "gitlab client ID") - cmd.Flags().StringVar(&cfg.Gitlab.OAuthConfig.ClientSecret, "gitlab-client-secret", "", "gitlab client secret") + cmd.Flags().StringVar(&cfg.GitlabHostname, "gitlab-hostname", gitlab.DefaultHostname, "gitlab hostname") + cmd.Flags().StringVar(&cfg.GitlabClientID, "gitlab-client-id", "", "gitlab client ID") + cmd.Flags().StringVar(&cfg.GitlabClientSecret, "gitlab-client-secret", "", "gitlab client secret") - cmd.Flags().StringVar(&cfg.OIDC.Name, "oidc-name", cfg.OIDC.Name, "User friendly OIDC name") - cmd.Flags().StringVar(&cfg.OIDC.IssuerURL, "oidc-issuer-url", cfg.OIDC.IssuerURL, "OIDC issuer URL") + cmd.Flags().StringVar(&cfg.OIDC.Name, "oidc-name", "", "User friendly OIDC name") + cmd.Flags().StringVar(&cfg.OIDC.IssuerURL, "oidc-issuer-url", "", "OIDC issuer URL") cmd.Flags().StringVar(&cfg.OIDC.ClientID, "oidc-client-id", "", "OIDC client ID") cmd.Flags().StringVar(&cfg.OIDC.ClientSecret, "oidc-client-secret", "", "OIDC client secret") - cmd.Flags().StringSliceVar(&cfg.OIDC.Scopes, "oidc-scopes", authenticator.DefaultScopes, "OIDC scopes") + cmd.Flags().StringSliceVar(&cfg.OIDC.Scopes, "oidc-scopes", authenticator.DefaultOIDCScopes, "OIDC scopes") cmd.Flags().StringVar(&cfg.OIDC.UsernameClaim, "oidc-username-claim", string(authenticator.DefaultUsernameClaim), "OIDC claim to be used for username (name, email, or sub)") cmd.Flags().BoolVar(&cfg.RestrictOrganizationCreation, "restrict-org-creation", false, "Restrict organization creation capability to site admin role") diff --git a/docs/auth/site_admin.md b/docs/auth/site_admin.md deleted file mode 100644 index f108ee304..000000000 --- a/docs/auth/site_admin.md +++ /dev/null @@ -1,15 +0,0 @@ -# Site Admin - -The `site-admin` user allows for exceptional access to OTF. The user possesses unlimited privileges and uses a token to sign-in. See the documentation for the [`--site-token` flag](../../config/flags/#-site-token) for details on how to set the token. - -!!! note - Keep the token secure. Anyone with access to the token has complete access to OTF. - -You can sign into the web UI using the token. Use the link found in the bottom right corner of the login page: - -![login page](../images/no_authenticators_site_admin_login.png){.screenshot} -![site admin enter token](../images/site_admin_login_enter_token.png){.screenshot} -![site admin profile](../images/site_admin_profile.png){.screenshot} - -!!! note - Use of the site admin token is recommended only for one-off administrative and testing purposes. You should use an Identity Provider in most cases. diff --git a/docs/auth/site_admins.md b/docs/auth/site_admins.md new file mode 100644 index 000000000..be38e8da3 --- /dev/null +++ b/docs/auth/site_admins.md @@ -0,0 +1,23 @@ +# Site Admins + +Site admins possesses supreme privileges to an OTF installation. There are two ways to assume the role: + +* Promote users to the role using the [`--site-admins`](../../config/flags/#-site-admins) flag. +* Set a token with the [`--site-token`](../../config/flags/#-site-token) flag and use it to login as the built-in `site-admin` user + +## Promoting users + +To promote users to the role, simply set the [`--site-admins`](../../config/flags/#-site-admins) flag. There is no need to re-login. + +## Site token + +Set a site token with the [`--site-token`](../../config/flags/#-site-token) flag. You can use the token with the API, CLI, and the web UI. + +To use it to login to the web UI, use the link in the bottom right corner of the login page: + +![login page](../images/no_authenticators_site_admin_login.png){.screenshot} +![site admin enter token](../images/site_admin_login_enter_token.png){.screenshot} +![site admin profile](../images/site_admin_profile.png){.screenshot} + +!!! note + Keep the token secure. Anyone with access to the token has complete access to OTF. Use of the site admin token is recommended only for one-off administrative and testing purposes. You should use an Identity Provider in most cases. diff --git a/docs/config/flags.md b/docs/config/flags.md index 17837d082..2452ccce2 100644 --- a/docs/config/flags.md +++ b/docs/config/flags.md @@ -191,8 +191,7 @@ comma. For example: otfd --site-admins bob@example.com,alice@example.com ``` -Any users that were previously promoted and are no longer specified with this -flag are demoted. +Users are automatically created if they don't exist already. ## `--site-token` diff --git a/docs/github_app.md b/docs/github_app.md new file mode 100644 index 000000000..a67e8c819 --- /dev/null +++ b/docs/github_app.md @@ -0,0 +1,61 @@ +# Github app + +OTF provides the ability to create a [Github app](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/about-creating-github-apps). The app can then be used as an alternative to a personal access token for a VCS provider, offering the following advantages: + +* Unlike a personal token, an app is [not necessarily tied to an individual's personal Github account](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app#choosing-between-a-github-app-or-a-personal-access-token). Instead it can be owned and installed into a Github organization. If an individual leaves an organization then the app continues to function. +* The app can be installed into more than Github account. For instance, if you install the app into Github organizations `dev` and `prod` you can then create VCS providers for those installations respectively, restricting their access to the repositories belonging to each organization. +* An app comes with [its own webhook](https://docs.github.com/en/apps/creating-github-apps/about-creating-github-apps/deciding-when-to-build-a-github-app#github-apps-have-built-in-webhooks). Therefore, unlike with personal tokens, OTF does not need to create webhooks on Github repositories. This can be advantage if you want to overcome the maximum 20 webhook per-repo limit (OTF creates a separate webhook on a repo for each VCS provider if using a personal token). +* An app has a higher [maximum possible rate-limit](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps). +* The github app creation process automatically persists the app credentials to the database. There is no copying-and-pasting of credentials involved. + +!!! note + Github apps also have access to a richer API for status checks. A future version of OTF will take advantage of this. + +## Create the app + +Select **site** in the top right corner menu to take you to the site settings page: + +![site settings page](images/site_settings.png){.screenshot} + +Select **GitHub app**. You are then prompted to create an app: + +![github app page](images/empty_github_app_page.png){.screenshot} + +Select the link to create a new app. You are presented with a form to create the app: + +![new github app form](images/new_github_app.png){.screenshot} + +An app has an owner. By default your github personal account is the owner. If you would prefer a Github organization to own the application then enter the name of the organization. + +An app is *private* by default. That means the app can only be installed into the Github account that owns the app, and only repositories in that account will be accessible to OTF. If you want to install the app into more than one Github account then you need to select the **Public** checkbox. (This can be changed once the app has been created, via the app settings page on Github). + +Click **Create** and you are redirected to Github. You are given the opportunity to set a name (it must be globally unique and cannot match the name of a Github account): + +![create github app on github.com](images/github_create_github_app.png){.screenshot} + +Click the **Create GitHub App for ...** button. + +You're then redirected back to OTF, where details of the app are now visible: + +![github app created](images/github_app_created.png){.screenshot} + +## Install the app + +Once you've created the app you need to install it. + +On the Github app page, click the **Install** button: + +![github app created](images/github_app_created.png){.screenshot} + +You are re-directed to Github, where you can select the repositories that are to be made accessible to OTF: + +![github app created](images/github_install_app.png){.screenshot} + +!!! note + If you created a *public* app earlier you will first be presented with a choice of accounts to install the app into. + +Click the **Install** button and you'll be re-directed back to OTF. The installation should now be listed: + +![github app installation listing](images/github_app_install_list.png){.screenshot} + +You can create a [VCS provider](vcs_providers.md) from the installation. diff --git a/docs/images/connected_workspace_main_page.png b/docs/images/connected_workspace_main_page.png index 04151c27f..19a1fff7e 100644 Binary files a/docs/images/connected_workspace_main_page.png and b/docs/images/connected_workspace_main_page.png differ diff --git a/docs/images/empty_github_app_page.png b/docs/images/empty_github_app_page.png new file mode 100644 index 000000000..147e6a182 Binary files /dev/null and b/docs/images/empty_github_app_page.png differ diff --git a/docs/images/github_app_created.png b/docs/images/github_app_created.png new file mode 100644 index 000000000..8e3b65159 Binary files /dev/null and b/docs/images/github_app_created.png differ diff --git a/docs/images/github_app_install_list.png b/docs/images/github_app_install_list.png new file mode 100644 index 000000000..7e1492b6b Binary files /dev/null and b/docs/images/github_app_install_list.png differ diff --git a/docs/images/github_create_github_app.png b/docs/images/github_create_github_app.png new file mode 100644 index 000000000..b92eff9fc Binary files /dev/null and b/docs/images/github_create_github_app.png differ diff --git a/docs/images/github_install_app.png b/docs/images/github_install_app.png new file mode 100644 index 000000000..be8c4aac5 Binary files /dev/null and b/docs/images/github_install_app.png differ diff --git a/docs/images/github_login_button.png b/docs/images/github_login_button.png index 76e52408d..0cbfa89d6 100644 Binary files a/docs/images/github_login_button.png and b/docs/images/github_login_button.png differ diff --git a/docs/images/modules_confirm.png b/docs/images/modules_confirm.png index 91686c66a..d012f8ec5 100644 Binary files a/docs/images/modules_confirm.png and b/docs/images/modules_confirm.png differ diff --git a/docs/images/modules_list.png b/docs/images/modules_list.png index 2c2cb3458..73d08ad40 100644 Binary files a/docs/images/modules_list.png and b/docs/images/modules_list.png differ diff --git a/docs/images/modules_select_provider.png b/docs/images/modules_select_provider.png index 6dc2483ef..23472b824 100644 Binary files a/docs/images/modules_select_provider.png and b/docs/images/modules_select_provider.png differ diff --git a/docs/images/modules_select_repo.png b/docs/images/modules_select_repo.png index b1a38da88..54e4580e6 100644 Binary files a/docs/images/modules_select_repo.png and b/docs/images/modules_select_repo.png differ diff --git a/docs/images/new_github_app.png b/docs/images/new_github_app.png new file mode 100644 index 000000000..6d9e1715b Binary files /dev/null and b/docs/images/new_github_app.png differ diff --git a/docs/images/new_github_vcs_provider_form.png b/docs/images/new_github_vcs_provider_form.png index 919c4bb30..1ee51c2f6 100644 Binary files a/docs/images/new_github_vcs_provider_form.png and b/docs/images/new_github_vcs_provider_form.png differ diff --git a/docs/images/new_org_created.png b/docs/images/new_org_created.png index 1f63402b4..ea9073968 100644 Binary files a/docs/images/new_org_created.png and b/docs/images/new_org_created.png differ diff --git a/docs/images/new_org_enter_name.png b/docs/images/new_org_enter_name.png index 1d2eb4a5b..9a229b36e 100644 Binary files a/docs/images/new_org_enter_name.png and b/docs/images/new_org_enter_name.png differ diff --git a/docs/images/newly_created_module_page.png b/docs/images/newly_created_module_page.png index 0f0fccaa3..2cd18e70b 100644 Binary files a/docs/images/newly_created_module_page.png and b/docs/images/newly_created_module_page.png differ diff --git a/docs/images/no_authenticators_site_admin_login.png b/docs/images/no_authenticators_site_admin_login.png index 6982048e4..20d2f3593 100644 Binary files a/docs/images/no_authenticators_site_admin_login.png and b/docs/images/no_authenticators_site_admin_login.png differ diff --git a/docs/images/oidc_login_button.png b/docs/images/oidc_login_button.png index 8ae50663c..ab2026e72 100644 Binary files a/docs/images/oidc_login_button.png and b/docs/images/oidc_login_button.png differ diff --git a/docs/images/org_token_created.png b/docs/images/org_token_created.png index 8f66f76d0..ea0d4f34a 100644 Binary files a/docs/images/org_token_created.png and b/docs/images/org_token_created.png differ diff --git a/docs/images/org_token_new.png b/docs/images/org_token_new.png index 66b9cfe8a..f1d2a6c9c 100644 Binary files a/docs/images/org_token_new.png and b/docs/images/org_token_new.png differ diff --git a/docs/images/organization_main_menu.png b/docs/images/organization_main_menu.png index 49580aeda..530df5567 100644 Binary files a/docs/images/organization_main_menu.png and b/docs/images/organization_main_menu.png differ diff --git a/docs/images/owners_team_page.png b/docs/images/owners_team_page.png index 996bdf3f1..17d732c01 100644 Binary files a/docs/images/owners_team_page.png and b/docs/images/owners_team_page.png differ diff --git a/docs/images/run_page_planned_and_finished_state.png b/docs/images/run_page_planned_and_finished_state.png index 5835ad068..a2df89967 100644 Binary files a/docs/images/run_page_planned_and_finished_state.png and b/docs/images/run_page_planned_and_finished_state.png differ diff --git a/docs/images/run_page_planned_state.png b/docs/images/run_page_planned_state.png index 54c9e1871..2a8829f02 100644 Binary files a/docs/images/run_page_planned_state.png and b/docs/images/run_page_planned_state.png differ diff --git a/docs/images/run_page_started.png b/docs/images/run_page_started.png index 7c5c9c95a..725b5463a 100644 Binary files a/docs/images/run_page_started.png and b/docs/images/run_page_started.png differ diff --git a/docs/images/site_admin_login_enter_token.png b/docs/images/site_admin_login_enter_token.png index 991db6338..9699c8592 100644 Binary files a/docs/images/site_admin_login_enter_token.png and b/docs/images/site_admin_login_enter_token.png differ diff --git a/docs/images/site_admin_profile.png b/docs/images/site_admin_profile.png index cbdc92204..49d09525d 100644 Binary files a/docs/images/site_admin_profile.png and b/docs/images/site_admin_profile.png differ diff --git a/docs/images/site_settings.png b/docs/images/site_settings.png new file mode 100644 index 000000000..270e59d31 Binary files /dev/null and b/docs/images/site_settings.png differ diff --git a/docs/images/team_permissions_added_workspace_manager.png b/docs/images/team_permissions_added_workspace_manager.png index 9e52bf0a2..dd6b25a76 100644 Binary files a/docs/images/team_permissions_added_workspace_manager.png and b/docs/images/team_permissions_added_workspace_manager.png differ diff --git a/docs/images/terraform_login_consent.png b/docs/images/terraform_login_consent.png index beba86b45..3811a1295 100644 Binary files a/docs/images/terraform_login_consent.png and b/docs/images/terraform_login_consent.png differ diff --git a/docs/images/terraform_login_flow_complete.png b/docs/images/terraform_login_flow_complete.png index 101ccf965..e65869646 100644 Binary files a/docs/images/terraform_login_flow_complete.png and b/docs/images/terraform_login_flow_complete.png differ diff --git a/docs/images/user_token_created.png b/docs/images/user_token_created.png index d540cf65c..174b5891c 100644 Binary files a/docs/images/user_token_created.png and b/docs/images/user_token_created.png differ diff --git a/docs/images/user_token_enter_description.png b/docs/images/user_token_enter_description.png index b8f15cfa9..74ce69c8d 100644 Binary files a/docs/images/user_token_enter_description.png and b/docs/images/user_token_enter_description.png differ diff --git a/docs/images/user_tokens.png b/docs/images/user_tokens.png index 1626a5d8d..7773cb745 100644 Binary files a/docs/images/user_tokens.png and b/docs/images/user_tokens.png differ diff --git a/docs/images/variables_entering_top_secret.png b/docs/images/variables_entering_top_secret.png index 93cbb21b7..92f9545f1 100644 Binary files a/docs/images/variables_entering_top_secret.png and b/docs/images/variables_entering_top_secret.png differ diff --git a/docs/images/vcs_provider_created_github_pat_provider.png b/docs/images/vcs_provider_created_github_pat_provider.png new file mode 100644 index 000000000..260870cbf Binary files /dev/null and b/docs/images/vcs_provider_created_github_pat_provider.png differ diff --git a/docs/images/vcs_provider_list_including_github_app.png b/docs/images/vcs_provider_list_including_github_app.png new file mode 100644 index 000000000..d899cd4fa Binary files /dev/null and b/docs/images/vcs_provider_list_including_github_app.png differ diff --git a/docs/images/vcs_providers_list.png b/docs/images/vcs_providers_list.png index 6b95e4bf5..16f5d3781 100644 Binary files a/docs/images/vcs_providers_list.png and b/docs/images/vcs_providers_list.png differ diff --git a/docs/images/workspace_edit_trigger_patterns.png b/docs/images/workspace_edit_trigger_patterns.png index ae345a487..24b3e17c9 100644 Binary files a/docs/images/workspace_edit_trigger_patterns.png and b/docs/images/workspace_edit_trigger_patterns.png differ diff --git a/docs/images/workspace_main_page.png b/docs/images/workspace_main_page.png index a2b4f6cfd..c9d3eca95 100644 Binary files a/docs/images/workspace_main_page.png and b/docs/images/workspace_main_page.png differ diff --git a/docs/images/workspace_page.png b/docs/images/workspace_page.png index 58e6c7547..1e8c26971 100644 Binary files a/docs/images/workspace_page.png and b/docs/images/workspace_page.png differ diff --git a/docs/images/workspace_permissions.png b/docs/images/workspace_permissions.png index 7b95ba815..8337e30b5 100644 Binary files a/docs/images/workspace_permissions.png and b/docs/images/workspace_permissions.png differ diff --git a/docs/images/workspace_settings.png b/docs/images/workspace_settings.png index fb160ff5b..d1f4738ca 100644 Binary files a/docs/images/workspace_settings.png and b/docs/images/workspace_settings.png differ diff --git a/docs/images/workspace_vcs_providers_list.png b/docs/images/workspace_vcs_providers_list.png index 84b4603ce..c1273e5f6 100644 Binary files a/docs/images/workspace_vcs_providers_list.png and b/docs/images/workspace_vcs_providers_list.png differ diff --git a/docs/images/workspace_vcs_repo_list.png b/docs/images/workspace_vcs_repo_list.png index c22e1629b..59b9bd065 100644 Binary files a/docs/images/workspace_vcs_repo_list.png and b/docs/images/workspace_vcs_repo_list.png differ diff --git a/docs/index.md b/docs/index.md index d1e36511b..b022d6841 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,15 +4,16 @@ OTF is an open-source alternative to Terraform Enterprise, sharing many of its f * Full Terraform CLI integration * Remote execution mode: plans and applies run on servers -* Agent execution mode: plans and applies run on agents +* Agent execution mode: plans and applies run on [agents](agents.md) * Remote state backend: state stored in PostgreSQL * SSO: sign in using an identity provider via OIDC, OAuth, etc. -* Module registry (provider registry coming soon) * RBAC: control team access to workspaces * VCS integration: trigger runs and publish modules from git commits +* Create and install a [Github app](github_app.md) to integrate OTF with Github * Compatible with much of the Terraform Enterprise/Cloud API * Minimal dependencies: requires only PostgreSQL * Stateless: horizontally scale servers in pods on Kubernetes, etc +* Module registry (provider registry coming soon)
![run page planned and finished state](images/run_page_planned_and_finished_state.png){.screenshot} diff --git a/docs/vcs_providers.md b/docs/vcs_providers.md index 5a2a1dfd3..063097e86 100644 --- a/docs/vcs_providers.md +++ b/docs/vcs_providers.md @@ -1,23 +1,36 @@ # VCS Providers -To connect workspaces and modules to git repositories containing Terraform configurations, you need to provide OTF with access to your VCS provider. +To connect workspaces and modules to git repositories containing Terraform configurations, you need to provide OTF with access to your VCS provider. You have a choice of three providers: -Firstly, create a provider for your organization. On your organization's main menu, select **VCS providers**. +* [Github app](github_app.md) +* Github personal access token +* Gitlab personal access token + +## Walkthrough + +This walkthrough shows you how to create a VCS provider via the web UI. + +On your organization's main menu, select **VCS providers**. ![organization main menu](images/organization_main_menu.png){.screenshot} -You'll be presented with a choice of providers to create. The choice is restricted to those for which you have enabled [SSO](#authentication). For instance, if you have enabled Github SSO then you can create a Github VCS provider. +You are presented with a choice of providers to create: -![vcs providers list](images/vcs_providers_list.png){.screenshot} +![vcs providers list](images/vcs_provider_list_including_github_app.png){.screenshot} -Select the provider you would like to create. You will then be prompted to enter a personal access token. Instructions for generating the token are included on the page. The token permits OTF to access your git repository and retrieve terraform configuration. Once you've generated and inserted the token into the field you also need to give the provider a name that describes it. +In this walkthrough we will create a provider using a Github personal access token. + +Select **New Github VCS Provider (Personal Token)**. You are then presented with a form on which to enter the token: ![new github vcs provider form](images/new_github_vcs_provider_form.png){.screenshot} -!!! note - Be sure to restrict the permissions on the token according to the instructions. +Click the **personal token** link. It'll take you to Github where you can create the token. Create a **classic** token with the **repo** scope (or you can create a fine-tuned token with the equivalent permissions). The token permits OTF to access your git repository and retrieve terraform configuration. Once you've generated the token, copy and paste it into the **Token** field. Optionally you can also assign the provider a name. + +Create the provider and it'll appear on the list of providers: + +![vcs providers list](images/vcs_provider_created_github_pat_provider.png){.screenshot} -Create the provider and it'll appear on the list of providers. You can now proceed to connecting workspaces and publishing modules. +You can now proceed to connecting workspaces (see below) and [publishing modules](registry.md). ### Connecting a workspace diff --git a/exchange-code.json b/exchange-code.json new file mode 100644 index 000000000..4c7114296 --- /dev/null +++ b/exchange-code.json @@ -0,0 +1,35 @@ +{ + "id": 384952, + "slug": "otf-new", + "node_id": "A_kwHOAc1xa84ABd-4", + "owner": { + "login": "automatize", + "id": 30241131, + "node_id": "MDEyOk9yZ2FuaXphdGlvbjMwMjQxMTMx", + "avatar_url": "https://avatars.githubusercontent.com/u/30241131?v=4", + "html_url": "https://github.com/automatize", + "gravatar_id": "", + "type": "Organization", + "site_admin": false, + "url": "https://api.github.com/users/automatize", + "events_url": "https://api.github.com/users/automatize/events{/privacy}", + "following_url": "https://api.github.com/users/automatize/following{/other_user}", + "followers_url": "https://api.github.com/users/automatize/followers", + "gists_url": "https://api.github.com/users/automatize/gists{/gist_id}", + "organizations_url": "https://api.github.com/users/automatize/orgs", + "received_events_url": "https://api.github.com/users/automatize/received_events", + "repos_url": "https://api.github.com/users/automatize/repos", + "starred_url": "https://api.github.com/users/automatize/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/automatize/subscriptions" + }, + "name": "OTF-new", + "description": "Trigger terraform runs in OTF from GitHub", + "external_url": "https://otf.fridayafternoonhangover.com", + "html_url": "https://github.com/apps/otf-new", + "created_at": "2023-09-03T16:10:38Z", + "updated_at": "2023-09-03T16:10:38Z", + "client_id": "Iv1.f3fefdd17666291b", + "client_secret": "fb14b7b11460196307e08f9b756a28091203f8f9", + "webhook_secret": "8a1963079437b7c38744d67931d7f2fc54e7e63d", + "pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA5Rfz3Utrsw9EycKthYLEt5KNA+9FphNCxklDzy3/KKAlAgu6\ns+SGyWM8mLn1GPYR7yfFZpww7uNh2Ya64YzM9ZTnS0Q80GqMtamCf11owxBS9d4q\nHYsxrFiIDu0fjxnMYeY8gbMkhG7Mr0cWwUsyleb091KWq9Rslcu/pZiKgpOdDnNB\nw7tNxzMYSOWkf/cRHnJ/KoYURdL0sUkqEflc/9XHuAZas3WaZOqjUzSKbVQU18KJ\nkcq/pmoWVsU4DjoyVt1uqPFNMclUBBui20Sng7EAtsB80xh4geDWN/F1EeqkFam3\ny9ycTP8A0A0zFBqXyWgjnEtjM6YntODNxtKV7QIDAQABAoIBAFgWKKdLK6MS2OE3\nIJ84U8k96Ui9BKvBtigl3ZPY3MZTJUevGN+4m/btWExlHA+39ddeFHHK1qnT9ji8\nrDizec9nrLNtDnEYtvfWsJ2mXfS63xs4jDsF3VimRdJvbHYKdmKiM1uvdPgS3lL6\n4435Cv9GaaR7NgHl0MacLBlRGNjxjOWwtTEd+mgVwzq5lCxxDY7+MBNvHbMZscdZ\nxxcpL1hg2K/7WKHz4CaVOnMiWORN8N8yVZ0mdRE4GtFNRsj13sGSpjYhwDOxGT5a\n1dZzuxee5/KrOMTIUU3qBG3yxfJ87eFyuMqdD+MFYoEgiOh7C671aquchtgTbjys\nZi53RoUCgYEA9/uEJQc2pVTPR2rp0Tm+mWUsST0UcXzK2NmR0Tf+vOHtrgovhPqU\nuxALWUQiBBzcwMx5crtCCZwEGwDGJlMUVSqUWWAFd4lCfSOxm7JUCelNS67Pykny\nuOhaoUqjZ8UaDcC4pDdepRQ4ggGyAYtwN92eW8mFOYHI121dk4QV37sCgYEA7IAZ\nEhdma0nIOb68dCJBQ/Zk6S3Z0QHTz46n/f0jEFbLvD9MHaVTuwIKfE/7KyZZGmO9\nLfcnfwMKmZnWjcLxhuhEFkhc5Gelvn12oizbBZ5uziir1ZhIn7U0YbA/Fq83qHZH\nIc9BBBexySKgNttpgcgJhdG0kqHsCoksvo3mYncCgYEA9zTwgskyHJbzG0rlVAGw\nk9Jb15bgLlItFQevaVXcyAahngHhZTs30VMpPQ/CqT7sgfZUi59JMbMqFJEs9z+S\n4WPVB1PFn2hhs8ZFY+TeChNdTrkxw4L8SIC4+FkjlGrUkikw5+Oaog4KVu/Bt/B9\nKfPvzaiS+sT6pmcMBeaCt8kCgYAD55GCZPSB7PPrUCTYXgBp2NWNq/4en0MZ+Cb5\n4IYFrQksEHd3PdWGDuCRcNiau8VY1DC5Y405YZl5M7sBGCjYq1kEbSlrc/KelH+y\n6b6r9xOpP66mlh8M0/cLbdd8zmPC2kEOY9eU87cxtOqkPTcet2jA1td+XEIDYoRk\nmP8mvQKBgQDJ0mx6pReYxZTxUDYQK0Nhl190QNFX9WdwxWQdcn3/ihV+8Im8Og/B\n2+WLZSnVgp9Lq6qMXtA9+pVINrzNIeGyr+JVzWlulySoqcf7gV8pzPH2kSTyJJDb\nXiAMJvx9DcMvSCCjjJvkWWheSjXTNXH9eqHNRY2/A7LyxWA3xnDP0w==\n-----END RSA PRIVATE KEY-----\n" +} diff --git a/go.mod b/go.mod index be607099c..ba03f56e8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/Masterminds/sprig/v3 v3.2.2 github.com/allegro/bigcache v1.2.1 github.com/antchfx/htmlquery v1.3.0 + github.com/bradleyfalzon/ghinstallation/v2 v2.7.0 github.com/buildkite/terminal-to-html v3.2.0+incompatible github.com/chromedp/cdproto v0.0.0-20230220211738-2b1ec77315c9 github.com/chromedp/chromedp v0.9.1 @@ -19,6 +20,7 @@ require ( github.com/gobwas/glob v0.2.3 github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c github.com/google/go-github/v41 v41.0.0 + github.com/google/go-github/v55 v55.0.0 github.com/google/goexpect v0.0.0-20210430020637-ab937bf7fd6f github.com/google/uuid v1.3.0 github.com/gorilla/handlers v1.5.1 @@ -49,7 +51,7 @@ require ( github.com/xanzy/go-gitlab v0.73.1 golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb golang.org/x/mod v0.11.0 - golang.org/x/net v0.9.0 + golang.org/x/net v0.10.0 golang.org/x/oauth2 v0.7.0 golang.org/x/sync v0.1.0 google.golang.org/api v0.118.0 @@ -63,12 +65,14 @@ require ( cloud.google.com/go/iam v0.13.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.1.1 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/antchfx/xpath v1.2.3 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect + github.com/cloudflare/circl v1.3.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect github.com/go-jose/go-jose/v3 v3.0.0 // indirect @@ -76,6 +80,7 @@ require ( github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.1.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.5.9 // indirect @@ -125,9 +130,9 @@ require ( github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8 // indirect github.com/zclconf/go-cty v1.8.0 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.7.0 // indirect - golang.org/x/sys v0.7.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/crypto v0.12.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect diff --git a/go.sum b/go.sum index 7321179bf..d4dbaeec3 100644 --- a/go.sum +++ b/go.sum @@ -67,6 +67,8 @@ github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFP github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= @@ -98,8 +100,12 @@ github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kB github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA= github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/bradleyfalzon/ghinstallation/v2 v2.7.0 h1:ranXaC3Zz/F6G/f0Joj3LrFp2OzOKfJZev5Q7OaMc88= +github.com/bradleyfalzon/ghinstallation/v2 v2.7.0/go.mod h1:ymxfmloxXBFXvvF1KpeUhOQM6Dfz9NYtfvTiJyk82UE= github.com/buildkite/terminal-to-html v3.2.0+incompatible h1:WdXzl7ZmYzCAz4pElZosPaUlRTW+qwVx/SkQSCa1jXs= github.com/buildkite/terminal-to-html v3.2.0+incompatible/go.mod h1:BFFdFecOxCgjdcarqI+8izs6v85CU/1RA/4Bqh4GR7E= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -119,6 +125,9 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.6.2/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -215,6 +224,8 @@ github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -273,6 +284,8 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v41 v41.0.0 h1:HseJrM2JFf2vfiZJ8anY2hqBjdfY1Vlj/K27ueww4gg= github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg= +github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= +github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/goexpect v0.0.0-20210430020637-ab937bf7fd6f h1:7MmqygqdeJtziBUpm4Z9ThROFZUaVGaePMfcDnluf1E= @@ -745,8 +758,10 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220210151621-f4118a5b28e2/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= +golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -830,13 +845,14 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= -golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -930,21 +946,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= -golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0= +golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -954,12 +974,14 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/auth/user.go b/internal/auth/user.go index 10074add7..468c18a0e 100644 --- a/internal/auth/user.go +++ b/internal/auth/user.go @@ -112,6 +112,8 @@ func (u *User) IsSiteAdmin() bool { func (u *User) CanAccessSite(action rbac.Action) bool { switch action { + case rbac.GetGithubAppAction: + return true case rbac.CreateUserAction, rbac.ListUsersAction: // A user can perform these actions only if they are an owner of at // least one organization. This permits an owner to search users or create diff --git a/internal/auth/web.go b/internal/auth/web.go index faf66d6b6..7fdcc610d 100644 --- a/internal/auth/web.go +++ b/internal/auth/web.go @@ -26,8 +26,8 @@ func (h *webHandlers) addHandlers(r *mux.Router) { h.addTeamHandlers(r) r.HandleFunc("/organizations/{name}/users", h.listOrganizationUsers).Methods("GET") - r.HandleFunc("/profile", h.profileHandler).Methods("GET") + r.HandleFunc("/admin", h.site).Methods("GET") } func (h *webHandlers) listOrganizationUsers(w http.ResponseWriter, r *http.Request) { @@ -71,3 +71,18 @@ func (h *webHandlers) profileHandler(w http.ResponseWriter, r *http.Request) { func (h *webHandlers) adminLoginPromptHandler(w http.ResponseWriter, r *http.Request) { h.Render("site_admin_login.tmpl", w, html.NewSitePage(r, "site admin login")) } + +func (h *webHandlers) site(w http.ResponseWriter, r *http.Request) { + user, err := internal.SubjectFromContext(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.Render("site.tmpl", w, struct { + html.SitePage + User internal.Subject + }{ + SitePage: html.NewSitePage(r, "site"), + User: user, + }) +} diff --git a/internal/authenticator/authenticator.go b/internal/authenticator/authenticator.go index 7bd09924e..b9267fdd5 100644 --- a/internal/authenticator/authenticator.go +++ b/internal/authenticator/authenticator.go @@ -1,42 +1,3 @@ // Package authenticator is responsible for handling the authentication of users with // third party identity providers. package authenticator - -import ( - "net/http" - - "github.com/go-logr/logr" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/http/html" - "github.com/leg100/otf/internal/organization" - "github.com/leg100/otf/internal/tokens" -) - -type ( - authenticator interface { - RequestPath() string - CallbackPath() string - RequestHandler(w http.ResponseWriter, r *http.Request) - ResponseHandler(w http.ResponseWriter, r *http.Request) - } - - service struct { - renderer html.Renderer - authenticators []authenticator - } - - Options struct { - logr.Logger - html.Renderer - - internal.HostnameService - organization.OrganizationService - auth.AuthService - tokens.TokensService - - Configs []cloud.CloudOAuthConfig - OIDCConfigs []cloud.OIDCConfig - } -) diff --git a/internal/authenticator/helper_test.go b/internal/authenticator/helper_test.go index 435809bb6..f7325db0b 100644 --- a/internal/authenticator/helper_test.go +++ b/internal/authenticator/helper_test.go @@ -2,60 +2,27 @@ package authenticator import ( "context" - "crypto" - "crypto/rsa" "net/http" - "testing" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/tokens" "golang.org/x/oauth2" ) type ( - fakeAuthenticatorService struct { + fakeTokensService struct { tokens.TokensService } - fakeOAuthClient struct { - user cloud.User - oauthClient - token *oauth2.Token - } - - fakeCloudClient struct { - user cloud.User - cloud.Client + fakeTokenHandler struct { + username string } ) -func (f *fakeAuthenticatorService) StartSession(w http.ResponseWriter, r *http.Request, opts tokens.StartSessionOptions) error { - http.Redirect(w, r, paths.Profile(), http.StatusFound) - return nil -} - -func (f *fakeOAuthClient) CallbackHandler(*http.Request) (*oauth2.Token, error) { - return f.token, nil -} - -func (f *fakeOAuthClient) NewClient(context.Context, *oauth2.Token) (cloud.Client, error) { - return &fakeCloudClient{user: f.user}, nil -} - -func (f *fakeCloudClient) GetCurrentUser(context.Context) (cloud.User, error) { - return f.user, nil +func (f fakeTokenHandler) getUsername(ctx context.Context, token *oauth2.Token) (string, error) { + return f.username, nil } -func fakeOAuthToken(t *testing.T, username, aud string, key *rsa.PrivateKey) *oauth2.Token { - idtoken := fakeIDToken(t, username, aud, "", key) - return (&oauth2.Token{}).WithExtra(map[string]any{"id_token": idtoken}) -} - -func fakeVerifier(t *testing.T, aud string, key *rsa.PrivateKey) *oidc.IDTokenVerifier { - keySet := &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{key.Public()}} - return oidc.NewVerifier("", keySet, &oidc.Config{ - ClientID: "otf", - }) +func (fakeTokensService) StartSession(w http.ResponseWriter, r *http.Request, opts tokens.StartSessionOptions) error { + w.Header().Set("username", *opts.Username) + return nil } diff --git a/internal/authenticator/idtoken_handler.go b/internal/authenticator/idtoken_handler.go new file mode 100644 index 000000000..85e190168 --- /dev/null +++ b/internal/authenticator/idtoken_handler.go @@ -0,0 +1,91 @@ +package authenticator + +import ( + "context" + "errors" + "fmt" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +var ( + // "openid" is a required scope for OpenID Connect flows, and profile + // gives OTF access to the user's username. + DefaultOIDCScopes = []string{oidc.ScopeOpenID, "profile"} + ErrMissingOIDCIssuerURL = errors.New("missing oidc-issuer-url") +) + +type ( + // idtokenHandler handles specifically an OIDC ID token, extracting the + // username from a claim within the token. + idtokenHandler struct { + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + username *usernameClaim + } + + // OIDCConfig is the configuration for a generic OIDC provider. + OIDCConfig struct { + // Name is the user-friendly identifier of the OIDC endpoint. + Name string + // IssuerURL is the issuer url for the OIDC provider. + IssuerURL string + // ClientID is the client id for the OIDC provider. + ClientID string + // ClientSecret is the client secret for the OIDC provider. + ClientSecret string + // Skip TLS Verification when communicating with issuer. + SkipTLSVerification bool + // Scopes to request from the OIDC provider. + Scopes []string + // UsernameClaim is the claim that provides the username. + UsernameClaim string + } +) + +func newIDTokenHandler(ctx context.Context, opts OIDCConfig) (*idtokenHandler, error) { + if opts.IssuerURL == "" { + return nil, ErrMissingOIDCIssuerURL + } + // construct oidc provider, using our own http client, which lets us disable + // tls verification for testing purposes. + ctx = contextWithClient(ctx, opts.SkipTLSVerification) + provider, err := oidc.NewProvider(ctx, opts.IssuerURL) + if err != nil { + return nil, fmt.Errorf("constructing OIDC provider: %w", err) + } + + // parse claim to be used for username + username, err := newUsernameClaim(opts.UsernameClaim) + if err != nil { + return nil, err + } + + return &idtokenHandler{ + verifier: provider.Verifier(&oidc.Config{ClientID: opts.ClientID}), + username: username, + provider: provider, + }, nil +} + +func (o idtokenHandler) getUsername(ctx context.Context, token *oauth2.Token) (string, error) { + // Extract the ID Token from OAuth2 token. + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return "", errors.New("id_token missing") + } + + // Parse and verify ID Token payload. + idt, err := o.verifier.Verify(ctx, rawIDToken) + if err != nil { + return "", err + } + + // Extract username from claim + if err := idt.Claims(&o.username); err != nil { + return "", err + } + + return o.username.value, nil +} diff --git a/internal/authenticator/idtoken_handler_test.go b/internal/authenticator/idtoken_handler_test.go new file mode 100644 index 000000000..08fd2d708 --- /dev/null +++ b/internal/authenticator/idtoken_handler_test.go @@ -0,0 +1,59 @@ +package authenticator + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func Test_newIDTokenHandler(t *testing.T) { + ctx := context.Background() + _, err := newIDTokenHandler(ctx, OIDCConfig{}) + assert.Equal(t, ErrMissingOIDCIssuerURL, err) +} + +// Test_idtokenHandler_getUsername tests extracting the 'name' claim from an ID +// token. +func Test_idtokenHandler_getUsername(t *testing.T) { + // create id token + token, err := jwt.NewBuilder(). + Audience([]string{"otf"}). + Claim("name", "bobby"). + IssuedAt(time.Now()). + Expiration(time.Now().Add(time.Minute)). + Build() + require.NoError(t, err) + key, err := rsa.GenerateKey(rand.Reader, 512) + require.NoError(t, err) + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, key)) + require.NoError(t, err) + + // setup id token verifier + fakeVerifier := func(t *testing.T, aud string, key *rsa.PrivateKey) *oidc.IDTokenVerifier { + keySet := &oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{key.Public()}} + return oidc.NewVerifier("", keySet, &oidc.Config{ClientID: "otf"}) + } + // setup handler to parse the 'name' claim + username, err := newUsernameClaim("name") + require.NoError(t, err) + + handler := idtokenHandler{ + verifier: fakeVerifier(t, "otf", key), + username: username, + } + got, err := handler.getUsername(context.Background(), (&oauth2.Token{}).WithExtra( + map[string]any{"id_token": string(signed)}, + )) + require.NoError(t, err) + assert.Equal(t, "bobby", got) +} diff --git a/internal/authenticator/oauth_authenticator.go b/internal/authenticator/oauth_authenticator.go deleted file mode 100644 index f6baf04e9..000000000 --- a/internal/authenticator/oauth_authenticator.go +++ /dev/null @@ -1,58 +0,0 @@ -package authenticator - -import ( - "net/http" - - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/http/html" - "github.com/leg100/otf/internal/http/html/paths" - "github.com/leg100/otf/internal/tokens" -) - -type ( - // oauthAuthenticator logs people onto the system using an OAuth handshake with an - // Identity provider before synchronising their user account and various organization - // and team memberships from the provider. - oauthAuthenticator struct { - internal.HostnameService - tokens.TokensService // for creating session - - oauthClient - } -) - -// ResponseHandler handles exchanging its auth code for a token. -func (a *oauthAuthenticator) ResponseHandler(w http.ResponseWriter, r *http.Request) { - // Handle oauth response; if there is an error, return user to login page - // along with flash error. - token, err := a.CallbackHandler(r) - if err != nil { - html.FlashError(w, err.Error()) - http.Redirect(w, r, paths.Login(), http.StatusFound) - return - } - - client, err := a.NewClient(r.Context(), token) - if err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } - - // give oauthAuthenticator unlimited access to services - ctx := internal.AddSubjectToContext(r.Context(), &internal.Superuser{Username: "authenticator"}) - - // Get cloud user - cuser, err := client.GetCurrentUser(ctx) - if err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } - - err = a.StartSession(w, r, tokens.StartSessionOptions{ - Username: &cuser.Name, - }) - if err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } -} diff --git a/internal/authenticator/oauth_authenticator_test.go b/internal/authenticator/oauth_authenticator_test.go deleted file mode 100644 index f02cfd35c..000000000 --- a/internal/authenticator/oauth_authenticator_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package authenticator - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/leg100/otf/internal/cloud" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/oauth2" -) - -func TestOAuthAuthenticator_ResponseHandler(t *testing.T) { - user := cloud.User{Name: "fake-user"} - - authenticator := &oauthAuthenticator{ - TokensService: &fakeAuthenticatorService{}, - oauthClient: &fakeOAuthClient{ - user: user, - token: &oauth2.Token{}, - }, - } - - r := httptest.NewRequest("GET", "/auth?state=state", nil) - r.AddCookie(&http.Cookie{Name: oauthCookieName, Value: "state"}) - w := httptest.NewRecorder() - authenticator.ResponseHandler(w, r) - - assert.Equal(t, http.StatusFound, w.Result().StatusCode) - - loc, err := w.Result().Location() - require.NoError(t, err) - assert.Equal(t, "/app/profile", loc.Path) -} diff --git a/internal/authenticator/oauth_client.go b/internal/authenticator/oauth_client.go index f5e0db491..753363a1b 100644 --- a/internal/authenticator/oauth_client.go +++ b/internal/authenticator/oauth_client.go @@ -2,15 +2,18 @@ package authenticator import ( "context" + "crypto/tls" "errors" "fmt" "net/http" "net/url" - "path" + "github.com/gorilla/mux" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/decode" + "github.com/leg100/otf/internal/http/html" + "github.com/leg100/otf/internal/http/html/paths" + "github.com/leg100/otf/internal/tokens" "golang.org/x/oauth2" ) @@ -19,75 +22,84 @@ const oauthCookieName = "oauth-state" var ErrOAuthCredentialsIncomplete = errors.New("must specify both client ID and client secret") type ( - oauthClient interface { - RequestHandler(w http.ResponseWriter, r *http.Request) - CallbackHandler(*http.Request) (*oauth2.Token, error) - NewClient(ctx context.Context, token *oauth2.Token) (cloud.Client, error) - RequestPath() string - CallbackPath() string - String() string + // tokenHandler takes an OAuth access token and returns the username + // associated with the token. + tokenHandler interface { + getUsername(context.Context, *oauth2.Token) (string, error) } // OAuthClient performs the client role in an oauth handshake, requesting // authorization from the user to access their account details on a particular // cloud. OAuthClient struct { - internal.HostnameService // for retrieving otf system hostname for use in redirects back to otf - cloudConfig cloud.Config - *oauth2.Config - } - - // OAuthClientConfig is configuration for constructing an OAuth client - OAuthClientConfig struct { - cloud.CloudOAuthConfig - otfHostname internal.HostnameService + // extract username from token + tokenHandler + // for creating session + tokens.TokensService + // for retrieving OTF system hostname to construct redirect URLs + internal.HostnameService + + OAuthConfig + } + + // OAuthConfig is configuration for constructing an OAuth client + OAuthConfig struct { + Hostname string + ClientID string + ClientSecret string + Endpoint oauth2.Endpoint + Scopes []string + Name string + SkipTLSVerification bool } ) -func NewOAuthClient(cfg OAuthClientConfig) (*OAuthClient, error) { - if cfg.OAuthConfig.ClientID == "" && cfg.OAuthConfig.ClientSecret != "" { +func newOAuthClient( + handler tokenHandler, + hostnameService internal.HostnameService, + tokensService tokens.TokensService, + cfg OAuthConfig, +) (*OAuthClient, error) { + + if cfg.ClientID == "" && cfg.ClientSecret != "" { return nil, ErrOAuthCredentialsIncomplete } - if cfg.OAuthConfig.ClientID != "" && cfg.OAuthConfig.ClientSecret == "" { + if cfg.ClientID != "" && cfg.ClientSecret == "" { return nil, ErrOAuthCredentialsIncomplete } - - authURL, err := updateHost(cfg.OAuthConfig.Endpoint.AuthURL, cfg.Hostname) - if err != nil { - return nil, err - } - - tokenURL, err := updateHost(cfg.OAuthConfig.Endpoint.TokenURL, cfg.Hostname) - if err != nil { - return nil, err + // if OAuth provider hostname specified then update its OAuth endpoint + // accordingly. + if cfg.Hostname != "" { + authURL, err := updateHost(cfg.Endpoint.AuthURL, cfg.Hostname) + if err != nil { + return nil, err + } + tokenURL, err := updateHost(cfg.Endpoint.TokenURL, cfg.Hostname) + if err != nil { + return nil, err + } + cfg.Endpoint.AuthURL = authURL + cfg.Endpoint.TokenURL = tokenURL } - - cfg.OAuthConfig.Endpoint.AuthURL = authURL - cfg.OAuthConfig.Endpoint.TokenURL = tokenURL - return &OAuthClient{ - HostnameService: cfg.otfHostname, - cloudConfig: cfg.Config, - Config: cfg.OAuthConfig, + tokenHandler: handler, + HostnameService: hostnameService, + TokensService: tokensService, + OAuthConfig: cfg, }, nil } // String provides a human-readable identifier for the oauth client, using the // name of its underlying cloud provider -func (a *OAuthClient) String() string { return a.cloudConfig.Name } +func (a *OAuthClient) String() string { return a.Name } -func (a *OAuthClient) RequestPath() string { - return path.Join("/oauth", a.cloudConfig.Name, "login") -} - -// RequestHandler initiates the oauth flow, redirecting user to the auth server -func (a *OAuthClient) RequestHandler(w http.ResponseWriter, r *http.Request) { +// requestHandler initiates the oauth flow, redirecting user to the auth server +func (a *OAuthClient) requestHandler(w http.ResponseWriter, r *http.Request) { state, err := internal.GenerateToken() if err != nil { http.Error(w, "unable to generate state token: "+err.Error(), http.StatusInternalServerError) return } - http.SetCookie(w, &http.Cookie{ Name: oauthCookieName, Value: state, @@ -96,83 +108,87 @@ func (a *OAuthClient) RequestHandler(w http.ResponseWriter, r *http.Request) { HttpOnly: true, Secure: true, // HTTPS only }) - - cfg, err := a.config() - if err != nil { - http.Error(w, "unable to get redirect url: "+err.Error(), http.StatusInternalServerError) - return - } - - redirectURL := cfg.AuthCodeURL(state) + redirectURL := a.config().AuthCodeURL(state) http.Redirect(w, r, redirectURL, http.StatusFound) } -func (a *OAuthClient) CallbackPath() string { - return path.Join("/oauth", a.cloudConfig.Name, "callback") -} - -func (a *OAuthClient) CallbackHandler(r *http.Request) (*oauth2.Token, error) { - // Parse query string - var resp struct { - AuthCode string `schema:"code"` - State string - Error string - ErrorDescription string `schema:"error_description"` - ErrorURI string `schema:"error_uri"` - } - if err := decode.Query(&resp, r.URL.Query()); err != nil { - return nil, err - } - if resp.Error != "" { - return nil, fmt.Errorf("%s: %s\n\nSee %s", resp.Error, resp.ErrorDescription, resp.ErrorURI) - } - - // Validate state - cookie, err := r.Cookie(oauthCookieName) +// callbackHandler handles the response from the identity provider, exchanging +// the code it receives for an access token, which it then uses to retrieve its +// corresponding username and start a new OTF user session. +func (a *OAuthClient) callbackHandler(w http.ResponseWriter, r *http.Request) { + getToken := func() (*oauth2.Token, error) { + // Parse query string + var resp struct { + AuthCode string `schema:"code"` + State string + Error string + ErrorDescription string `schema:"error_description"` + ErrorURI string `schema:"error_uri"` + } + if err := decode.Query(&resp, r.URL.Query()); err != nil { + return nil, err + } + if resp.Error != "" { + return nil, fmt.Errorf("%s: %s\n\nSee %s", resp.Error, resp.ErrorDescription, resp.ErrorURI) + } + // Validate state + cookie, err := r.Cookie(oauthCookieName) + if err != nil { + return nil, fmt.Errorf("missing state cookie (the cookie expires after 60 seconds)") + } + if resp.State != cookie.Value || resp.State == "" { + return nil, fmt.Errorf("state mismatch between cookie and callback response") + } + // Exchange code for an access token (optionally skipping TLS verification + // for testing purposes). + ctx := contextWithClient(r.Context(), a.SkipTLSVerification) + return a.config().Exchange(ctx, resp.AuthCode) + } + // Get token; if there is an error, return user to login page along with + // flash error. + token, err := getToken() if err != nil { - return nil, fmt.Errorf("missing state cookie (the cookie expires after 60 seconds)") + html.FlashError(w, err.Error()) + http.Redirect(w, r, paths.Login(), http.StatusFound) + return } - if resp.State != cookie.Value || resp.State == "" { - return nil, fmt.Errorf("state mismatch between cookie and callback response") + // Extract username from OAuth token + username, err := a.getUsername(r.Context(), token) + if err != nil { + html.Error(w, err.Error(), http.StatusInternalServerError, false) + return } - - ctx := context.WithValue(r.Context(), oauth2.HTTPClient, a.cloudConfig.HTTPClient()) - - // Exchange code for an access token - cfg, err := a.config() + err = a.StartSession(w, r, tokens.StartSessionOptions{Username: &username}) if err != nil { - return nil, err + html.Error(w, err.Error(), http.StatusInternalServerError, false) + return } - - return cfg.Exchange(ctx, resp.AuthCode) } -// NewClient constructs a cloud client configured with the given oauth token for authentication. -func (a *OAuthClient) NewClient(ctx context.Context, token *oauth2.Token) (cloud.Client, error) { - return a.cloudConfig.NewClient(ctx, cloud.Credentials{ - OAuthToken: token, - }) +// config generates an oauth2 config for the client - note this is done at +// run-time because the redirect URL uses an OTF hostname that may only be +// determined at run-time. +func (a *OAuthClient) config() *oauth2.Config { + return &oauth2.Config{ + Endpoint: a.Endpoint, + ClientID: a.ClientID, + ClientSecret: a.ClientSecret, + RedirectURL: a.URL(a.callbackPath()), + Scopes: a.Scopes, + } } -func (a *OAuthClient) getRedirectURL() (string, error) { - return (&url.URL{Scheme: "https", Host: a.Hostname(), Path: a.CallbackPath()}).String(), nil +func (a *OAuthClient) RequestPath() string { + return "/oauth/" + a.String() + "/login" } -// config generates an oauth2 config for the client - note this is done at -// run-time because the otf hostname may only be determined at run-time. -func (a *OAuthClient) config() (*oauth2.Config, error) { - redirectURL, err := a.getRedirectURL() - if err != nil { - return nil, err - } +func (a *OAuthClient) callbackPath() string { + return "/oauth/" + a.String() + "/callback" +} - return &oauth2.Config{ - Endpoint: a.Config.Endpoint, - ClientID: a.Config.ClientID, - ClientSecret: a.Config.ClientSecret, - RedirectURL: redirectURL, - Scopes: a.Config.Scopes, - }, nil +func (a *OAuthClient) addHandlers(r *mux.Router) { + r.HandleFunc(a.RequestPath(), a.requestHandler) + r.HandleFunc(a.callbackPath(), a.callbackHandler) } // updateHost updates the hostname in a URL @@ -184,3 +200,17 @@ func updateHost(u, host string) (string, error) { parsed.Host = host return parsed.String(), nil } + +// contextWithClient returns a context that embeds an OAuth2 http client. +func contextWithClient(ctx context.Context, skipTLSVerification bool) context.Context { + if skipTLSVerification { + return context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: skipTLSVerification, + }, + }, + }) + } + return ctx +} diff --git a/internal/authenticator/oauth_client_test.go b/internal/authenticator/oauth_client_test.go index 0fb6a0dd2..c7484c33e 100644 --- a/internal/authenticator/oauth_client_test.go +++ b/internal/authenticator/oauth_client_test.go @@ -8,18 +8,17 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" ) -func TestOAuthClient_RequestHandler(t *testing.T) { - client := newTestOAuthServerClient(t) +func TestOAuthClient_requestHandler(t *testing.T) { + client := newTestOAuthServerClient(t, "") r := httptest.NewRequest("GET", "/auth", nil) w := httptest.NewRecorder() - client.RequestHandler(w, r) + client.requestHandler(w, r) assert.Equal(t, http.StatusFound, w.Result().StatusCode) @@ -32,19 +31,19 @@ func TestOAuthClient_RequestHandler(t *testing.T) { } } -func TestOAuthClient_CallbackHandler(t *testing.T) { - client := newTestOAuthServerClient(t) +func TestOAuthClient_callbackHandler(t *testing.T) { + client := newTestOAuthServerClient(t, "bobby") r := httptest.NewRequest("GET", "/auth?state=state", nil) r.AddCookie(&http.Cookie{Name: oauthCookieName, Value: "state"}) + w := httptest.NewRecorder() - token, err := client.CallbackHandler(r) - require.NoError(t, err) - assert.Equal(t, token.AccessToken, "fake_token") + client.callbackHandler(w, r) + assert.Equal(t, w.Header().Get("username"), "bobby") } // newTestOAuthServerClient creates an OAuth server for testing purposes and // returns a client configured to access the server. -func newTestOAuthServerClient(t *testing.T) *OAuthClient { +func newTestOAuthServerClient(t *testing.T, username string) *OAuthClient { srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { out, err := json.Marshal(&oauth2.Token{AccessToken: "fake_token"}) require.NoError(t, err) @@ -55,22 +54,20 @@ func newTestOAuthServerClient(t *testing.T) *OAuthClient { u, err := url.Parse(srv.URL) require.NoError(t, err) - client, err := NewOAuthClient(OAuthClientConfig{ - CloudOAuthConfig: cloud.CloudOAuthConfig{ - OAuthConfig: &oauth2.Config{ - Endpoint: oauth2.Endpoint{ - AuthURL: srv.URL, - TokenURL: srv.URL, - }, - }, - Config: cloud.Config{ - SkipTLSVerification: true, - Hostname: u.Host, - Name: "fake-cloud", + client, err := newOAuthClient( + fakeTokenHandler{username}, + internal.NewHostnameService("otf-server.com"), + fakeTokensService{}, + OAuthConfig{ + Hostname: u.Host, + Endpoint: oauth2.Endpoint{ + AuthURL: srv.URL, + TokenURL: srv.URL, }, + Name: "fake-cloud", + SkipTLSVerification: true, }, - otfHostname: internal.FakeHostnameService{Host: "otf-server.com"}, - }) + ) require.NoError(t, err) return client } diff --git a/internal/authenticator/oidc_authenticator.go b/internal/authenticator/oidc_authenticator.go deleted file mode 100644 index 6521ac726..000000000 --- a/internal/authenticator/oidc_authenticator.go +++ /dev/null @@ -1,128 +0,0 @@ -package authenticator - -import ( - "context" - "errors" - "fmt" - "net/http" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/http/html" - "github.com/leg100/otf/internal/http/html/paths" - "github.com/leg100/otf/internal/tokens" - "golang.org/x/oauth2" -) - -var ( - _ authenticator = &oidcAuthenticator{} - - // "openid" is a required scope for OpenID Connect flows, and profile - // gives OTF access to the user's username. - DefaultScopes = []string{oidc.ScopeOpenID, "profile"} - - ErrMissingOIDCIssuerURL = errors.New("missing oidc-issuer-url") -) - -type ( - // oidcAuthenticator is an authenticator that uses OIDC. - oidcAuthenticator struct { - tokens.TokensService // for creating session - - oidcConfig cloud.OIDCConfig - provider *oidc.Provider - verifier *oidc.IDTokenVerifier - username *usernameClaim - - oauthClient - } - - oidcAuthenticatorOptions struct { - tokens.TokensService // for creating session - internal.HostnameService // for constructing redirect URL - cloud.OIDCConfig - } -) - -func newOIDCAuthenticator(ctx context.Context, opts oidcAuthenticatorOptions) (*oidcAuthenticator, error) { - if opts.IssuerURL == "" { - return nil, ErrMissingOIDCIssuerURL - } - - cloudConfig := cloud.Config{ - Name: opts.Name, - SkipTLSVerification: opts.SkipTLSVerification, - } - - // construct oidc provider, using our own http client, which lets us disable - // tls verification for testing purposes. - ctx = oidc.ClientContext(ctx, cloudConfig.HTTPClient()) - provider, err := oidc.NewProvider(ctx, opts.IssuerURL) - if err != nil { - return nil, fmt.Errorf("constructing OIDC provider: %w", err) - } - - // parse claim to be used for username - username, err := newUsernameClaim(opts.OIDCConfig.UsernameClaim) - if err != nil { - return nil, err - } - - return &oidcAuthenticator{ - TokensService: opts.TokensService, - oidcConfig: opts.OIDCConfig, - provider: provider, - verifier: provider.Verifier(&oidc.Config{ClientID: opts.ClientID}), - username: username, - oauthClient: &OAuthClient{ - HostnameService: opts.HostnameService, - Config: &oauth2.Config{ - ClientID: opts.ClientID, - ClientSecret: opts.ClientSecret, - Endpoint: provider.Endpoint(), - Scopes: opts.Scopes, - }, - cloudConfig: cloudConfig, - }, - }, nil -} - -func (o oidcAuthenticator) ResponseHandler(w http.ResponseWriter, r *http.Request) { - // Handle oauth response; if there is an error, return user to login page - // along with flash error. - token, err := o.CallbackHandler(r) - if err != nil { - html.FlashError(w, err.Error()) - http.Redirect(w, r, paths.Login(), http.StatusFound) - return - } - - // Extract the ID Token from OAuth2 token. - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - html.Error(w, "id_token missing", http.StatusInternalServerError, false) - return - } - - // Parse and verify ID Token payload. - idt, err := o.verifier.Verify(r.Context(), rawIDToken) - if err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } - - // Extract username from claim - if err := idt.Claims(&o.username); err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } - - err = o.StartSession(w, r, tokens.StartSessionOptions{ - Username: &o.username.value, - }) - if err != nil { - html.Error(w, err.Error(), http.StatusInternalServerError, false) - return - } -} diff --git a/internal/authenticator/oidc_authenticator_test.go b/internal/authenticator/oidc_authenticator_test.go deleted file mode 100644 index 68a58c1a4..000000000 --- a/internal/authenticator/oidc_authenticator_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package authenticator - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestNewOIDCAuthenticator(t *testing.T) { - ctx := context.Background() - _, err := newOIDCAuthenticator(ctx, oidcAuthenticatorOptions{}) - assert.Equal(t, ErrMissingOIDCIssuerURL, err) -} - -func TestOIDCAuthenticator_ResponseHandler(t *testing.T) { - priv, err := rsa.GenerateKey(rand.Reader, 512) - require.NoError(t, err) - - authenticator := &oidcAuthenticator{ - TokensService: &fakeAuthenticatorService{}, - oauthClient: &fakeOAuthClient{ - token: fakeOAuthToken(t, "", "otf", priv), - }, - verifier: fakeVerifier(t, "otf", priv), - } - - r := httptest.NewRequest("GET", "/auth?state=state", nil) - r.AddCookie(&http.Cookie{Name: oauthCookieName, Value: "state"}) - w := httptest.NewRecorder() - authenticator.ResponseHandler(w, r) - - assert.Equal(t, http.StatusFound, w.Result().StatusCode, w.Body.String()) - - loc, err := w.Result().Location() - require.NoError(t, err) - assert.Equal(t, "/app/profile", loc.Path) -} diff --git a/internal/authenticator/opaque_handler.go b/internal/authenticator/opaque_handler.go new file mode 100644 index 000000000..32d677dc6 --- /dev/null +++ b/internal/authenticator/opaque_handler.go @@ -0,0 +1,35 @@ +package authenticator + +import ( + "context" + + "golang.org/x/oauth2" +) + +type ( + // opaqueHandler uses an 'opaque' OAuth access token to retrieve the + // username of the authenticated user. + opaqueHandler struct { + OpaqueHandlerConfig + } + + OpaqueHandlerConfig struct { + OAuthConfig + ClientConstructor func(cfg OAuthConfig, token *oauth2.Token) (IdentityProviderClient, error) + } + + IdentityProviderClient interface { + // GetCurrentUser retrieves the currently authenticated user + GetCurrentUser(ctx context.Context) (string, error) + } +) + +func (a *opaqueHandler) getUsername(ctx context.Context, token *oauth2.Token) (string, error) { + // construct client from token + client, err := a.ClientConstructor(a.OAuthConfig, token) + if err != nil { + return "", err + } + // get username from identity provider + return client.GetCurrentUser(ctx) +} diff --git a/internal/authenticator/service.go b/internal/authenticator/service.go index 15f71a2b0..c1e681228 100644 --- a/internal/authenticator/service.go +++ b/internal/authenticator/service.go @@ -5,73 +5,101 @@ import ( "net/http" "github.com/gorilla/mux" + "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/http/html" + "github.com/leg100/otf/internal/logr" + "github.com/leg100/otf/internal/tokens" ) -func NewAuthenticatorService(opts Options) (*service, error) { - svc := service{ - renderer: opts.Renderer, +type ( + Options struct { + logr.Logger + html.Renderer + + internal.HostnameService + tokens.TokensService + + OpaqueHandlerConfigs []OpaqueHandlerConfig + IDTokenHandlerConfig OIDCConfig + + SkipTLSVerification bool } - for _, cfg := range opts.Configs { - if cfg.OAuthConfig.ClientID == "" && cfg.OAuthConfig.ClientSecret == "" { - // skip creating oauth client when creds are unspecified - continue - } - client, err := NewOAuthClient(OAuthClientConfig{ - CloudOAuthConfig: cfg, - otfHostname: opts.HostnameService, - }) - if err != nil { - return nil, err - } - authenticator := &oauthAuthenticator{ - HostnameService: opts.HostnameService, - TokensService: opts.TokensService, - oauthClient: client, - } - svc.authenticators = append(svc.authenticators, authenticator) + service struct { + html.Renderer - opts.V(2).Info("activated oauth client", "name", cfg, "hostname", cfg.Hostname) + clients []*OAuthClient } +) - for _, cfg := range opts.OIDCConfigs { +// NewAuthenticatorService constructs a service for logging users onto +// the system. Supports multiple clients: zero or more clients that support an +// opaque token, and one client that supports IDToken/OIDC. +func NewAuthenticatorService(ctx context.Context, opts Options) (*service, error) { + svc := service{Renderer: opts.Renderer} + // Construct clients with opaque token handlers + for _, cfg := range opts.OpaqueHandlerConfigs { if cfg.ClientID == "" && cfg.ClientSecret == "" { - // skip creating oidc client when creds are unspecified + // skip creating OAuth client when creds are unspecified continue } - - authenticator, err := newOIDCAuthenticator(context.Background(), oidcAuthenticatorOptions{ - TokensService: opts.TokensService, - HostnameService: opts.HostnameService, - OIDCConfig: cfg, - }) + cfg.SkipTLSVerification = opts.SkipTLSVerification + client, err := newOAuthClient( + &opaqueHandler{cfg}, + opts.HostnameService, + opts.TokensService, + cfg.OAuthConfig, + ) if err != nil { return nil, err } - - svc.authenticators = append(svc.authenticators, authenticator) - - opts.V(0).Info("activated oidc client", "name", cfg.Name) + svc.clients = append(svc.clients, client) + opts.V(0).Info("activated OAuth client", "name", cfg.Name, "hostname", cfg.Hostname) } - + // Construct client with OIDC IDToken handler + if opts.IDTokenHandlerConfig.ClientID == "" && opts.IDTokenHandlerConfig.ClientSecret == "" { + // skip creating OIDC authenticator when creds are unspecified + return &svc, nil + } + opts.IDTokenHandlerConfig.SkipTLSVerification = opts.SkipTLSVerification + handler, err := newIDTokenHandler(ctx, opts.IDTokenHandlerConfig) + if err != nil { + return nil, err + } + client, err := newOAuthClient( + handler, + opts.HostnameService, + opts.TokensService, + OAuthConfig{ + Endpoint: handler.provider.Endpoint(), + Scopes: opts.IDTokenHandlerConfig.Scopes, + ClientID: opts.IDTokenHandlerConfig.ClientID, + ClientSecret: opts.IDTokenHandlerConfig.ClientSecret, + Name: opts.IDTokenHandlerConfig.Name, + SkipTLSVerification: opts.SkipTLSVerification, + }, + ) + if err != nil { + return nil, err + } + svc.clients = append(svc.clients, client) + opts.V(0).Info("activated OIDC client", "name", opts.IDTokenHandlerConfig.Name) return &svc, nil } func (a *service) AddHandlers(r *mux.Router) { - for _, authenticator := range a.authenticators { - r.HandleFunc(authenticator.RequestPath(), authenticator.RequestHandler) - r.HandleFunc(authenticator.CallbackPath(), authenticator.ResponseHandler) + for _, authenticator := range a.clients { + authenticator.addHandlers(r) } r.HandleFunc("/login", a.loginHandler) } func (a *service) loginHandler(w http.ResponseWriter, r *http.Request) { - a.renderer.Render("login.tmpl", w, struct { + a.Render("login.tmpl", w, struct { html.SitePage - Authenticators []authenticator + Clients []*OAuthClient }{ - SitePage: html.NewSitePage(r, "login"), - Authenticators: a.authenticators, + SitePage: html.NewSitePage(r, "login"), + Clients: a.clients, }) } diff --git a/internal/authenticator/authenticator_test.go b/internal/authenticator/service_test.go similarity index 62% rename from internal/authenticator/authenticator_test.go rename to internal/authenticator/service_test.go index 450872f1b..0e5353a99 100644 --- a/internal/authenticator/authenticator_test.go +++ b/internal/authenticator/service_test.go @@ -1,44 +1,43 @@ package authenticator import ( + "context" "net/http/httptest" "testing" "github.com/go-logr/logr" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/html" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/oauth2" ) func TestNewAuthenticatorService(t *testing.T) { opts := Options{ Logger: logr.Discard(), - HostnameService: internal.FakeHostnameService{Host: "fake-host.org"}, - Configs: []cloud.CloudOAuthConfig{ + HostnameService: internal.NewHostnameService("fake-host.org"), + OpaqueHandlerConfigs: []OpaqueHandlerConfig{ { - OAuthConfig: &oauth2.Config{ + OAuthConfig: OAuthConfig{ ClientID: "id-1", ClientSecret: "secret-1", }, }, { - OAuthConfig: &oauth2.Config{ + OAuthConfig: OAuthConfig{ ClientID: "id-2", ClientSecret: "secret-2", }, }, { // should be skipped - OAuthConfig: &oauth2.Config{}, + OAuthConfig: OAuthConfig{}, }, }, } - got, err := NewAuthenticatorService(opts) + got, err := NewAuthenticatorService(context.Background(), opts) require.NoError(t, err) - assert.Equal(t, 2, len(got.authenticators)) + assert.Equal(t, 2, len(got.clients)) } // TestLoginHandler tests the login page handler, testing for the presence of a @@ -46,21 +45,11 @@ func TestNewAuthenticatorService(t *testing.T) { func TestLoginHandler(t *testing.T) { renderer, err := html.NewRenderer(false) require.NoError(t, err) - svc := &service{ - renderer: renderer, - } + svc := &service{Renderer: renderer} - svc.authenticators = []authenticator{ - &oauthAuthenticator{ - oauthClient: &OAuthClient{ - cloudConfig: cloud.Config{Name: "cloud1"}, - }, - }, - &oauthAuthenticator{ - oauthClient: &OAuthClient{ - cloudConfig: cloud.Config{Name: "cloud2"}, - }, - }, + svc.clients = []*OAuthClient{ + {OAuthConfig: OAuthConfig{Name: "cloud1"}}, + {OAuthConfig: OAuthConfig{Name: "cloud2"}}, } r := httptest.NewRequest("GET", "/?", nil) diff --git a/internal/cloud/cloud.go b/internal/cloud/cloud.go deleted file mode 100644 index 39a0ec5ed..000000000 --- a/internal/cloud/cloud.go +++ /dev/null @@ -1,34 +0,0 @@ -// Package cloud provides types for use with cloud providers. -package cloud - -import ( - "context" - "net/http" -) - -const ( - Github = "github" - Gitlab = "gitlab" -) - -// Kind is the kind of cloud provider, e.g. github, gitlab, etc. -type Kind string - -// Cloud is an external provider of various cloud services e.g. identity provider, VCS -// repositories etc. -type Cloud interface { - NewClient(context.Context, ClientOptions) (Client, error) - EventHandler -} - -type Service interface { - GetCloudConfig(name string) (Config, error) - ListCloudConfigs() []Config -} - -// EventHandler handles incoming events -type EventHandler interface { - // HandleEvent extracts a cloud-specific event from the http request, converting it into a - // VCS event. Returns nil if the event is to be ignored. - HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *VCSEvent -} diff --git a/internal/cloud/config.go b/internal/cloud/config.go deleted file mode 100644 index b12f1c63b..000000000 --- a/internal/cloud/config.go +++ /dev/null @@ -1,69 +0,0 @@ -package cloud - -import ( - "context" - "net/http" - - otfhttp "github.com/leg100/otf/internal/http" - - "golang.org/x/oauth2" -) - -// Config is configuration for a cloud provider -type Config struct { - Name string - Hostname string - SkipTLSVerification bool - - Cloud -} - -func (cfg Config) String() string { return cfg.Name } - -func (cfg *Config) NewClient(ctx context.Context, creds Credentials) (Client, error) { - return cfg.Cloud.NewClient(ctx, ClientOptions{ - Hostname: cfg.Hostname, - SkipTLSVerification: cfg.SkipTLSVerification, - Credentials: creds, - }) -} - -func (cfg *Config) HTTPClient() *http.Client { - return &http.Client{ - Transport: otfhttp.DefaultTransport(cfg.SkipTLSVerification), - } -} - -// Credentials are credentials for a cloud client -type Credentials struct { - // tokens are mutually-exclusive - at least one must be specified - OAuthToken *oauth2.Token - PersonalToken *string -} - -// CloudOAuthConfig is the configuration for a cloud provider and its OAuth -// configuration. -type CloudOAuthConfig struct { - Config - OAuthConfig *oauth2.Config -} - -// OIDCConfig is the configuration for a generic oidc provider. -type OIDCConfig struct { - // Name is the user-friendly identifier of the oidc endpoint. - Name string - // IssuerURL is the issuer url for the oidc provider. - IssuerURL string - // RedirectURL is the redirect url for the oidc provider. - RedirectURL string - // ClientID is the client id for the oidc provider. - ClientID string - // ClientSecret is the client secret for the oidc provider. - ClientSecret string - // Skip TLS Verification when communicating with issuer. - SkipTLSVerification bool - // Scopes to request from the oidc provider. - Scopes []string - // UsernameClaim is the claim that provides the username. - UsernameClaim string -} diff --git a/internal/cloud/user.go b/internal/cloud/user.go deleted file mode 100644 index 06b080537..000000000 --- a/internal/cloud/user.go +++ /dev/null @@ -1,8 +0,0 @@ -package cloud - -type ( - // User is a user account on a cloud provider. - User struct { - Name string - } -) diff --git a/internal/cloud/vcs_event.go b/internal/cloud/vcs_event.go deleted file mode 100644 index 092ec08a9..000000000 --- a/internal/cloud/vcs_event.go +++ /dev/null @@ -1,57 +0,0 @@ -package cloud - -import ( - "github.com/google/uuid" -) - -const ( - VCSEventTypePull VCSEventType = iota - VCSEventTypePush - VCSEventTypeTag - - VCSActionCreated VCSAction = iota - VCSActionDeleted - VCSActionMerged - VCSActionUpdated -) - -type ( - // VCSEvent is a VCS event received from a cloud, e.g. a commit event from - // github - VCSEvent struct { - // - // These fields are populated by the generic webhook handler - // - RepoID uuid.UUID - VCSProviderID string - RepoPath string - - // - // These fields are populated by cloud-specific handlers - // - Cloud Kind - - Type VCSEventType - Action VCSAction - Tag string - CommitSHA string - CommitURL string - Branch string // head branch - DefaultBranch string - - PullRequestNumber int - PullRequestURL string - PullRequestTitle string - - SenderUsername string - SenderAvatarURL string - SenderHTMLURL string - - // Paths of files that have been added/modified/removed. Only applicable - // to Push and Tag events types. - Paths []string - } - - VCSEventType int - VCSAction int -) diff --git a/internal/cloud/vcs_status.go b/internal/cloud/vcs_status.go deleted file mode 100644 index 8e44d2852..000000000 --- a/internal/cloud/vcs_status.go +++ /dev/null @@ -1,11 +0,0 @@ -package cloud - -type VCSStatus string - -const ( - VCSPendingStatus VCSStatus = "pending" - VCSRunningStatus VCSStatus = "running" - VCSSuccessStatus VCSStatus = "success" - VCSErrorStatus VCSStatus = "error" - VCSFailureStatus VCSStatus = "failure" -) diff --git a/internal/configversion/tfe_test.go b/internal/configversion/tfe_test.go index 9b4661e74..dfc5d33f3 100644 --- a/internal/configversion/tfe_test.go +++ b/internal/configversion/tfe_test.go @@ -30,7 +30,7 @@ func TestUploadConfigurationVersion(t *testing.T) { // setup client client := http.Client{ - Transport: otfhttp.DefaultTransport(true), + Transport: otfhttp.InsecureTransport, } // upload config smaller than MaxConfigSize diff --git a/internal/connections/connection.go b/internal/connections/connection.go new file mode 100644 index 000000000..aeedb46ec --- /dev/null +++ b/internal/connections/connection.go @@ -0,0 +1,132 @@ +// Package connections manages connections between VCS repositories and OTF +// resources, e.g. workspaces, modules. +package connections + +import ( + "context" + "fmt" + + "github.com/leg100/otf/internal/logr" + "github.com/leg100/otf/internal/repohooks" + "github.com/leg100/otf/internal/sql" + "github.com/leg100/otf/internal/sql/pggen" + "github.com/leg100/otf/internal/vcsprovider" +) + +const ( + WorkspaceConnection ConnectionType = iota + ModuleConnection +) + +type ( + // ConnectionType identifies the OTF resource type in a VCS connection. + ConnectionType int + + // Connection is a connection between a VCS repo and an OTF resource. + Connection struct { + VCSProviderID string + Repo string + } + + ConnectOptions struct { + ConnectionType // OTF resource type + + VCSProviderID string // vcs provider of repo + ResourceID string // ID of OTF resource + RepoPath string + } + + DisconnectOptions struct { + ConnectionType // OTF resource type + + ResourceID string // ID of OTF resource + } + + SynchroniseOptions struct { + VCSProviderID string // vcs provider of repo + RepoPath string + } + + ConnectionService Service + + // Service manages connections between OTF resources and VCS repos + Service interface { + // Connect adds a connection between a VCS repo and an OTF resource. A + // webhook is created if one doesn't exist already. + Connect(ctx context.Context, opts ConnectOptions) (*Connection, error) + + // Disconnect removes a connection between a VCS repo and an OTF + // resource. If there are no more connections then its + // webhook is removed. + Disconnect(ctx context.Context, opts DisconnectOptions) error + } + + Options struct { + logr.Logger + vcsprovider.VCSProviderService + repohooks.RepohookService + *sql.DB + } + + service struct { + logr.Logger + vcsprovider.Service + repohooks.RepohookService + + *db + } +) + +func NewService(ctx context.Context, opts Options) *service { + return &service{ + Logger: opts.Logger, + Service: opts.VCSProviderService, + RepohookService: opts.RepohookService, + db: &db{opts.DB}, + } +} + +// Connect an OTF resource to a VCS repo. +func (s *service) Connect(ctx context.Context, opts ConnectOptions) (*Connection, error) { + // check vcs provider is valid + provider, err := s.GetVCSProvider(ctx, opts.VCSProviderID) + if err != nil { + return nil, fmt.Errorf("retrieving vcs provider: %w", err) + } + + err = s.db.Tx(ctx, func(ctx context.Context, q pggen.Querier) error { + // github app vcs provider does not require a repohook to be created + if provider.GithubApp == nil { + _, err := s.RepohookService.CreateRepohook(ctx, repohooks.CreateRepohookOptions{ + VCSProviderID: opts.VCSProviderID, + RepoPath: opts.RepoPath, + }) + if err != nil { + return fmt.Errorf("creating webhook: %w", err) + } + } + return s.db.createConnection(ctx, opts) + }) + if err != nil { + return nil, err + } + return &Connection{ + Repo: opts.RepoPath, + VCSProviderID: opts.VCSProviderID, + }, nil +} + +// Disconnect resource from repo +func (s *service) Disconnect(ctx context.Context, opts DisconnectOptions) error { + return s.db.Tx(ctx, func(ctx context.Context, q pggen.Querier) error { + if err := s.db.deleteConnection(ctx, opts); err != nil { + return err + } + // now that a connection has been deleted, also delete any repohooks that + // are no longer referenced by connections + if err := s.RepohookService.DeleteUnreferencedRepohooks(ctx); err != nil { + return err + } + return nil + }) +} diff --git a/internal/connections/db.go b/internal/connections/db.go new file mode 100644 index 000000000..28f84e14d --- /dev/null +++ b/internal/connections/db.go @@ -0,0 +1,53 @@ +package connections + +import ( + "context" + "fmt" + + "github.com/jackc/pgtype" + "github.com/leg100/otf/internal/sql" + "github.com/leg100/otf/internal/sql/pggen" +) + +type ( + db struct { + *sql.DB + } +) + +func (db *db) createConnection(ctx context.Context, opts ConnectOptions) error { + q := db.Conn(ctx) + params := pggen.InsertRepoConnectionParams{ + VCSProviderID: sql.String(opts.VCSProviderID), + RepoPath: sql.String(opts.RepoPath), + } + + switch opts.ConnectionType { + case WorkspaceConnection: + params.WorkspaceID = sql.String(opts.ResourceID) + params.ModuleID = pgtype.Text{Status: pgtype.Null} + case ModuleConnection: + params.ModuleID = sql.String(opts.ResourceID) + params.WorkspaceID = pgtype.Text{Status: pgtype.Null} + default: + return fmt.Errorf("unknown connection type: %v", opts.ConnectionType) + } + + if _, err := q.InsertRepoConnection(ctx, params); err != nil { + return sql.Error(err) + } + return nil +} + +func (db *db) deleteConnection(ctx context.Context, opts DisconnectOptions) (err error) { + q := db.Conn(ctx) + switch opts.ConnectionType { + case WorkspaceConnection: + _, err = q.DeleteWorkspaceConnectionByID(ctx, sql.String(opts.ResourceID)) + case ModuleConnection: + _, err = q.DeleteModuleConnectionByID(ctx, sql.String(opts.ResourceID)) + default: + return fmt.Errorf("unknown connection type: %v", opts.ConnectionType) + } + return err +} diff --git a/internal/daemon/config.go b/internal/daemon/config.go index ad6cbafbb..df9456314 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -2,14 +2,11 @@ package daemon import ( "errors" - "reflect" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/agent" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/authenticator" "github.com/leg100/otf/internal/configversion" - "github.com/leg100/otf/internal/github" - "github.com/leg100/otf/internal/gitlab" "github.com/leg100/otf/internal/inmem" "github.com/leg100/otf/internal/tokens" ) @@ -21,9 +18,13 @@ var ErrInvalidSecretLength = errors.New("secret must be 16 bytes in size") type Config struct { AgentConfig *agent.Config CacheConfig *inmem.CacheConfig - Github cloud.CloudOAuthConfig - Gitlab cloud.CloudOAuthConfig - OIDC cloud.OIDCConfig + GithubHostname string + GithubClientID string + GithubClientSecret string + GitlabHostname string + GitlabClientID string + GitlabClientSecret string + OIDC authenticator.OIDCConfig Secret []byte // 16-byte secret for signing URLs and encrypting payloads SiteToken string Host string @@ -37,6 +38,7 @@ type Config struct { DisableScheduler bool RestrictOrganizationCreation bool SiteAdmins []string + SkipTLSVerification bool // skip checks for latest terraform version DisableLatestChecker *bool @@ -55,18 +57,6 @@ func ApplyDefaults(cfg *Config) { if cfg.MaxConfigSize == 0 { cfg.MaxConfigSize = configversion.DefaultConfigMaxSize } - if reflect.ValueOf(cfg.Github).IsZero() { - cfg.Github = cloud.CloudOAuthConfig{ - Config: github.Defaults(), - OAuthConfig: github.OAuthDefaults(), - } - } - if reflect.ValueOf(cfg.Gitlab).IsZero() { - cfg.Gitlab = cloud.CloudOAuthConfig{ - Config: gitlab.Defaults(), - OAuthConfig: gitlab.OAuthDefaults(), - } - } } func (cfg *Config) Valid() error { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 3d9e1e213..601eacc1b 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -13,9 +13,12 @@ import ( "github.com/leg100/otf/internal/agent" "github.com/leg100/otf/internal/auth" "github.com/leg100/otf/internal/authenticator" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/disco" + "github.com/leg100/otf/internal/ghapphandler" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/gitlab" "github.com/leg100/otf/internal/http" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/inmem" @@ -26,7 +29,7 @@ import ( "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/releases" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/repohooks" "github.com/leg100/otf/internal/run" "github.com/leg100/otf/internal/scheduler" "github.com/leg100/otf/internal/sql" @@ -34,6 +37,7 @@ import ( "github.com/leg100/otf/internal/tfeapi" "github.com/leg100/otf/internal/tokens" "github.com/leg100/otf/internal/variable" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/otf/internal/workspace" "golang.org/x/sync/errgroup" @@ -58,14 +62,15 @@ type ( internal.HostnameService configversion.ConfigurationVersionService run.RunService - repo.RepoService + repohooks.RepohookService logs.LogsService notifications.NotificationService + connections.ConnectionService + github.GithubAppService Handlers []internal.Handlers - agent process - cloudService *inmem.CloudService + agent process } process interface { @@ -90,10 +95,6 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { if err != nil { return nil, fmt.Errorf("setting up web page renderer: %w", err) } - cloudService, err := inmem.NewCloudService(cfg.Github.Config, cfg.Gitlab.Config) - if err != nil { - return nil, err - } cache, err := inmem.NewCache(*cfg.CacheConfig) if err != nil { return nil, err @@ -151,21 +152,46 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { return nil, fmt.Errorf("setting up authentication middleware: %w", err) } + githubAppService := github.NewService(github.Options{ + Logger: logger, + DB: db, + Renderer: renderer, + HostnameService: hostnameService, + GithubHostname: cfg.GithubHostname, + SkipTLSVerification: cfg.SkipTLSVerification, + }) + + vcsEventBroker := &vcs.Broker{} + vcsProviderService := vcsprovider.NewService(vcsprovider.Options{ - Logger: logger, - DB: db, - Renderer: renderer, - Responder: responder, - CloudService: cloudService, + Logger: logger, + DB: db, + Renderer: renderer, + Responder: responder, + HostnameService: hostnameService, + GithubAppService: githubAppService, + GithubHostname: cfg.GithubHostname, + GitlabHostname: cfg.GitlabHostname, + SkipTLSVerification: cfg.SkipTLSVerification, + Subscriber: vcsEventBroker, }) - repoService := repo.NewService(ctx, repo.Options{ + repoService := repohooks.NewService(ctx, repohooks.Options{ Logger: logger, DB: db, - CloudService: cloudService, HostnameService: hostnameService, - Broker: broker, OrganizationService: orgService, VCSProviderService: vcsProviderService, + GithubAppService: githubAppService, + VCSEventBroker: vcsEventBroker, + }) + repoService.RegisterCloudHandler(vcs.GithubKind, github.HandleEvent) + repoService.RegisterCloudHandler(vcs.GitlabKind, gitlab.HandleEvent) + + connectionService := connections.NewService(ctx, connections.Options{ + Logger: logger, + DB: db, + VCSProviderService: vcsProviderService, + RepohookService: repoService, }) releasesService := releases.NewService(releases.Options{ Logger: logger, @@ -180,7 +206,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { Broker: broker, Renderer: renderer, Responder: responder, - RepoService: repoService, + ConnectionService: connectionService, TeamService: authService, OrganizationService: orgService, VCSProviderService: vcsProviderService, @@ -194,6 +220,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { Signer: signer, MaxConfigSize: cfg.MaxConfigSize, }) + runService := run.NewService(run.Options{ Logger: logger, DB: db, @@ -206,7 +233,7 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { VCSProviderService: vcsProviderService, Broker: broker, Cache: cache, - Subscriber: repoService, + VCSEventSubscriber: vcsEventBroker, Signer: signer, ReleasesService: releasesService, }) @@ -225,7 +252,9 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { HostnameService: hostnameService, VCSProviderService: vcsProviderService, Signer: signer, - RepoService: repoService, + ConnectionService: connectionService, + RepohookService: repoService, + VCSEventSubscriber: vcsEventBroker, }) stateService := state.NewService(state.Options{ Logger: logger, @@ -265,15 +294,37 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { return nil, err } - authenticatorService, err := authenticator.NewAuthenticatorService(authenticator.Options{ - Logger: logger, - Renderer: renderer, - HostnameService: hostnameService, - OrganizationService: orgService, - AuthService: authService, - TokensService: tokensService, - Configs: []cloud.CloudOAuthConfig{cfg.Github, cfg.Gitlab}, - OIDCConfigs: []cloud.OIDCConfig{cfg.OIDC}, + authenticatorService, err := authenticator.NewAuthenticatorService(ctx, authenticator.Options{ + Logger: logger, + Renderer: renderer, + HostnameService: hostnameService, + TokensService: tokensService, + OpaqueHandlerConfigs: []authenticator.OpaqueHandlerConfig{ + { + ClientConstructor: github.NewOAuthClient, + OAuthConfig: authenticator.OAuthConfig{ + Hostname: cfg.GithubHostname, + Name: string(vcs.GithubKind), + Endpoint: github.OAuthEndpoint, + Scopes: github.OAuthScopes, + ClientID: cfg.GithubClientID, + ClientSecret: cfg.GithubClientSecret, + }, + }, + { + ClientConstructor: gitlab.NewOAuthClient, + OAuthConfig: authenticator.OAuthConfig{ + Hostname: cfg.GitlabHostname, + Name: string(vcs.GitlabKind), + Endpoint: gitlab.OAuthEndpoint, + Scopes: gitlab.OAuthScopes, + ClientID: cfg.GitlabClientID, + ClientSecret: cfg.GitlabClientSecret, + }, + }, + }, + IDTokenHandlerConfig: cfg.OIDC, + SkipTLSVerification: cfg.SkipTLSVerification, }) if err != nil { return nil, err @@ -314,7 +365,14 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { loginServer, configService, notificationService, + githubAppService, disco.Service{}, + &ghapphandler.Handler{ + Logger: logger, + Publisher: vcsEventBroker, + GithubAppService: githubAppService, + VCSProviderService: vcsProviderService, + }, } return &Daemon{ @@ -333,13 +391,13 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) { ConfigurationVersionService: configService, RunService: runService, LogsService: logsService, - RepoService: repoService, + RepohookService: repoService, NotificationService: notificationService, - //ReleasesService: releasesService, - Broker: broker, - DB: db, - agent: agent, - cloudService: cloudService, + GithubAppService: githubAppService, + ConnectionService: connectionService, + Broker: broker, + DB: db, + agent: agent, }, nil } @@ -405,16 +463,6 @@ func (d *Daemon) Start(ctx context.Context, started chan struct{}) error { WorkspaceService: d.WorkspaceService, }, }, - { - Name: "webhook purger", - Logger: d.Logger, - System: &repo.Purger{ - Logger: d.Logger.WithValues("component", "purger"), - Subscriber: d.Broker, - Service: d.RepoService, - DB: d.DB, - }, - }, { Name: "notifier", Logger: d.Logger, diff --git a/internal/ghapphandler/ghapphandler.go b/internal/ghapphandler/ghapphandler.go new file mode 100644 index 000000000..4fe7ab741 --- /dev/null +++ b/internal/ghapphandler/ghapphandler.go @@ -0,0 +1,55 @@ +// Package ghapphandler provides a handler for the github app webhook endpoint. +package ghapphandler + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/logr" + "github.com/leg100/otf/internal/vcs" + "github.com/leg100/otf/internal/vcsprovider" +) + +type Handler struct { + logr.Logger + vcs.Publisher + github.GithubAppService + vcsprovider.VCSProviderService +} + +func (h *Handler) AddHandlers(r *mux.Router) { + r.Handle(github.AppEventsPath, h) +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // permit handler to talk to services + ctx := internal.AddSubjectToContext(r.Context(), &internal.Superuser{Username: "github-app-event-handler"}) + // retrieve github app config; if one hasn't been configured then return a + // 400 + app, err := h.GetGithubApp(ctx) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // use github-specific handler to unmarshal event + payload := github.HandleEvent(w, r, app.WebhookSecret) + if payload == nil { + return + } + h.V(2).Info("received vcs event", "type", "github-app", "repo", payload.RepoPath) + // relay a copy of the event for each vcs provider configured with the + // github app install that triggered the event. + providers, err := h.ListVCSProvidersByGithubAppInstall(ctx, *payload.GithubAppInstallID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + for _, prov := range providers { + h.Publish(vcs.Event{ + EventHeader: vcs.EventHeader{VCSProviderID: prov.ID}, + EventPayload: *payload, + }) + } +} diff --git a/internal/github/app.go b/internal/github/app.go new file mode 100644 index 000000000..7064ed7bb --- /dev/null +++ b/internal/github/app.go @@ -0,0 +1,67 @@ +package github + +import ( + "fmt" + + "golang.org/x/exp/slog" +) + +type ( + App struct { + ID int64 // github's app id + Slug string // github's "slug" name + WebhookSecret string + PrivateKey string + + // Organization is the name of the organization that owns the app. If + // the app is owned by a user then this is nil. + Organization *string + } + + CreateAppOptions struct { + AppID int64 + WebhookSecret string + PrivateKey string + Slug string + Organization *string + } +) + +func newApp(opts CreateAppOptions) *App { + return &App{ + ID: opts.AppID, + Slug: opts.Slug, + WebhookSecret: opts.WebhookSecret, + PrivateKey: opts.PrivateKey, + Organization: opts.Organization, + } +} + +func (a *App) String() string { return a.Slug } + +// URL returns the app's URL on GitHub +func (a *App) URL(hostname string) string { + return "https://" + hostname + "/apps/" + a.Slug +} + +// NewInstallURL returns the GitHub URL for creating a new install of the app. +func (a *App) NewInstallURL(hostname string) string { + return "https://" + hostname + "/apps/" + a.Slug + "/installations/new" +} + +// LogValue implements slog.LogValuer. +func (a *App) LogValue() slog.Value { + return slog.GroupValue( + slog.Int64("id", a.ID), + slog.String("slug", a.Slug), + ) +} + +// AdvancedURL returns the URL for the "advanced" settings on github +func (a *App) AdvancedURL(hostname string) string { + path := fmt.Sprintf("/settings/apps/%s/advanced", a.Slug) + if a.Organization != nil { + path = fmt.Sprintf("/organizations/%s%s", *a.Organization, path) + } + return fmt.Sprintf("https://%s%s", hostname, path) +} diff --git a/internal/github/client.go b/internal/github/client.go index b6eadb67b..022fc137b 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -2,6 +2,7 @@ package github import ( "context" + "sort" "errors" "fmt" @@ -13,105 +14,217 @@ import ( "strings" "time" - otfhttp "github.com/leg100/otf/internal/http" - - "github.com/google/go-github/v41/github" + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/google/go-github/v55/github" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/authenticator" + otfhttp "github.com/leg100/otf/internal/http" + "github.com/leg100/otf/internal/vcs" "golang.org/x/oauth2" ) -type Client struct { - client *github.Client -} - -func NewClient(ctx context.Context, cfg cloud.ClientOptions) (*Client, error) { - var ( +type ( + // Client is a wrapper around the upstream go-github client + Client struct { client *github.Client - err error - ) - // Optionally skip TLS verification of github API - if cfg.SkipTLSVerification { - ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{ - Transport: otfhttp.DefaultTransport(true), - }) + // whether authenticated using an installation access token + iat bool } - // Github's oauth access token never expires - var src oauth2.TokenSource - if cfg.OAuthToken != nil { - src = oauth2.StaticTokenSource(cfg.OAuthToken) - } else if cfg.PersonalToken != nil { - src = oauth2.StaticTokenSource(&oauth2.Token{AccessToken: *cfg.PersonalToken}) - } else { - return nil, fmt.Errorf("no credentials provided") + ClientOptions struct { + Hostname string + SkipTLSVerification bool + + // Only specify one of the following + OAuthToken *oauth2.Token + PersonalToken *string + *AppCredentials + *InstallCredentials + } + + // Credentials for authenticating as an app: + // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app#authentication-as-a-github-app + AppCredentials struct { + // Github app ID + ID int64 + // Private key in PEM format + PrivateKey string + } + + // Credentials for authenticating as an app installation: + // https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app#authentication-as-an-app-installation + InstallCredentials struct { + // Github installation ID + ID int64 + // Github username if installed in a user account; mutually exclusive + // with Organization + User *string + // Github organization if installed in an organization; mutually + // exclusive with User + Organization *string + + AppCredentials } +) - httpClient := oauth2.NewClient(ctx, src) +func NewClient(cfg ClientOptions) (*Client, error) { + if cfg.Hostname == "" { + cfg.Hostname = DefaultHostname + } + // build http roundtripper using provided credentials + var ( + tripper = otfhttp.DefaultTransport + err error - if cfg.Hostname != DefaultGithubHostname { - client, err = NewEnterpriseClient(cfg.Hostname, httpClient) + iat bool + ) + if cfg.SkipTLSVerification { + tripper = otfhttp.InsecureTransport + } + switch { + case cfg.AppCredentials != nil: + tripper, err = ghinstallation.NewAppsTransport(tripper, cfg.AppCredentials.ID, []byte(cfg.AppCredentials.PrivateKey)) + if err != nil { + return nil, err + } + case cfg.InstallCredentials != nil: + iat = true + creds := cfg.InstallCredentials + installTransport, err := ghinstallation.New(tripper, creds.AppCredentials.ID, creds.ID, []byte(creds.AppCredentials.PrivateKey)) + if err != nil { + return nil, err + } + // ghinstallation defaults to https://api.github.com + if cfg.Hostname != DefaultHostname { + installTransport.BaseURL = (&url.URL{Scheme: "https", Path: "/api/v3", Host: cfg.Hostname}).String() + } + tripper = installTransport + case cfg.PersonalToken != nil: + // personal token is actually an OAuth2 *access token, so wrap + // inside an OAuth2 token and handle it the same as an OAuth2 token + cfg.OAuthToken = &oauth2.Token{AccessToken: *cfg.PersonalToken} + fallthrough + case cfg.OAuthToken != nil: + tripper = &oauth2.Transport{ + Base: tripper, + // Github's oauth access token never expires + Source: oauth2.ReuseTokenSource(nil, oauth2.StaticTokenSource(cfg.OAuthToken)), + } + } + // create upstream client with roundtripper + client := github.NewClient(&http.Client{Transport: tripper}) + // Assume github enterprise if using non-default hostname + if cfg.Hostname != DefaultHostname { + client, err = client.WithEnterpriseURLs( + "https://"+cfg.Hostname, + "https://"+cfg.Hostname, + ) if err != nil { return nil, err } - } else { - client = github.NewClient(httpClient) } - return &Client{client: client}, nil + return &Client{client: client, iat: iat}, nil +} + +func NewTokenClient(opts vcs.NewTokenClientOptions) (vcs.Client, error) { + return NewClient(ClientOptions{ + Hostname: opts.Hostname, + PersonalToken: &opts.Token, + SkipTLSVerification: opts.SkipTLSVerification, + }) } -func NewEnterpriseClient(hostname string, httpClient *http.Client) (*github.Client, error) { - return github.NewEnterpriseClient( - "https://"+hostname, - "https://"+hostname, - httpClient) +func NewOAuthClient(cfg authenticator.OAuthConfig, token *oauth2.Token) (authenticator.IdentityProviderClient, error) { + return NewClient(ClientOptions{ + Hostname: cfg.Hostname, + OAuthToken: token, + SkipTLSVerification: cfg.SkipTLSVerification, + }) } -func (g *Client) GetCurrentUser(ctx context.Context) (cloud.User, error) { +func (g *Client) GetCurrentUser(ctx context.Context) (string, error) { guser, _, err := g.client.Users.Get(ctx, "") if err != nil { - return cloud.User{}, err + return "", err } - return cloud.User{Name: guser.GetLogin()}, nil + return guser.GetLogin(), nil } -func (g *Client) GetRepository(ctx context.Context, identifier string) (cloud.Repository, error) { +func (g *Client) GetRepository(ctx context.Context, identifier string) (vcs.Repository, error) { owner, name, found := strings.Cut(identifier, "/") if !found { - return cloud.Repository{}, fmt.Errorf("malformed identifier: %s", identifier) + return vcs.Repository{}, fmt.Errorf("malformed identifier: %s", identifier) } repo, _, err := g.client.Repositories.Get(ctx, owner, name) if err != nil { - return cloud.Repository{}, err + return vcs.Repository{}, err } - return cloud.Repository{ + return vcs.Repository{ Path: identifier, DefaultBranch: repo.GetDefaultBranch(), }, nil } -func (g *Client) ListRepositories(ctx context.Context, opts cloud.ListRepositoriesOptions) ([]string, error) { - repos, _, err := g.client.Repositories.List(ctx, "", &github.RepositoryListOptions{ - ListOptions: github.ListOptions{ - PerPage: opts.PageSize, - }, - // retrieve repositories in order of most recently pushed to - Sort: "pushed", - }) - if err != nil { - return nil, err +// ListRepositories lists repositories belonging to the authenticated entity: if +// authenticated using a user's oauth token or PAT then their repos are listed; +// if authenticated using a github installation then repos that the installation +// has access to are listed. +// + +// ListRepositories has different behaviour depending on the authentication: +// (a) if authenticated as an app installation then repositories accessible to +// the installation are listed; *all* repos are listed, in order of last pushed +// to. +// (b) if authenticated using a personal access token then repositories +// belonging to the user are listed; only the first page of repos is listed, +// those that have most recently been pushed to. +func (g *Client) ListRepositories(ctx context.Context, opts vcs.ListRepositoriesOptions) ([]string, error) { + var ( + repos []*github.Repository + ) + if g.iat { + // Apps.ListRepos endpoint does not support ordering on the server-side, + // so instead we request *all* repos, page-by-page, and then sort + // client-side. + var page = 1 + for { + result, resp, err := g.client.Apps.ListRepos(ctx, &github.ListOptions{ + PerPage: opts.PageSize, + Page: page, + }) + if err != nil { + return nil, err + } + repos = append(repos, result.Repositories...) + if resp.NextPage != 0 { + page = resp.NextPage + } else { + break + } + } + // sort repositories in order of most recently pushed to + sort.Slice(repos, func(i, j int) bool { return repos[i].GetPushedAt().After(repos[j].GetPushedAt().Time) }) + } else { + var err error + repos, _, err = g.client.Repositories.List(ctx, "", &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: opts.PageSize}, + // retrieve repositories in order of most recently pushed to + Sort: "pushed", + }) + if err != nil { + return nil, err + } } - - var names []string - for _, repo := range repos { - names = append(names, repo.GetFullName()) + names := make([]string, len(repos)) + for i, repo := range repos { + names[i] = repo.GetFullName() } return names, nil } -func (g *Client) ListTags(ctx context.Context, opts cloud.ListTagsOptions) ([]string, error) { +func (g *Client) ListTags(ctx context.Context, opts vcs.ListTagsOptions) ([]string, error) { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return nil, fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -132,7 +245,15 @@ func (g *Client) ListTags(ctx context.Context, opts cloud.ListTagsOptions) ([]st return tags, nil } -func (g *Client) GetRepoTarball(ctx context.Context, opts cloud.GetRepoTarballOptions) ([]byte, string, error) { +func (g *Client) ExchangeCode(ctx context.Context, code string) (*github.AppConfig, error) { + cfg, _, err := g.client.Apps.CompleteAppManifest(ctx, code) + if err != nil { + return nil, err + } + return cfg, nil +} + +func (g *Client) GetRepoTarball(ctx context.Context, opts vcs.GetRepoTarballOptions) ([]byte, string, error) { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return nil, "", fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -193,7 +314,7 @@ func (g *Client) GetRepoTarball(ctx context.Context, opts cloud.GetRepoTarballOp } // CreateWebhook creates a webhook on a github repository. -func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOptions) (string, error) { +func (g *Client) CreateWebhook(ctx context.Context, opts vcs.CreateWebhookOptions) (string, error) { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return "", fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -202,9 +323,9 @@ func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOpti var events []string for _, event := range opts.Events { switch event { - case cloud.VCSEventTypePush: + case vcs.EventTypePush: events = append(events, "push") - case cloud.VCSEventTypePull: + case vcs.EventTypePull: events = append(events, "pull_request") } } @@ -224,7 +345,7 @@ func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOpti return strconv.FormatInt(hook.GetID(), 10), nil } -func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.UpdateWebhookOptions) error { +func (g *Client) UpdateWebhook(ctx context.Context, id string, opts vcs.UpdateWebhookOptions) error { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -238,9 +359,9 @@ func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.Update var events []string for _, event := range opts.Events { switch event { - case cloud.VCSEventTypePush: + case vcs.EventTypePush: events = append(events, "push") - case cloud.VCSEventTypePull: + case vcs.EventTypePull: events = append(events, "pull_request") } } @@ -260,46 +381,46 @@ func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.Update return nil } -func (g *Client) GetWebhook(ctx context.Context, opts cloud.GetWebhookOptions) (cloud.Webhook, error) { +func (g *Client) GetWebhook(ctx context.Context, opts vcs.GetWebhookOptions) (vcs.Webhook, error) { owner, name, found := strings.Cut(opts.Repo, "/") if !found { - return cloud.Webhook{}, fmt.Errorf("malformed identifier: %s", opts.Repo) + return vcs.Webhook{}, fmt.Errorf("malformed identifier: %s", opts.Repo) } intID, err := strconv.ParseInt(opts.ID, 10, 64) if err != nil { - return cloud.Webhook{}, err + return vcs.Webhook{}, err } hook, resp, err := g.client.Repositories.GetHook(ctx, owner, name, intID) if err != nil { if resp.StatusCode == http.StatusNotFound { - return cloud.Webhook{}, internal.ErrResourceNotFound + return vcs.Webhook{}, internal.ErrResourceNotFound } - return cloud.Webhook{}, err + return vcs.Webhook{}, err } - var events []cloud.VCSEventType + var events []vcs.EventType for _, event := range hook.Events { switch event { case "push": - events = append(events, cloud.VCSEventTypePush) + events = append(events, vcs.EventTypePush) case "pull_request": - events = append(events, cloud.VCSEventTypePull) + events = append(events, vcs.EventTypePull) } } // extracting OTF endpoint from github's config map is a bit of work... rawEndpoint, ok := hook.Config["url"] if !ok { - return cloud.Webhook{}, errors.New("missing url") + return vcs.Webhook{}, errors.New("missing url") } endpoint, ok := rawEndpoint.(string) if !ok { - return cloud.Webhook{}, errors.New("url is not a string") + return vcs.Webhook{}, errors.New("url is not a string") } - return cloud.Webhook{ + return vcs.Webhook{ ID: strconv.FormatInt(hook.GetID(), 10), Repo: opts.Repo, Events: events, @@ -307,7 +428,7 @@ func (g *Client) GetWebhook(ctx context.Context, opts cloud.GetWebhookOptions) ( }, nil } -func (g *Client) DeleteWebhook(ctx context.Context, opts cloud.DeleteWebhookOptions) error { +func (g *Client) DeleteWebhook(ctx context.Context, opts vcs.DeleteWebhookOptions) error { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -322,7 +443,7 @@ func (g *Client) DeleteWebhook(ctx context.Context, opts cloud.DeleteWebhookOpti return err } -func (g *Client) SetStatus(ctx context.Context, opts cloud.SetStatusOptions) error { +func (g *Client) SetStatus(ctx context.Context, opts vcs.SetStatusOptions) error { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -330,13 +451,13 @@ func (g *Client) SetStatus(ctx context.Context, opts cloud.SetStatusOptions) err var status string switch opts.Status { - case cloud.VCSPendingStatus, cloud.VCSRunningStatus: + case vcs.PendingStatus, vcs.RunningStatus: status = "pending" - case cloud.VCSSuccessStatus: + case vcs.SuccessStatus: status = "success" - case cloud.VCSErrorStatus: + case vcs.ErrorStatus: status = "error" - case cloud.VCSFailureStatus: + case vcs.FailureStatus: status = "failure" default: return fmt.Errorf("invalid vcs status: %s", opts.Status) @@ -407,25 +528,57 @@ listloop: return files, nil } -func (g *Client) GetCommit(ctx context.Context, repo, ref string) (cloud.Commit, error) { +func (g *Client) GetCommit(ctx context.Context, repo, ref string) (vcs.Commit, error) { owner, name, found := strings.Cut(repo, "/") if !found { - return cloud.Commit{}, fmt.Errorf("malformed identifier: %s", repo) + return vcs.Commit{}, fmt.Errorf("malformed identifier: %s", repo) } commit, resp, err := g.client.Repositories.GetCommit(ctx, owner, name, ref, nil) if err != nil { - return cloud.Commit{}, err + return vcs.Commit{}, err } defer resp.Body.Close() - return cloud.Commit{ + return vcs.Commit{ SHA: commit.GetSHA(), URL: commit.GetHTMLURL(), - Author: cloud.CommitAuthor{ + Author: vcs.CommitAuthor{ Username: commit.GetAuthor().GetLogin(), AvatarURL: commit.GetAuthor().GetAvatarURL(), ProfileURL: commit.GetAuthor().GetHTMLURL(), }, }, nil } + +// ListInstallations lists installations of the currently authenticated app. +func (g *Client) ListInstallations(ctx context.Context) ([]*github.Installation, error) { + installs, resp, err := g.client.Apps.ListInstallations(ctx, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return installs, err +} + +func (g *Client) GetInstallation(ctx context.Context, installID int64) (*github.Installation, error) { + install, resp, err := g.client.Apps.GetInstallation(ctx, installID) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return install, err +} + +// DeleteInstallation deletes an installation of a github app with the given +// installation ID. +func (g *Client) DeleteInstallation(ctx context.Context, installID int64) error { + resp, err := g.client.Apps.DeleteInstallation(ctx, installID) + if err != nil { + return err + } + defer resp.Body.Close() + return err +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go index 09de94f0f..285b0e0f3 100644 --- a/internal/github/client_test.go +++ b/internal/github/client_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -16,13 +16,13 @@ import ( func TestGetUser(t *testing.T) { ctx := context.Background() - want := cloud.User{Name: "fake-user"} + want := "fake-user" client := newTestServerClient(t, WithUser(&want)) got, err := client.GetCurrentUser(ctx) require.NoError(t, err) - assert.Equal(t, want.Name, got.Name) + assert.Equal(t, want, got) } func TestGetRepository(t *testing.T) { @@ -51,7 +51,7 @@ func TestGetRepoTarball(t *testing.T) { WithArchive(want), ) - got, ref, err := client.GetRepoTarball(ctx, cloud.GetRepoTarballOptions{ + got, ref, err := client.GetRepoTarball(ctx, vcs.GetRepoTarballOptions{ Repo: "acme/terraform", }) require.NoError(t, err) @@ -70,7 +70,7 @@ func TestCreateWebhook(t *testing.T) { WithRepo("acme/terraform"), ) - _, err := client.CreateWebhook(ctx, cloud.CreateWebhookOptions{ + _, err := client.CreateWebhook(ctx, vcs.CreateWebhookOptions{ Repo: "acme/terraform", Secret: "me-secret", }) @@ -80,14 +80,12 @@ func TestCreateWebhook(t *testing.T) { // newTestServerClient creates a github server for testing purposes and // returns a client configured to access the server. func newTestServerClient(t *testing.T, opts ...TestServerOption) *Client { - _, cfg := NewTestServer(t, opts...) + _, u := NewTestServer(t, opts...) - client, err := NewClient(context.Background(), cloud.ClientOptions{ - Hostname: cfg.Hostname, + client, err := NewClient(ClientOptions{ + Hostname: u.Host, SkipTLSVerification: true, - Credentials: cloud.Credentials{ - OAuthToken: &oauth2.Token{AccessToken: "fake-token"}, - }, + OAuthToken: &oauth2.Token{AccessToken: "fake-token"}, }) require.NoError(t, err) diff --git a/internal/github/cloud.go b/internal/github/cloud.go deleted file mode 100644 index ff277b15e..000000000 --- a/internal/github/cloud.go +++ /dev/null @@ -1,18 +0,0 @@ -package github - -import ( - "context" - "net/http" - - "github.com/leg100/otf/internal/cloud" -) - -type Cloud struct{} - -func (g *Cloud) NewClient(ctx context.Context, opts cloud.ClientOptions) (cloud.Client, error) { - return NewClient(ctx, opts) -} - -func (Cloud) HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.VCSEvent { - return HandleEvent(w, r, secret) -} diff --git a/internal/github/db.go b/internal/github/db.go new file mode 100644 index 000000000..05169c472 --- /dev/null +++ b/internal/github/db.go @@ -0,0 +1,71 @@ +package github + +import ( + "context" + + "github.com/jackc/pgtype" + "github.com/leg100/otf/internal/sql" + "github.com/leg100/otf/internal/sql/pggen" +) + +type ( + // pgdb is a github app database on postgres + pgdb struct { + *sql.DB // provides access to generated SQL queries + } + + // row represents a database row for a github app + row struct { + GithubAppID pgtype.Int8 `json:"github_app_id"` + WebhookSecret pgtype.Text `json:"webhook_secret"` + PrivateKey pgtype.Text `json:"private_key"` + Slug pgtype.Text `json:"slug"` + Organization pgtype.Text `json:"organization"` + } +) + +func (r row) convert() *App { + app := &App{ + ID: r.GithubAppID.Int, + Slug: r.Slug.String, + WebhookSecret: r.WebhookSecret.String, + PrivateKey: r.PrivateKey.String, + } + if r.Organization.Status == pgtype.Present { + app.Organization = &r.Organization.String + } + return app +} + +func (db *pgdb) create(ctx context.Context, app *App) error { + _, err := db.Conn(ctx).InsertGithubApp(ctx, pggen.InsertGithubAppParams{ + GithubAppID: pgtype.Int8{Int: app.ID, Status: pgtype.Present}, + WebhookSecret: sql.String(app.WebhookSecret), + PrivateKey: sql.String(app.PrivateKey), + Slug: sql.String(app.Slug), + Organization: sql.StringPtr(app.Organization), + }) + return err +} + +func (db *pgdb) get(ctx context.Context) (*App, error) { + result, err := db.Conn(ctx).FindGithubApp(ctx) + if err != nil { + return nil, sql.Error(err) + } + return row(result).convert(), nil +} + +func (db *pgdb) delete(ctx context.Context) error { + return db.Lock(ctx, "github_apps", func(ctx context.Context, q pggen.Querier) error { + result, err := db.Conn(ctx).FindGithubApp(ctx) + if err != nil { + return sql.Error(err) + } + _, err = db.Conn(ctx).DeleteGithubApp(ctx, result.GithubAppID) + if err != nil { + return sql.Error(err) + } + return nil + }) +} diff --git a/internal/github/github.go b/internal/github/github.go index a743c90af..134e57955 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -2,26 +2,17 @@ package github import ( - "github.com/leg100/otf/internal/cloud" - "golang.org/x/oauth2" oauth2github "golang.org/x/oauth2/github" ) const ( - DefaultGithubHostname string = "github.com" + DefaultHostname = "github.com" ) -func Defaults() cloud.Config { - return cloud.Config{ - Name: "github", - Hostname: DefaultGithubHostname, - Cloud: &Cloud{}, - } -} +var ( + OAuthEndpoint = oauth2github.Endpoint -func OAuthDefaults() *oauth2.Config { - return &oauth2.Config{ - Endpoint: oauth2github.Endpoint, - Scopes: []string{"user:email", "read:org"}, - } -} + // TODO: don't think read:org scope is necessary any more...not since OTF + // stopped sync'ing org and team memberships from github. + OAuthScopes = []string{"user:email", "read:org"} +) diff --git a/internal/github/installation.go b/internal/github/installation.go new file mode 100644 index 000000000..dc2094df9 --- /dev/null +++ b/internal/github/installation.go @@ -0,0 +1,14 @@ +package github + +import "github.com/google/go-github/v55/github" + +type Installation struct { + *github.Installation +} + +func (i *Installation) String() string { + if i.GetAccount().GetType() == "Organization" { + return "org/" + i.GetAccount().GetLogin() + } + return "user/" + i.GetAccount().GetLogin() +} diff --git a/internal/github/event_handler.go b/internal/github/repohook_handler.go similarity index 64% rename from internal/github/event_handler.go rename to internal/github/repohook_handler.go index 45ea45413..5dd22f612 100644 --- a/internal/github/event_handler.go +++ b/internal/github/repohook_handler.go @@ -5,13 +5,12 @@ import ( "net/http" "strings" - "github.com/google/go-github/v41/github" - "github.com/leg100/otf/internal/cloud" + "github.com/google/go-github/v55/github" + "github.com/leg100/otf/internal/vcs" ) -// HandleEvent handles incoming events from github -func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.VCSEvent { - event, err := handle(r, secret) +func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *vcs.EventPayload { + event, err := handleEventWithError(r, secret) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return nil @@ -20,23 +19,23 @@ func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.V return event } -func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { +func handleEventWithError(r *http.Request, secret string) (*vcs.EventPayload, error) { payload, err := github.ValidatePayload(r, []byte(secret)) if err != nil { return nil, err } - rawEvent, err := github.ParseWebHook(github.WebHookType(r), payload) + raw, err := github.ParseWebHook(github.WebHookType(r), payload) if err != nil { return nil, err } // convert github event to an OTF event - to := cloud.VCSEvent{ - Cloud: cloud.Github, + to := vcs.EventPayload{ + VCSKind: vcs.GithubKind, } - switch event := rawEvent.(type) { + switch event := raw.(type) { case *github.PushEvent: // populate event with list of changed file paths for _, c := range event.Commits { @@ -44,6 +43,7 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { to.Paths = append(to.Paths, c.Modified...) to.Paths = append(to.Paths, c.Removed...) } + to.RepoPath = event.GetRepo().GetFullName() to.CommitSHA = event.GetAfter() to.CommitURL = event.GetHeadCommit().GetURL() to.DefaultBranch = event.GetRepo().GetDefaultBranch() @@ -52,6 +52,10 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { to.SenderAvatarURL = event.GetSender().GetAvatarURL() to.SenderHTMLURL = event.GetSender().GetHTMLURL() + if install := event.GetInstallation(); install != nil { + to.GithubAppInstallID = install.ID + } + // a github.PushEvent includes tag events but OTF categorises them as separate // event types parts := strings.Split(event.GetRef(), "/") @@ -60,31 +64,30 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { } switch parts[1] { case "tags": - to.Type = cloud.VCSEventTypeTag + to.Type = vcs.EventTypeTag switch { case event.GetCreated(): - to.Action = cloud.VCSActionCreated + to.Action = vcs.ActionCreated case event.GetDeleted(): - to.Action = cloud.VCSActionDeleted + to.Action = vcs.ActionDeleted default: return nil, fmt.Errorf("no action specified for tag event") } to.Tag = parts[2] - return &to, nil case "heads": - to.Type = cloud.VCSEventTypePush - to.Action = cloud.VCSActionCreated + to.Type = vcs.EventTypePush + to.Action = vcs.ActionCreated to.Branch = parts[2] - return &to, nil default: return nil, fmt.Errorf("malformed ref: %s", event.GetRef()) } case *github.PullRequestEvent: - to.Type = cloud.VCSEventTypePull + to.Type = vcs.EventTypePull + to.RepoPath = event.GetRepo().GetFullName() to.PullRequestNumber = event.GetPullRequest().GetNumber() to.PullRequestURL = event.GetPullRequest().GetHTMLURL() to.PullRequestTitle = event.GetPullRequest().GetTitle() @@ -93,17 +96,21 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { to.SenderAvatarURL = event.GetSender().GetAvatarURL() to.SenderHTMLURL = event.GetSender().GetHTMLURL() + if install := event.GetInstallation(); install != nil { + to.GithubAppInstallID = install.ID + } + switch event.GetAction() { case "opened": - to.Action = cloud.VCSActionCreated + to.Action = vcs.ActionCreated case "closed": if event.PullRequest.GetMerged() { - to.Action = cloud.VCSActionMerged + to.Action = vcs.ActionMerged } else { - to.Action = cloud.VCSActionDeleted + to.Action = vcs.ActionDeleted } case "synchronize": - to.Action = cloud.VCSActionUpdated + to.Action = vcs.ActionUpdated default: // ignore other pull request events return nil, nil @@ -116,9 +123,19 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { // commit-url isn't provided in a pull-request event so one is // constructed instead to.CommitURL = event.GetRepo().GetHTMLURL() + "/commit/" + to.CommitSHA - - return &to, nil + case *github.InstallationEvent: + // ignore events other than uninstallation events + if event.GetAction() != "deleted" { + return nil, nil + } + to.Action = vcs.ActionDeleted + to.Type = vcs.EventTypeInstallation + to.GithubAppInstallID = event.GetInstallation().ID default: return nil, nil } + if err := to.Validate(); err != nil { + return nil, err + } + return &to, nil } diff --git a/internal/github/event_handler_test.go b/internal/github/repohook_handler_test.go similarity index 62% rename from internal/github/event_handler_test.go rename to internal/github/repohook_handler_test.go index fd2990692..f7c2d3f23 100644 --- a/internal/github/event_handler_test.go +++ b/internal/github/repohook_handler_test.go @@ -5,8 +5,9 @@ import ( "os" "testing" - "github.com/google/go-github/v41/github" - "github.com/leg100/otf/internal/cloud" + "github.com/google/go-github/v55/github" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,33 +17,55 @@ func TestEventHandler(t *testing.T) { name string eventType string body string - want *cloud.VCSEvent + want *vcs.EventPayload }{ { "push", "push", "./testdata/github_push.json", - &cloud.VCSEvent{ - Cloud: cloud.Github, - Type: cloud.VCSEventTypePush, + &vcs.EventPayload{ + VCSKind: vcs.GithubKind, + Type: vcs.EventTypePush, + RepoPath: "leg100/tfc-workspaces", Branch: "master", DefaultBranch: "master", CommitSHA: "42d6fc7dac35cc7945231195e248af2f6256b522", CommitURL: "https://github.com/leg100/tfc-workspaces/commit/42d6fc7dac35cc7945231195e248af2f6256b522", - Action: cloud.VCSActionCreated, + Action: vcs.ActionCreated, Paths: []string{"main.tf"}, SenderUsername: "leg100", SenderAvatarURL: "https://avatars.githubusercontent.com/u/75728?v=4", SenderHTMLURL: "https://github.com/leg100", }, }, + { + "push from github app install", + "push", + "./testdata/github_app_push.json", + &vcs.EventPayload{ + VCSKind: vcs.GithubKind, + Type: vcs.EventTypePush, + RepoPath: "leg100/otf-workspaces", + Branch: "master", + DefaultBranch: "master", + CommitSHA: "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + CommitURL: "https://github.com/leg100/otf-workspaces/commit/0a2d223fa1a3844480e3b7716cf87aacb658b91f", + Action: vcs.ActionCreated, + Paths: []string{}, + SenderUsername: "leg100", + SenderAvatarURL: "https://avatars.githubusercontent.com/u/75728?v=4", + SenderHTMLURL: "https://github.com/leg100", + GithubAppInstallID: internal.Int64(42997659), + }, + }, { "pull request opened", "pull_request", "./testdata/github_pull_opened.json", - &cloud.VCSEvent{ - Cloud: cloud.Github, - Type: cloud.VCSEventTypePull, + &vcs.EventPayload{ + VCSKind: vcs.GithubKind, + Type: vcs.EventTypePull, + RepoPath: "leg100/otf-workspaces", Branch: "pr-2", DefaultBranch: "master", CommitSHA: "c560613b228f5e189520fbab4078284ea8312bcb", @@ -50,7 +73,7 @@ func TestEventHandler(t *testing.T) { PullRequestNumber: 2, PullRequestURL: "https://github.com/leg100/otf-workspaces/pull/2", PullRequestTitle: "pr-2", - Action: cloud.VCSActionCreated, + Action: vcs.ActionCreated, SenderUsername: "leg100", SenderAvatarURL: "https://avatars.githubusercontent.com/u/75728?v=4", SenderHTMLURL: "https://github.com/leg100", @@ -60,9 +83,10 @@ func TestEventHandler(t *testing.T) { "pull request updated", "pull_request", "./testdata/github_pull_update.json", - &cloud.VCSEvent{ - Cloud: cloud.Github, - Type: cloud.VCSEventTypePull, + &vcs.EventPayload{ + VCSKind: vcs.GithubKind, + Type: vcs.EventTypePull, + RepoPath: "leg100/otf-workspaces", Branch: "pr-1", DefaultBranch: "master", CommitSHA: "067e2b4c6394b3dad3c0ec89ffc428ab60ae7e5d", @@ -70,7 +94,7 @@ func TestEventHandler(t *testing.T) { PullRequestNumber: 1, PullRequestURL: "https://github.com/leg100/otf-workspaces/pull/1", PullRequestTitle: "pr-1", - Action: cloud.VCSActionUpdated, + Action: vcs.ActionUpdated, SenderUsername: "leg100", SenderAvatarURL: "https://avatars.githubusercontent.com/u/75728?v=4", SenderHTMLURL: "https://github.com/leg100", @@ -80,14 +104,15 @@ func TestEventHandler(t *testing.T) { "tag pushed", "push", "./testdata/github_push_tag.json", - &cloud.VCSEvent{ - Cloud: cloud.Github, - Type: cloud.VCSEventTypeTag, + &vcs.EventPayload{ + VCSKind: vcs.GithubKind, + Type: vcs.EventTypeTag, + RepoPath: "leg100/terraform-otf-test", Tag: "v1.0.0", DefaultBranch: "master", CommitSHA: "07101e82c4f525d5f697111f0690bdd0ff40a865", CommitURL: "https://github.com/leg100/terraform-otf-test/commit/07101e82c4f525d5f697111f0690bdd0ff40a865", - Action: cloud.VCSActionCreated, + Action: vcs.ActionCreated, SenderUsername: "leg100", SenderAvatarURL: "https://avatars.githubusercontent.com/u/75728?v=4", SenderHTMLURL: "https://github.com/leg100", @@ -105,7 +130,7 @@ func TestEventHandler(t *testing.T) { r.Header.Add(github.EventTypeHeader, tt.eventType) w := httptest.NewRecorder() got := HandleEvent(w, r, "") - assert.Equal(t, 202, w.Code) + assert.Equal(t, 202, w.Code, w.Body.String()) assert.Equal(t, tt.want, got) }) } diff --git a/internal/github/service.go b/internal/github/service.go new file mode 100644 index 000000000..bcfd9bc40 --- /dev/null +++ b/internal/github/service.go @@ -0,0 +1,204 @@ +package github + +import ( + "context" + "errors" + "fmt" + + "github.com/go-logr/logr" + "github.com/gorilla/mux" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/http/html" + "github.com/leg100/otf/internal/organization" + "github.com/leg100/otf/internal/rbac" + "github.com/leg100/otf/internal/sql" + "github.com/leg100/otf/internal/vcs" +) + +type ( + // Alias services so they don't conflict when nested together in struct + GithubAppService Service + + Service interface { + CreateGithubApp(ctx context.Context, opts CreateAppOptions) (*App, error) + // GetGithubApp returns the github app. If no github app has been + // created then nil is returned without an error. + GetGithubApp(ctx context.Context) (*App, error) + DeleteGithubApp(ctx context.Context) error + + ListInstallations(ctx context.Context) ([]*Installation, error) + DeleteInstallation(ctx context.Context, installID int64) error + + GetInstallCredentials(ctx context.Context, installID int64) (*InstallCredentials, error) + } + + service struct { + logr.Logger + + GithubHostname string + + site internal.Authorizer + organization internal.Authorizer + db *pgdb + web *webHandlers + } + + Options struct { + internal.HostnameService + *sql.DB + html.Renderer + logr.Logger + vcs.Publisher + GithubHostname string + SkipTLSVerification bool + } +) + +func NewService(opts Options) *service { + svc := service{ + Logger: opts.Logger, + GithubHostname: opts.GithubHostname, + site: &internal.SiteAuthorizer{Logger: opts.Logger}, + organization: &organization.Authorizer{Logger: opts.Logger}, + db: &pgdb{opts.DB}, + } + svc.web = &webHandlers{ + Renderer: opts.Renderer, + HostnameService: opts.HostnameService, + GithubHostname: opts.GithubHostname, + GithubSkipTLS: opts.SkipTLSVerification, + svc: &svc, + } + return &svc +} + +func (a *service) AddHandlers(r *mux.Router) { + a.web.addHandlers(r) +} + +func (a *service) CreateGithubApp(ctx context.Context, opts CreateAppOptions) (*App, error) { + subject, err := a.site.CanAccess(ctx, rbac.CreateGithubAppAction, "") + if err != nil { + return nil, err + } + + app := newApp(opts) + + if err := a.db.create(ctx, app); err != nil { + a.Error(err, "creating github app", "app", app, "subject", subject) + return nil, err + } + a.V(0).Info("created github app", "app", app, "subject", subject) + return app, nil +} + +func (a *service) GetGithubApp(ctx context.Context) (*App, error) { + subject, err := a.site.CanAccess(ctx, rbac.GetGithubAppAction, "") + if err != nil { + return nil, err + } + + app, err := a.db.get(ctx) + if errors.Is(err, internal.ErrResourceNotFound) { + return nil, nil + } else if err != nil { + return nil, err + } + a.V(9).Info("retrieved github app", "app", app, "subject", subject) + + return app, nil +} + +func (a *service) DeleteGithubApp(ctx context.Context) error { + subject, err := a.site.CanAccess(ctx, rbac.DeleteGithubAppAction, "") + if err != nil { + return err + } + + err = a.db.delete(ctx) + if err != nil { + a.Error(err, "deleting github app", "subject", subject) + return err + } + a.V(0).Info("deleted github app", "subject", subject) + return nil +} + +func (a *service) ListInstallations(ctx context.Context) ([]*Installation, error) { + app, err := a.db.get(ctx) + if errors.Is(err, internal.ErrResourceNotFound) { + return nil, nil + } else if err != nil { + return nil, err + } + client, err := a.newClient(app) + if err != nil { + return nil, err + } + from, err := client.ListInstallations(ctx) + if err != nil { + return nil, err + } + to := make([]*Installation, len(from)) + for i, f := range from { + to[i] = &Installation{Installation: f} + } + return to, nil +} + +func (a *service) GetInstallCredentials(ctx context.Context, installID int64) (*InstallCredentials, error) { + app, err := a.db.get(ctx) + if err != nil { + return nil, err + } + client, err := a.newClient(app) + if err != nil { + return nil, err + } + install, err := client.GetInstallation(ctx, installID) + if err != nil { + return nil, err + } + creds := InstallCredentials{ + ID: installID, + AppCredentials: AppCredentials{ + ID: app.ID, + PrivateKey: app.PrivateKey, + }, + } + switch install.GetTargetType() { + case "Organization": + creds.Organization = install.GetAccount().Login + case "User": + creds.User = install.GetAccount().Login + default: + return nil, fmt.Errorf("unexpected target type: %s", install.GetTargetType()) + } + return &creds, nil +} + +func (a *service) DeleteInstallation(ctx context.Context, installID int64) error { + app, err := a.db.get(ctx) + if err != nil { + return err + } + client, err := a.newClient(app) + if err != nil { + return err + } + if err := client.DeleteInstallation(ctx, installID); err != nil { + return err + } + return nil +} + +func (a *service) newClient(app *App) (*Client, error) { + return NewClient(ClientOptions{ + Hostname: a.GithubHostname, + SkipTLSVerification: true, + AppCredentials: &AppCredentials{ + ID: app.ID, + PrivateKey: app.PrivateKey, + }, + }) +} diff --git a/internal/github/test_server.go b/internal/github/test_server.go index 7b8a729f5..8392764b1 100644 --- a/internal/github/test_server.go +++ b/internal/github/test_server.go @@ -16,7 +16,6 @@ import ( "github.com/google/go-github/v41/github" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/oauth2" @@ -41,12 +40,13 @@ type ( *httptest.Server *testdb + mux *http.ServeMux } TestServerOption func(*TestServer) testdb struct { - user *cloud.User + username *string repo *string commit *string defaultBranch *string @@ -78,18 +78,18 @@ type ( } ) -func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.Config) { +func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, *url.URL) { srv := TestServer{ testdb: &testdb{}, statuses: make(chan *github.StatusEvent, 999), WebhookEvents: make(chan webhookEvent, 999), + mux: http.NewServeMux(), } for _, o := range opts { o(&srv) } - mux := http.NewServeMux() - mux.HandleFunc("/login/oauth/authorize", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/login/oauth/authorize", func(w http.ResponseWriter, r *http.Request) { q := url.Values{} q.Add("state", r.URL.Query().Get("state")) q.Add("code", internal.GenerateRandomString(10)) @@ -106,21 +106,21 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C http.Redirect(w, r, callback.String(), http.StatusFound) }) - mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/login/oauth/access_token", func(w http.ResponseWriter, r *http.Request) { out, err := json.Marshal(&oauth2.Token{AccessToken: "stub_token"}) require.NoError(t, err) w.Header().Add("Content-Type", "application/json") w.Write(out) }) - if srv.user != nil { - mux.HandleFunc("/api/v3/user", func(w http.ResponseWriter, r *http.Request) { - out, err := json.Marshal(&github.User{Login: internal.String(srv.user.Name)}) + if srv.username != nil { + srv.mux.HandleFunc("/api/v3/user", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&github.User{Login: srv.username}) require.NoError(t, err) w.Header().Add("Content-Type", "application/json") w.Write(out) }) } - mux.HandleFunc("/api/v3/user/repos", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/user/repos", func(w http.ResponseWriter, r *http.Request) { repos := []*github.Repository{{FullName: srv.repo}} out, err := json.Marshal(repos) require.NoError(t, err) @@ -128,7 +128,7 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C w.Write(out) }) if srv.repo != nil { - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/git/matching-refs/", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/git/matching-refs/", func(w http.ResponseWriter, r *http.Request) { var refs []*github.Reference for _, ref := range srv.refs { refs = append(refs, &github.Reference{Ref: internal.String(ref)}) @@ -138,19 +138,19 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C w.Header().Add("Content-Type", "application/json") w.Write(out) }) - mux.HandleFunc("/api/v3/repos/"+*srv.repo, func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo, func(w http.ResponseWriter, r *http.Request) { repo := &github.Repository{FullName: srv.repo, DefaultBranch: srv.defaultBranch} out, err := json.Marshal(repo) require.NoError(t, err) w.Header().Add("Content-Type", "application/json") w.Write(out) }) - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/tarball/", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/tarball/", func(w http.ResponseWriter, r *http.Request) { link := url.URL{Scheme: "https", Host: r.Host, Path: "/mytarball"} http.Redirect(w, r, link.String(), http.StatusFound) }) // https://docs.github.com/en/rest/webhooks/repos?apiVersion=2022-11-28#create-a-repository-webhook - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/hooks", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/hooks", func(w http.ResponseWriter, r *http.Request) { var opts struct { Events []string `json:"events"` Config struct { @@ -189,7 +189,7 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C // https://docs.github.com/en/rest/webhooks/repos?apiVersion=2022-11-28#get-a-repository-webhook // https://docs.github.com/en/rest/webhooks/repos?apiVersion=2022-11-28#update-a-repository-webhook // https://docs.github.com/en/rest/webhooks/repos?apiVersion=2022-11-28#delete-a-repository-webhook - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/hooks/123", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/hooks/123", func(w http.ResponseWriter, r *http.Request) { switch r.Method { case "PATCH": var opts struct { @@ -241,7 +241,7 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C } }) // https://docs.github.com/en/rest/commits/statuses?apiVersion=2022-11-28#create-a-commit-status - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/statuses/", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/statuses/", func(w http.ResponseWriter, r *http.Request) { var commit github.StatusEvent if err := json.NewDecoder(r.Body).Decode(&commit); err != nil { http.Error(w, err.Error(), http.StatusUnprocessableEntity) @@ -251,7 +251,7 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C w.WriteHeader(http.StatusCreated) }) // https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests-files - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/pulls/"+srv.pullNumber+"/files", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/pulls/"+srv.pullNumber+"/files", func(w http.ResponseWriter, r *http.Request) { var commits []*github.CommitFile for _, f := range srv.pullFiles { commits = append(commits, &github.CommitFile{ @@ -268,7 +268,7 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C }) if srv.commit != nil { // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit - mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/commits/"+*srv.commit, func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/api/v3/repos/"+*srv.repo+"/commits/"+*srv.commit, func(w http.ResponseWriter, r *http.Request) { out, err := json.Marshal(&github.Commit{ SHA: internal.String(*srv.commit), URL: internal.String(*srv.url + "/" + *srv.repo), @@ -287,35 +287,28 @@ func NewTestServer(t *testing.T, opts ...TestServerOption) (*TestServer, cloud.C } if srv.tarball != nil { - mux.HandleFunc("/mytarball", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/mytarball", func(w http.ResponseWriter, r *http.Request) { w.Write(srv.tarball) }) } - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + srv.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { t.Logf("github server received request for non-existent path: %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) }) - srv.Server = httptest.NewTLSServer(mux) + srv.Server = httptest.NewTLSServer(srv.mux) t.Cleanup(srv.Close) srv.url = &srv.URL u, err := url.Parse(srv.URL) require.NoError(t, err) - - cfg := cloud.Config{ - Name: "github", - Hostname: u.Host, - Cloud: &Cloud{}, - SkipTLSVerification: true, - } - return &srv, cfg + return &srv, u } -func WithUser(user *cloud.User) TestServerOption { +func WithUser(username *string) TestServerOption { return func(srv *TestServer) { - srv.user = user + srv.username = username } } @@ -356,6 +349,12 @@ func WithArchive(tarball []byte) TestServerOption { } } +func WithHandler(path string, h http.HandlerFunc) TestServerOption { + return func(srv *TestServer) { + srv.mux.HandleFunc(path, h) + } +} + func (s *TestServer) HasWebhook() bool { return s.testdb.webhook != nil } @@ -365,26 +364,7 @@ func (s *TestServer) SendEvent(t *testing.T, event GithubEvent, payload []byte) t.Helper() require.True(t, s.HasWebhook()) - - // generate signature for push event - mac := hmac.New(sha256.New, []byte(s.testdb.webhook.secret)) - mac.Write(payload) - sig := mac.Sum(nil) - - req, err := http.NewRequest("POST", s.testdb.webhook.Config["url"].(string), bytes.NewReader(payload)) - require.NoError(t, err) - req.Header.Add("Content-type", "application/json") - req.Header.Add("X-GitHub-Event", string(event)) - req.Header.Add("X-Hub-Signature-256", "sha256="+hex.EncodeToString(sig)) - - res, err := http.DefaultClient.Do(req) - require.NoError(t, err) - - if !assert.Equal(t, http.StatusAccepted, res.StatusCode) { - response, err := io.ReadAll(res.Body) - require.NoError(t, err) - t.Fatal(string(response)) - } + SendEventRequest(t, event, s.testdb.webhook.Config["url"].(string), s.testdb.webhook.secret, payload) } // GetStatus retrieves a commit status event off the queue, timing out after 10 @@ -403,3 +383,28 @@ func (s *TestServer) GetStatus(t *testing.T, ctx context.Context) *github.Status } return nil } + +// SendEventRequest sends a GitHub event via a http request to the url, signed with the secret, +func SendEventRequest(t *testing.T, event GithubEvent, url, secret string, payload []byte) { + t.Helper() + + // generate signature + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + sig := mac.Sum(nil) + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + require.NoError(t, err) + req.Header.Add("Content-type", "application/json") + req.Header.Add("X-GitHub-Event", string(event)) + req.Header.Add("X-Hub-Signature-256", "sha256="+hex.EncodeToString(sig)) + + res, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + if !assert.Equal(t, http.StatusAccepted, res.StatusCode) { + response, err := io.ReadAll(res.Body) + require.NoError(t, err) + t.Fatal(string(response)) + } +} diff --git a/internal/github/testdata/github_app_push.json b/internal/github/testdata/github_app_push.json new file mode 100644 index 000000000..f4488adb4 --- /dev/null +++ b/internal/github/testdata/github_app_push.json @@ -0,0 +1,200 @@ +{ + "ref": "refs/heads/master", + "before": "f602c779bb6f16b7ec1ea7be3cc3432b97a3c958", + "after": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "repository": { + "id": 590586738, + "node_id": "R_kgDOIzOjcg", + "name": "otf-workspaces", + "full_name": "leg100/otf-workspaces", + "private": true, + "owner": { + "name": "leg100", + "email": "75728+leg100@users.noreply.github.com", + "login": "leg100", + "id": 75728, + "node_id": "MDQ6VXNlcjc1NzI4", + "avatar_url": "https://avatars.githubusercontent.com/u/75728?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/leg100", + "html_url": "https://github.com/leg100", + "followers_url": "https://api.github.com/users/leg100/followers", + "following_url": "https://api.github.com/users/leg100/following{/other_user}", + "gists_url": "https://api.github.com/users/leg100/gists{/gist_id}", + "starred_url": "https://api.github.com/users/leg100/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/leg100/subscriptions", + "organizations_url": "https://api.github.com/users/leg100/orgs", + "repos_url": "https://api.github.com/users/leg100/repos", + "events_url": "https://api.github.com/users/leg100/events{/privacy}", + "received_events_url": "https://api.github.com/users/leg100/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/leg100/otf-workspaces", + "description": "Sample workspaces for OTF", + "fork": false, + "url": "https://github.com/leg100/otf-workspaces", + "forks_url": "https://api.github.com/repos/leg100/otf-workspaces/forks", + "keys_url": "https://api.github.com/repos/leg100/otf-workspaces/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/leg100/otf-workspaces/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/leg100/otf-workspaces/teams", + "hooks_url": "https://api.github.com/repos/leg100/otf-workspaces/hooks", + "issue_events_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/events{/number}", + "events_url": "https://api.github.com/repos/leg100/otf-workspaces/events", + "assignees_url": "https://api.github.com/repos/leg100/otf-workspaces/assignees{/user}", + "branches_url": "https://api.github.com/repos/leg100/otf-workspaces/branches{/branch}", + "tags_url": "https://api.github.com/repos/leg100/otf-workspaces/tags", + "blobs_url": "https://api.github.com/repos/leg100/otf-workspaces/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/leg100/otf-workspaces/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/leg100/otf-workspaces/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/leg100/otf-workspaces/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/leg100/otf-workspaces/statuses/{sha}", + "languages_url": "https://api.github.com/repos/leg100/otf-workspaces/languages", + "stargazers_url": "https://api.github.com/repos/leg100/otf-workspaces/stargazers", + "contributors_url": "https://api.github.com/repos/leg100/otf-workspaces/contributors", + "subscribers_url": "https://api.github.com/repos/leg100/otf-workspaces/subscribers", + "subscription_url": "https://api.github.com/repos/leg100/otf-workspaces/subscription", + "commits_url": "https://api.github.com/repos/leg100/otf-workspaces/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/leg100/otf-workspaces/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/leg100/otf-workspaces/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/leg100/otf-workspaces/contents/{+path}", + "compare_url": "https://api.github.com/repos/leg100/otf-workspaces/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/leg100/otf-workspaces/merges", + "archive_url": "https://api.github.com/repos/leg100/otf-workspaces/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/leg100/otf-workspaces/downloads", + "issues_url": "https://api.github.com/repos/leg100/otf-workspaces/issues{/number}", + "pulls_url": "https://api.github.com/repos/leg100/otf-workspaces/pulls{/number}", + "milestones_url": "https://api.github.com/repos/leg100/otf-workspaces/milestones{/number}", + "notifications_url": "https://api.github.com/repos/leg100/otf-workspaces/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/leg100/otf-workspaces/labels{/name}", + "releases_url": "https://api.github.com/repos/leg100/otf-workspaces/releases{/id}", + "deployments_url": "https://api.github.com/repos/leg100/otf-workspaces/deployments", + "created_at": 1674067797, + "updated_at": "2023-01-18T18:50:12Z", + "pushed_at": 1697565914, + "git_url": "git://github.com/leg100/otf-workspaces.git", + "ssh_url": "git@github.com:leg100/otf-workspaces.git", + "clone_url": "https://github.com/leg100/otf-workspaces.git", + "svn_url": "https://github.com/leg100/otf-workspaces", + "homepage": null, + "size": 12, + "stargazers_count": 0, + "watchers_count": 0, + "language": "HCL", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 4, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + + ], + "visibility": "private", + "forks": 0, + "open_issues": 4, + "watchers": 0, + "default_branch": "master", + "stargazers": 0, + "master_branch": "master" + }, + "pusher": { + "name": "leg100", + "email": "75728+leg100@users.noreply.github.com" + }, + "sender": { + "login": "leg100", + "id": 75728, + "node_id": "MDQ6VXNlcjc1NzI4", + "avatar_url": "https://avatars.githubusercontent.com/u/75728?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/leg100", + "html_url": "https://github.com/leg100", + "followers_url": "https://api.github.com/users/leg100/followers", + "following_url": "https://api.github.com/users/leg100/following{/other_user}", + "gists_url": "https://api.github.com/users/leg100/gists{/gist_id}", + "starred_url": "https://api.github.com/users/leg100/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/leg100/subscriptions", + "organizations_url": "https://api.github.com/users/leg100/orgs", + "repos_url": "https://api.github.com/users/leg100/repos", + "events_url": "https://api.github.com/users/leg100/events{/privacy}", + "received_events_url": "https://api.github.com/users/leg100/received_events", + "type": "User", + "site_admin": false + }, + "installation": { + "id": 42997659, + "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDI5OTc2NTk=" + }, + "created": false, + "deleted": false, + "forced": false, + "base_ref": null, + "compare": "https://github.com/leg100/otf-workspaces/compare/f602c779bb6f...0a2d223fa1a3", + "commits": [ + { + "id": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "tree_id": "9f6882422d87bdceea343daa23826d73ae9a6eff", + "distinct": true, + "message": "empty commit", + "timestamp": "2023-10-17T19:05:12+01:00", + "url": "https://github.com/leg100/otf-workspaces/commit/0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "author": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "committer": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "added": [ + + ], + "removed": [ + + ], + "modified": [ + + ] + } + ], + "head_commit": { + "id": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "tree_id": "9f6882422d87bdceea343daa23826d73ae9a6eff", + "distinct": true, + "message": "empty commit", + "timestamp": "2023-10-17T19:05:12+01:00", + "url": "https://github.com/leg100/otf-workspaces/commit/0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "author": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "committer": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "added": [ + + ], + "removed": [ + + ], + "modified": [ + + ] + } +} diff --git a/internal/github/testdata/github_push.json b/internal/github/testdata/github_push.json index 92f072865..2a9548cba 100644 --- a/internal/github/testdata/github_push.json +++ b/internal/github/testdata/github_push.json @@ -6,7 +6,7 @@ "id": 481653257, "node_id": "R_kgDOHLVyCQ", "name": "tfc-workspaces", - "full_name": "%s", + "full_name": "leg100/tfc-workspaces", "private": true, "owner": { "name": "leg100", diff --git a/internal/github/web.go b/internal/github/web.go new file mode 100644 index 000000000..3180d3c10 --- /dev/null +++ b/internal/github/web.go @@ -0,0 +1,217 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/http/decode" + "github.com/leg100/otf/internal/http/html" + "github.com/leg100/otf/internal/http/html/paths" + "github.com/leg100/otf/internal/rbac" +) + +const ( + // GithubPath is the URL path for the endpoint receiving VCS events from the + // Github App + AppEventsPath = "/webhooks/github-app" +) + +type webHandlers struct { + html.Renderer + internal.HostnameService + + svc Service + GithubHostname string + + // toggle skipping TLS on connections to github (for testing purposes) + GithubSkipTLS bool +} + +func (h *webHandlers) addHandlers(r *mux.Router) { + r = html.UIRouter(r) + + r.HandleFunc("/github-apps", h.get).Methods("GET") + r.HandleFunc("/github-apps/new", h.new).Methods("GET") + r.HandleFunc("/github-apps/exchange-code", h.exchangeCode).Methods("GET") + r.HandleFunc("/github-apps/{github_app_id}/delete", h.delete).Methods("POST") + r.HandleFunc("/github-apps/{github_app_id}/delete-install", h.deleteInstall).Methods("POST") +} + +func (h *webHandlers) new(w http.ResponseWriter, r *http.Request) { + type ( + hookAttrs struct { + URL string `json:"url"` + } + manifest struct { + Name string `json:"name"` + URL string `json:"url"` + Redirect string `json:"redirect_url"` + SetupURL string `json:"setup_url"` + Description string `json:"description"` + Events []string `json:"default_events"` + Permissions map[string]string `json:"default_permissions"` + Public bool `json:"public"` + HookAttrs hookAttrs `json:"hook_attributes"` + } + ) + m := manifest{ + Name: "otf-" + internal.GenerateRandomString(4), + URL: h.URL(""), + SetupURL: h.URL(paths.GithubApps()), + HookAttrs: hookAttrs{URL: h.URL(AppEventsPath)}, + Redirect: h.URL(paths.ExchangeCodeGithubApp()), + Description: "Trigger terraform runs in OTF from GitHub", + Events: []string{"push", "pull_request"}, + Public: false, + Permissions: map[string]string{ + "checks": "write", + "contents": "read", + "metadata": "read", + "pull_requests": "write", + "statuses": "write", + }, + } + marshaled, err := json.Marshal(&m) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h.Render("github_apps_new.tmpl", w, struct { + html.SitePage + Manifest string + GithubHostname string + }{ + SitePage: html.NewSitePage(r, "select app owner"), + Manifest: string(marshaled), + GithubHostname: h.GithubHostname, + }) +} + +func (h *webHandlers) get(w http.ResponseWriter, r *http.Request) { + app, err := h.svc.GetGithubApp(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + installs, err := h.svc.ListInstallations(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + user, err := auth.UserFromContext(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h.Render("github_apps_get.tmpl", w, struct { + html.SitePage + App *App + Installations []*Installation + GithubHostname string + CanCreateApp bool + CanDeleteApp bool + }{ + SitePage: html.NewSitePage(r, "github app"), + App: app, + Installations: installs, + GithubHostname: h.GithubHostname, + CanCreateApp: user.CanAccessSite(rbac.CreateGithubAppAction), + CanDeleteApp: user.CanAccessSite(rbac.DeleteGithubAppAction), + }) +} + +func (h *webHandlers) exchangeCode(w http.ResponseWriter, r *http.Request) { + var params struct { + Code string `schema:"code,required"` + } + if err := decode.All(¶ms, r); err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + // exchange code for credentials using an anonymous client + client, err := NewClient(ClientOptions{ + Hostname: h.GithubHostname, + SkipTLSVerification: h.GithubSkipTLS, + }) + if err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + cfg, err := client.ExchangeCode(r.Context(), params.Code) + if err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + opts := CreateAppOptions{ + AppID: cfg.GetID(), + Slug: cfg.GetSlug(), + WebhookSecret: cfg.GetWebhookSecret(), + PrivateKey: cfg.GetPEM(), + } + if cfg.GetOwner().GetType() == "Organization" { + opts.Organization = cfg.GetOwner().Login + } + _, err = h.svc.CreateGithubApp(r.Context(), opts) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + html.FlashSuccess(w, "created github app: "+cfg.GetSlug()) + http.Redirect(w, r, paths.GithubApps(), http.StatusFound) +} + +func (h *webHandlers) delete(w http.ResponseWriter, r *http.Request) { + app, err := h.svc.GetGithubApp(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if err := h.svc.DeleteGithubApp(r.Context()); err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // render a small templated flash message + buf := new(bytes.Buffer) + err = h.RenderTemplate("github_delete_message.tmpl", buf, struct { + GithubHostname string + *App + }{ + GithubHostname: h.GithubHostname, + App: app, + }) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + } + html.FlashSuccess(w, buf.String()) + + http.Redirect(w, r, paths.GithubApps(), http.StatusFound) +} + +func (h *webHandlers) deleteInstall(w http.ResponseWriter, r *http.Request) { + var params struct { + InstallID int64 `schema:"install_id,required"` + } + if err := decode.All(¶ms, r); err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + err := h.svc.DeleteInstallation(r.Context(), params.InstallID) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + html.FlashSuccess(w, fmt.Sprintf("deleted installation: %d", params.InstallID)) + http.Redirect(w, r, paths.GithubApps(), http.StatusFound) +} diff --git a/internal/github/web_test.go b/internal/github/web_test.go new file mode 100644 index 000000000..b2c8ae3f0 --- /dev/null +++ b/internal/github/web_test.go @@ -0,0 +1,134 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v55/github" + + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWebHandlers_new(t *testing.T) { + h := &webHandlers{ + Renderer: testutils.NewRenderer(t), + HostnameService: internal.NewHostnameService("example.com"), + } + + r := httptest.NewRequest("GET", "/?", nil) + w := httptest.NewRecorder() + h.new(w, r) + assert.Equal(t, 200, w.Code, w.Body.String()) +} + +func TestWebHandlers_get(t *testing.T) { + h := &webHandlers{ + Renderer: testutils.NewRenderer(t), + HostnameService: internal.NewHostnameService("example.com"), + svc: &fakeService{ + app: &App{}, + installs: []*Installation{{ + Installation: &github.Installation{ID: internal.Int64(123)}, + }}, + }, + } + + r := httptest.NewRequest("GET", "/?", nil) + r = r.WithContext(internal.AddSubjectToContext(context.Background(), &auth.SiteAdmin)) + w := httptest.NewRecorder() + h.get(w, r) + assert.Equal(t, 200, w.Code, w.Body.String()) +} + +func TestWebHandlers_exchangeCode(t *testing.T) { + // create stub github server with an exchange code handler + githubStubHostname := func() string { + mux := http.NewServeMux() + mux.HandleFunc("/api/v3/app-manifests/the-code/conversions", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&github.AppConfig{ + Slug: internal.String("my-otf-app"), + Owner: &github.User{}, + }) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }) + stub := httptest.NewTLSServer(mux) + t.Cleanup(stub.Close) + + u, err := url.Parse(stub.URL) + require.NoError(t, err) + return u.Host + }() + + h := &webHandlers{ + Renderer: testutils.NewRenderer(t), + GithubHostname: githubStubHostname, + GithubSkipTLS: true, + svc: &fakeService{}, + } + + r := httptest.NewRequest("GET", "/?code=the-code", nil) + w := httptest.NewRecorder() + h.exchangeCode(w, r) + testutils.AssertRedirect(t, w, "/app/github-apps") +} + +func TestWebHandlers_deleteApp(t *testing.T) { + h := &webHandlers{ + Renderer: testutils.NewRenderer(t), + svc: &fakeService{ + app: &App{}, + }, + } + + r := httptest.NewRequest("POST", "/?", nil) + w := httptest.NewRecorder() + h.delete(w, r) + testutils.AssertRedirect(t, w, "/app/github-apps") +} + +func TestWebHandlers_deleteInstall(t *testing.T) { + h := &webHandlers{ + svc: &fakeService{}, + } + + r := httptest.NewRequest("POST", "/?install_id=123", nil) + w := httptest.NewRecorder() + h.deleteInstall(w, r) + testutils.AssertRedirect(t, w, "/app/github-apps") +} + +type fakeService struct { + app *App + installs []*Installation + GithubAppService +} + +func (f *fakeService) CreateGithubApp(context.Context, CreateAppOptions) (*App, error) { + return f.app, nil +} + +func (f *fakeService) GetGithubApp(context.Context) (*App, error) { + return f.app, nil +} + +func (f *fakeService) DeleteGithubApp(context.Context) error { + return nil +} + +func (f *fakeService) ListInstallations(context.Context) ([]*Installation, error) { + return f.installs, nil +} + +func (f *fakeService) DeleteInstallation(context.Context, int64) error { + return nil +} diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go index e6be0e387..5fbf229a2 100644 --- a/internal/gitlab/client.go +++ b/internal/gitlab/client.go @@ -12,15 +12,27 @@ import ( "strings" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/authenticator" + "github.com/leg100/otf/internal/vcs" "github.com/xanzy/go-gitlab" + "golang.org/x/oauth2" ) -type Client struct { - client *gitlab.Client -} +type ( + Client struct { + client *gitlab.Client + } + + ClientOptions struct { + Hostname string + SkipTLSVerification bool + + OAuthToken *oauth2.Token + PersonalToken *string + } +) -func NewClient(ctx context.Context, cfg cloud.ClientOptions) (*Client, error) { +func NewClient(cfg ClientOptions) (*Client, error) { var ( client *gitlab.Client err error @@ -47,27 +59,43 @@ func NewClient(ctx context.Context, cfg cloud.ClientOptions) (*Client, error) { return &Client{client: client}, nil } -func (g *Client) GetCurrentUser(ctx context.Context) (cloud.User, error) { +func NewTokenClient(opts vcs.NewTokenClientOptions) (vcs.Client, error) { + return NewClient(ClientOptions{ + Hostname: opts.Hostname, + PersonalToken: &opts.Token, + SkipTLSVerification: opts.SkipTLSVerification, + }) +} + +func NewOAuthClient(cfg authenticator.OAuthConfig, token *oauth2.Token) (authenticator.IdentityProviderClient, error) { + return NewClient(ClientOptions{ + Hostname: cfg.Hostname, + OAuthToken: token, + SkipTLSVerification: cfg.SkipTLSVerification, + }) +} + +func (g *Client) GetCurrentUser(ctx context.Context) (string, error) { guser, _, err := g.client.Users.CurrentUser() if err != nil { - return cloud.User{}, err + return "", err } - return cloud.User{Name: guser.Username}, nil + return guser.Username, nil } -func (g *Client) GetRepository(ctx context.Context, identifier string) (cloud.Repository, error) { +func (g *Client) GetRepository(ctx context.Context, identifier string) (vcs.Repository, error) { proj, _, err := g.client.Projects.GetProject(identifier, nil) if err != nil { - return cloud.Repository{}, err + return vcs.Repository{}, err } - return cloud.Repository{ + return vcs.Repository{ Path: proj.PathWithNamespace, DefaultBranch: proj.DefaultBranch, }, nil } -func (g *Client) ListRepositories(ctx context.Context, lopts cloud.ListRepositoriesOptions) ([]string, error) { +func (g *Client) ListRepositories(ctx context.Context, lopts vcs.ListRepositoriesOptions) ([]string, error) { opts := &gitlab.ListProjectsOptions{ ListOptions: gitlab.ListOptions{ PerPage: lopts.PageSize, @@ -88,7 +116,7 @@ func (g *Client) ListRepositories(ctx context.Context, lopts cloud.ListRepositor return repos, nil } -func (g *Client) ListTags(ctx context.Context, opts cloud.ListTagsOptions) ([]string, error) { +func (g *Client) ListTags(ctx context.Context, opts vcs.ListTagsOptions) ([]string, error) { results, _, err := g.client.Tags.ListTags(opts.Repo, &gitlab.ListTagsOptions{ Search: internal.String("^" + opts.Prefix), }) @@ -103,7 +131,7 @@ func (g *Client) ListTags(ctx context.Context, opts cloud.ListTagsOptions) ([]st return tags, nil } -func (g *Client) GetRepoTarball(ctx context.Context, opts cloud.GetRepoTarballOptions) ([]byte, string, error) { +func (g *Client) GetRepoTarball(ctx context.Context, opts vcs.GetRepoTarballOptions) ([]byte, string, error) { owner, name, found := strings.Cut(opts.Repo, "/") if !found { return nil, "", fmt.Errorf("malformed identifier: %s", opts.Repo) @@ -148,7 +176,7 @@ func (g *Client) GetRepoTarball(ctx context.Context, opts cloud.GetRepoTarballOp return tarball, parts[1], nil } -func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOptions) (string, error) { +func (g *Client) CreateWebhook(ctx context.Context, opts vcs.CreateWebhookOptions) (string, error) { addOpts := &gitlab.AddProjectHookOptions{ EnableSSLVerification: internal.Bool(true), PushEvents: internal.Bool(true), @@ -157,9 +185,9 @@ func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOpti } for _, event := range opts.Events { switch event { - case cloud.VCSEventTypePush: + case vcs.EventTypePush: addOpts.PushEvents = internal.Bool(true) - case cloud.VCSEventTypePull: + case vcs.EventTypePull: addOpts.MergeRequestsEvents = internal.Bool(true) } } @@ -171,7 +199,7 @@ func (g *Client) CreateWebhook(ctx context.Context, opts cloud.CreateWebhookOpti return strconv.Itoa(hook.ID), nil } -func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.UpdateWebhookOptions) error { +func (g *Client) UpdateWebhook(ctx context.Context, id string, opts vcs.UpdateWebhookOptions) error { intID, err := strconv.Atoi(id) if err != nil { return err @@ -184,9 +212,9 @@ func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.Update } for _, event := range opts.Events { switch event { - case cloud.VCSEventTypePush: + case vcs.EventTypePush: editOpts.PushEvents = internal.Bool(true) - case cloud.VCSEventTypePull: + case vcs.EventTypePull: editOpts.MergeRequestsEvents = internal.Bool(true) } } @@ -198,29 +226,29 @@ func (g *Client) UpdateWebhook(ctx context.Context, id string, opts cloud.Update return nil } -func (g *Client) GetWebhook(ctx context.Context, opts cloud.GetWebhookOptions) (cloud.Webhook, error) { +func (g *Client) GetWebhook(ctx context.Context, opts vcs.GetWebhookOptions) (vcs.Webhook, error) { id, err := strconv.Atoi(opts.ID) if err != nil { - return cloud.Webhook{}, err + return vcs.Webhook{}, err } hook, resp, err := g.client.Projects.GetProjectHook(opts.Repo, id) if err != nil { if resp.StatusCode == http.StatusNotFound { - return cloud.Webhook{}, internal.ErrResourceNotFound + return vcs.Webhook{}, internal.ErrResourceNotFound } - return cloud.Webhook{}, err + return vcs.Webhook{}, err } - var events []cloud.VCSEventType + var events []vcs.EventType if hook.PushEvents { - events = append(events, cloud.VCSEventTypePush) + events = append(events, vcs.EventTypePush) } if hook.MergeRequestsEvents { - events = append(events, cloud.VCSEventTypePull) + events = append(events, vcs.EventTypePull) } - return cloud.Webhook{ + return vcs.Webhook{ ID: strconv.Itoa(id), Repo: opts.Repo, Events: events, @@ -228,7 +256,7 @@ func (g *Client) GetWebhook(ctx context.Context, opts cloud.GetWebhookOptions) ( }, nil } -func (g *Client) DeleteWebhook(ctx context.Context, opts cloud.DeleteWebhookOptions) error { +func (g *Client) DeleteWebhook(ctx context.Context, opts vcs.DeleteWebhookOptions) error { id, err := strconv.Atoi(opts.ID) if err != nil { return err @@ -238,7 +266,7 @@ func (g *Client) DeleteWebhook(ctx context.Context, opts cloud.DeleteWebhookOpti return err } -func (g *Client) SetStatus(ctx context.Context, opts cloud.SetStatusOptions) error { +func (g *Client) SetStatus(ctx context.Context, opts vcs.SetStatusOptions) error { return nil } @@ -246,6 +274,6 @@ func (g *Client) ListPullRequestFiles(ctx context.Context, repo string, pull int return nil, nil } -func (g *Client) GetCommit(ctx context.Context, repo, ref string) (cloud.Commit, error) { - return cloud.Commit{}, nil +func (g *Client) GetCommit(ctx context.Context, repo, ref string) (vcs.Commit, error) { + return vcs.Commit{}, nil } diff --git a/internal/gitlab/client_test.go b/internal/gitlab/client_test.go index f7f6be2d8..326592065 100644 --- a/internal/gitlab/client_test.go +++ b/internal/gitlab/client_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/xanzy/go-gitlab" @@ -18,14 +18,14 @@ func TestClient(t *testing.T) { ctx := context.Background() t.Run("GetUser", func(t *testing.T) { - want := cloud.User{Name: "fake-user"} + want := "fake-user" - provider := newTestClient(t, WithGitlabUser(&want)) + provider := newTestClient(t, WithGitlabUser(want)) - user, err := provider.GetCurrentUser(ctx) + got, err := provider.GetCurrentUser(ctx) require.NoError(t, err) - assert.Equal(t, "fake-user", user.Name) + assert.Equal(t, "fake-user", got) }) t.Run("GetRepository", func(t *testing.T) { @@ -43,7 +43,7 @@ func TestClient(t *testing.T) { provider := newTestClient(t, WithGitlabRepo(want[0], "")) - got, err := provider.ListRepositories(ctx, cloud.ListRepositoriesOptions{}) + got, err := provider.ListRepositories(ctx, vcs.ListRepositoriesOptions{}) require.NoError(t, err) assert.Equal(t, want, got) @@ -57,7 +57,7 @@ func TestClient(t *testing.T) { WithGitlabTarball(want), ) - got, ref, err := client.GetRepoTarball(ctx, cloud.GetRepoTarballOptions{ + got, ref, err := client.GetRepoTarball(ctx, vcs.GetRepoTarballOptions{ Repo: "acme/terraform", }) require.NoError(t, err) diff --git a/internal/gitlab/cloud.go b/internal/gitlab/cloud.go deleted file mode 100644 index 6e70b5adb..000000000 --- a/internal/gitlab/cloud.go +++ /dev/null @@ -1,18 +0,0 @@ -package gitlab - -import ( - "context" - "net/http" - - "github.com/leg100/otf/internal/cloud" -) - -type Cloud struct{} - -func (g *Cloud) NewClient(ctx context.Context, opts cloud.ClientOptions) (cloud.Client, error) { - return NewClient(ctx, opts) -} - -func (Cloud) HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.VCSEvent { - return HandleEvent(w, r, secret) -} diff --git a/internal/gitlab/event_handler.go b/internal/gitlab/event_handler.go index 26ef9b19b..79a08de34 100644 --- a/internal/gitlab/event_handler.go +++ b/internal/gitlab/event_handler.go @@ -7,11 +7,11 @@ import ( "net/http" "strings" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/vcs" "github.com/xanzy/go-gitlab" ) -func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.VCSEvent { +func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *vcs.EventPayload { event, err := handle(r, secret) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -21,7 +21,7 @@ func HandleEvent(w http.ResponseWriter, r *http.Request, secret string) *cloud.V return event } -func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { +func handle(r *http.Request, secret string) (*vcs.EventPayload, error) { if token := r.Header.Get("X-Gitlab-Token"); token != secret { return nil, errors.New("token validation failed") } @@ -42,7 +42,7 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { if len(refParts) != 3 { return nil, fmt.Errorf("malformed ref: %s", event.Ref) } - return &cloud.VCSEvent{ + return &vcs.EventPayload{ Branch: refParts[2], CommitSHA: event.After, DefaultBranch: event.Project.DefaultBranch, @@ -52,7 +52,7 @@ func handle(r *http.Request, secret string) (*cloud.VCSEvent, error) { if len(refParts) != 3 { return nil, fmt.Errorf("malformed ref: %s", event.Ref) } - return &cloud.VCSEvent{ + return &vcs.EventPayload{ Tag: refParts[2], // Action: action, CommitSHA: event.After, diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go index 8099661be..db729eda4 100644 --- a/internal/gitlab/gitlab.go +++ b/internal/gitlab/gitlab.go @@ -2,22 +2,14 @@ package gitlab import ( - "github.com/leg100/otf/internal/cloud" - "golang.org/x/oauth2" oauth2gitlab "golang.org/x/oauth2/gitlab" ) -func Defaults() cloud.Config { - return cloud.Config{ - Name: "gitlab", - Hostname: "gitlab.com", - Cloud: &Cloud{}, - } -} +const ( + DefaultHostname string = "gitlab.com" +) -func OAuthDefaults() *oauth2.Config { - return &oauth2.Config{ - Endpoint: oauth2gitlab.Endpoint, - Scopes: []string{"read_user", "read_api"}, - } -} +var ( + OAuthEndpoint = oauth2gitlab.Endpoint + OAuthScopes = []string{"read_user", "read_api"} +) diff --git a/internal/gitlab/test_server.go b/internal/gitlab/test_server.go index 81a0efe63..b4ddc024f 100644 --- a/internal/gitlab/test_server.go +++ b/internal/gitlab/test_server.go @@ -9,7 +9,6 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/stretchr/testify/require" "github.com/xanzy/go-gitlab" "golang.org/x/oauth2" @@ -117,9 +116,9 @@ func NewTestServer(t *testing.T, opts ...TestGitlabServerOption) *httptest.Serve type TestGitlabServerOption func(*testServerDB) -func WithGitlabUser(user *cloud.User) TestGitlabServerOption { +func WithGitlabUser(username string) TestGitlabServerOption { return func(db *testServerDB) { - db.user = &gitlab.User{Username: user.Name, ID: 1} + db.user = &gitlab.User{Username: username, ID: 1} } } diff --git a/internal/hostname.go b/internal/hostname.go index 74d4dc641..7f8d791bd 100644 --- a/internal/hostname.go +++ b/internal/hostname.go @@ -3,14 +3,19 @@ package internal import ( "fmt" "net" + "net/url" "strings" ) type ( - // HostnameService provides the OTF user-facing hostname. + // HostnameService is registry of hostnames HostnameService interface { + // Return the OTF hostname. Hostname() string + // Set the OTF hostname. SetHostname(string) + // Return OTF URL with the given path + URL(path string) string } hostnameService struct { @@ -25,6 +30,15 @@ func NewHostnameService(hostname string) *hostnameService { func (s *hostnameService) Hostname() string { return s.hostname } func (s *hostnameService) SetHostname(hostname string) { s.hostname = hostname } +func (s *hostnameService) URL(path string) string { + u := url.URL{ + Scheme: "https", + Host: s.Hostname(), + Path: path, + } + return u.String() +} + // NormalizeAddress takes a host:port and converts it into a host:port // appropriate for setting as the addressable hostname of otfd, e.g. converting // 0.0.0.0 to 127.0.0.1. diff --git a/internal/hostname_test_helpers.go b/internal/hostname_test_helpers.go deleted file mode 100644 index 7979ee7ff..000000000 --- a/internal/hostname_test_helpers.go +++ /dev/null @@ -1,9 +0,0 @@ -package internal - -type FakeHostnameService struct { - Host string - - HostnameService -} - -func (s FakeHostnameService) Hostname() string { return s.Host } diff --git a/internal/http/client.go b/internal/http/client.go index 205f713dc..1f8c2a541 100644 --- a/internal/http/client.go +++ b/internal/http/client.go @@ -2,7 +2,6 @@ package http import ( "context" - "crypto/tls" "encoding/json" "errors" "fmt" @@ -14,7 +13,6 @@ import ( "time" "github.com/DataDog/jsonapi" - "github.com/hashicorp/go-cleanhttp" retryablehttp "github.com/hashicorp/go-retryablehttp" "github.com/leg100/otf/internal" ) @@ -52,12 +50,6 @@ func NewClient(config Config) (*Client, error) { return nil, fmt.Errorf("missing API token") } - if config.HTTPClient == nil { - transport := cleanhttp.DefaultPooledTransport() - transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: config.Insecure} - config.HTTPClient = &http.Client{Transport: transport} - } - // Create the client. client := &Client{ baseURL: baseURL, @@ -68,7 +60,7 @@ func NewClient(config Config) (*Client, error) { client.http = &retryablehttp.Client{ CheckRetry: client.retryHTTPCheck, ErrorHandler: retryablehttp.PassthroughErrorHandler, - HTTPClient: config.HTTPClient, + HTTPClient: &http.Client{Transport: config.Transport}, RetryWaitMin: 100 * time.Millisecond, RetryWaitMax: 400 * time.Millisecond, RetryMax: 30, diff --git a/internal/http/client_config.go b/internal/http/client_config.go index 5db56bbfd..f126510d7 100644 --- a/internal/http/client_config.go +++ b/internal/http/client_config.go @@ -30,22 +30,20 @@ type Config struct { Token string // Headers that will be added to every request. Headers http.Header - // A custom HTTP client to use. - HTTPClient *http.Client // RetryLogHook is invoked each time a request is retried. RetryLogHook RetryLogHook - // Insecure skips verification of upstream TLS certs. - // NOTE: this does not take effect if HTTPClient is non-nil - Insecure bool + // Override default http transport + Transport http.RoundTripper } // NewConfig constructs a new http client config with defaults. func NewConfig() Config { config := Config{ - Address: os.Getenv("TFE_ADDRESS"), - BasePath: DefaultBasePath, - Token: os.Getenv("TFE_TOKEN"), - Headers: make(http.Header), + Address: os.Getenv("TFE_ADDRESS"), + BasePath: DefaultBasePath, + Token: os.Getenv("TFE_TOKEN"), + Headers: make(http.Header), + Transport: DefaultTransport, } // Set the default address if none is given. if config.Address == "" { diff --git a/internal/http/html/paths/admin_paths.go b/internal/http/html/paths/admin_paths.go new file mode 100644 index 000000000..0f1c03653 --- /dev/null +++ b/internal/http/html/paths/admin_paths.go @@ -0,0 +1,7 @@ +// Code generated by "go generate"; DO NOT EDIT. + +package paths + +func Admin() string { + return "/app/admin" +} diff --git a/internal/http/html/paths/funcmap.go b/internal/http/html/paths/funcmap.go index 688893b0e..44bc4a3c3 100644 --- a/internal/http/html/paths/funcmap.go +++ b/internal/http/html/paths/funcmap.go @@ -9,6 +9,8 @@ import ( var funcmap = template.FuncMap{} func init() { + funcmap["adminPath"] = Admin + funcmap["loginPath"] = Login funcmap["logoutPath"] = Logout @@ -25,6 +27,17 @@ func init() { funcmap["createTokenPath"] = CreateToken + funcmap["githubAppsPath"] = GithubApps + funcmap["createGithubAppPath"] = CreateGithubApp + funcmap["newGithubAppPath"] = NewGithubApp + funcmap["githubAppPath"] = GithubApp + funcmap["editGithubAppPath"] = EditGithubApp + funcmap["updateGithubAppPath"] = UpdateGithubApp + funcmap["deleteGithubAppPath"] = DeleteGithubApp + funcmap["exchangeCodeGithubAppPath"] = ExchangeCodeGithubApp + funcmap["completeGithubAppPath"] = CompleteGithubApp + funcmap["deleteInstallGithubAppPath"] = DeleteInstallGithubApp + funcmap["organizationsPath"] = Organizations funcmap["createOrganizationPath"] = CreateOrganization funcmap["newOrganizationPath"] = NewOrganization @@ -130,6 +143,7 @@ func init() { funcmap["editVCSProviderPath"] = EditVCSProvider funcmap["updateVCSProviderPath"] = UpdateVCSProvider funcmap["deleteVCSProviderPath"] = DeleteVCSProvider + funcmap["newGithubAppVCSProviderPath"] = NewGithubAppVCSProvider funcmap["modulesPath"] = Modules funcmap["createModulePath"] = CreateModule diff --git a/internal/http/html/paths/gen.go b/internal/http/html/paths/gen.go index 738846810..481da805d 100644 --- a/internal/http/html/paths/gen.go +++ b/internal/http/html/paths/gen.go @@ -99,6 +99,10 @@ type controller struct { } var specs = []controllerSpec{ + { + Name: "admin", + controllerType: singlePath, + }, { Name: "login", controllerType: singlePath, @@ -138,6 +142,23 @@ var specs = []controllerSpec{ controllerType: singlePath, path: "/profile/tokens/create", }, + { + Name: "github_app", + controllerType: resourcePath, + actions: []action{ + { + name: "exchange-code", + collection: true, + }, + { + name: "complete", + collection: true, + }, + { + name: "delete-install", + }, + }, + }, { Name: "organization", controllerType: resourcePath, @@ -275,6 +296,12 @@ var specs = []controllerSpec{ controllerType: resourcePath, camel: "VCSProvider", lowerCamel: "vcsProvider", + actions: []action{ + { + name: "new-github-app", + collection: true, + }, + }, }, { Name: "module", diff --git a/internal/http/html/paths/github_app_paths.go b/internal/http/html/paths/github_app_paths.go new file mode 100644 index 000000000..1481dfacb --- /dev/null +++ b/internal/http/html/paths/github_app_paths.go @@ -0,0 +1,45 @@ +// Code generated by "go generate"; DO NOT EDIT. + +package paths + +import "fmt" + +func GithubApps() string { + return "/app/github-apps" +} + +func CreateGithubApp() string { + return "/app/github-apps/create" +} + +func NewGithubApp() string { + return "/app/github-apps/new" +} + +func GithubApp(githubApp string) string { + return fmt.Sprintf("/app/github-apps/%s", githubApp) +} + +func EditGithubApp(githubApp string) string { + return fmt.Sprintf("/app/github-apps/%s/edit", githubApp) +} + +func UpdateGithubApp(githubApp string) string { + return fmt.Sprintf("/app/github-apps/%s/update", githubApp) +} + +func DeleteGithubApp(githubApp string) string { + return fmt.Sprintf("/app/github-apps/%s/delete", githubApp) +} + +func ExchangeCodeGithubApp() string { + return "/app/github-apps/exchange-code" +} + +func CompleteGithubApp() string { + return "/app/github-apps/complete" +} + +func DeleteInstallGithubApp(githubApp string) string { + return fmt.Sprintf("/app/github-apps/%s/delete-install", githubApp) +} diff --git a/internal/http/html/paths/select_ghapp_owner_paths.go b/internal/http/html/paths/select_ghapp_owner_paths.go new file mode 100644 index 000000000..84d8c8b61 --- /dev/null +++ b/internal/http/html/paths/select_ghapp_owner_paths.go @@ -0,0 +1,7 @@ +// Code generated by "go generate"; DO NOT EDIT. + +package paths + +func SelectGhappOwner() string { + return "/app/admin/ghapp/select-owner" +} diff --git a/internal/http/html/paths/vcs_provider_paths.go b/internal/http/html/paths/vcs_provider_paths.go index bf688e04e..edf5328d2 100644 --- a/internal/http/html/paths/vcs_provider_paths.go +++ b/internal/http/html/paths/vcs_provider_paths.go @@ -31,3 +31,7 @@ func UpdateVCSProvider(vcsProvider string) string { func DeleteVCSProvider(vcsProvider string) string { return fmt.Sprintf("/app/vcs-providers/%s/delete", vcsProvider) } + +func NewGithubAppVCSProvider(organization string) string { + return fmt.Sprintf("/app/organizations/%s/vcs-providers/new-github-app", organization) +} diff --git a/internal/http/html/static/css/output.css b/internal/http/html/static/css/output.css index 3ed6ff3d3..08f739c3d 100644 --- a/internal/http/html/static/css/output.css +++ b/internal/http/html/static/css/output.css @@ -880,6 +880,10 @@ th, td { display: none; } +.h-4 { + height: 1rem; +} + .h-5 { height: 1.25rem; } @@ -888,6 +892,10 @@ th, td { min-height: 100vh; } +.w-20 { + width: 5rem; +} + .w-32 { width: 8rem; } @@ -1367,6 +1375,11 @@ th, td { background-color: rgb(243 244 246 / var(--tw-bg-opacity)); } +.hover\:bg-gray-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .hover\:bg-gray-200:hover { --tw-bg-opacity: 1; background-color: rgb(229 231 235 / var(--tw-bg-opacity)); @@ -1377,11 +1390,6 @@ th, td { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.hover\:bg-gray-100:hover { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); -} - .hover\:text-blue-800:hover { --tw-text-opacity: 1; color: rgb(30 64 175 / var(--tw-text-opacity)); diff --git a/internal/http/html/static/images/external_link_icon.svg b/internal/http/html/static/images/external_link_icon.svg new file mode 100644 index 000000000..e92dfc075 --- /dev/null +++ b/internal/http/html/static/images/external_link_icon.svg @@ -0,0 +1 @@ + diff --git a/internal/http/html/static/js/github_apps_new.js b/internal/http/html/static/js/github_apps_new.js new file mode 100644 index 000000000..6a96ca3dd --- /dev/null +++ b/internal/http/html/static/js/github_apps_new.js @@ -0,0 +1,13 @@ +document.addEventListener('alpine:init', () => { + Alpine.data('action', (hostname, manifest) => ({ + organization: '', + manifest: JSON.parse(manifest), + public: false, + get action() { + if (this.organization === '') { + return "https://" + hostname + "/settings/apps/new" + } + return "https://" + hostname + "/organizations/" + this.organization + "/settings/apps/new" + }, + })) +}) diff --git a/internal/http/html/static/templates/content/github_apps_get.tmpl b/internal/http/html/static/templates/content/github_apps_get.tmpl new file mode 100644 index 000000000..fbb9cbea2 --- /dev/null +++ b/internal/http/html/static/templates/content/github_apps_get.tmpl @@ -0,0 +1,66 @@ +{{ template "layout" . }} + +{{ define "content-header-title" }}GitHub app{{ end }} + +{{ define "content" }} + {{ with .App }} +
+
+
+ + + {{ .String }} + + +
+
+ {{ template "identifier" . }} + {{ if $.CanDeleteApp }} +
+ + +
+ {{ end }} +
+
+
+
+

Installations

+
+ +
+
+ {{ range $.Installations }} +
+
+
+ + + {{ .String }} + + +
+
+ {{ template "identifier" . }} +
+ + +
+
+
+
+ {{ end }} +
+ {{ else }} + + No GitHub app found. + {{ if .CanCreateApp }} + Create an app here. + {{ else }} + To create an app you need to possess the site admin role. + {{ end }} + + {{ end }} +{{ end }} diff --git a/internal/http/html/static/templates/content/github_apps_new.tmpl b/internal/http/html/static/templates/content/github_apps_new.tmpl new file mode 100644 index 000000000..ca787ed9b --- /dev/null +++ b/internal/http/html/static/templates/content/github_apps_new.tmpl @@ -0,0 +1,25 @@ +{{ template "layout" . }} + +{{ define "content-header-title" }}Create Github App{{ end }} + +{{ define "content" }} + +
+
+
+ + + If assigning ownership to a GitHub organization, enter its name here. Otherwise ownership is assigned to your personal GitHub account. + + +
+
+ + + By default an app is private and can only be installed on the owner's account. If you intend to install the app in more than one organization or user account then it is necessary to make the app public. +
+ + +
+
+{{ end }} diff --git a/internal/http/html/static/templates/content/github_delete_message.tmpl b/internal/http/html/static/templates/content/github_delete_message.tmpl new file mode 100644 index 000000000..ea7fea314 --- /dev/null +++ b/internal/http/html/static/templates/content/github_delete_message.tmpl @@ -0,0 +1,3 @@ + + Deleted GitHub app from OTF. You should also delete the app from GitHub . + diff --git a/internal/http/html/static/templates/content/login.tmpl b/internal/http/html/static/templates/content/login.tmpl index c0d1fdfe1..54535c749 100644 --- a/internal/http/html/static/templates/content/login.tmpl +++ b/internal/http/html/static/templates/content/login.tmpl @@ -4,12 +4,12 @@ {{ template "flash" . }}
- {{ range .Authenticators }} + {{ range .Clients }} {{ template "icons" .String }}Login with {{ title .String }} {{ else }} - No authenticators configured. + No identity providers configured. {{ end }}
diff --git a/internal/http/html/static/templates/content/module_new.tmpl b/internal/http/html/static/templates/content/module_new.tmpl index d1c36e9a3..714761237 100644 --- a/internal/http/html/static/templates/content/module_new.tmpl +++ b/internal/http/html/static/templates/content/module_new.tmpl @@ -76,7 +76,7 @@

Confirm module details

- Provider: {{ .VCSProvider.CloudConfig.Name }} + Provider: {{ .VCSProvider.Kind }}
Repository: {{ .Repo }} diff --git a/internal/http/html/static/templates/content/site.tmpl b/internal/http/html/static/templates/content/site.tmpl new file mode 100644 index 000000000..831de5857 --- /dev/null +++ b/internal/http/html/static/templates/content/site.tmpl @@ -0,0 +1,11 @@ +{{ template "layout" . }} + +{{ define "content-header-title" }}site settings{{ end }} + +{{ define "content" }} +
+ + GitHub app + +
+{{ end }} diff --git a/internal/http/html/static/templates/content/vcs_provider_github_app_new.tmpl b/internal/http/html/static/templates/content/vcs_provider_github_app_new.tmpl new file mode 100644 index 000000000..f8f30473e --- /dev/null +++ b/internal/http/html/static/templates/content/vcs_provider_github_app_new.tmpl @@ -0,0 +1,30 @@ +{{ template "layout" . }} + +{{ define "content-header-title" }} +
New Github App VCS Provider
+{{ end }} + +{{ define "content" }} + {{ with .Installations }} + Create a VCS provider that leverages the permissions of a GitHub app installation. +
+
+ + + An optional display name for your VCS provider. +
+
+ + + Select a Github App installation. +
+ +
+ {{ else }} + No installations of the GitHub app found. Install it on Github first. + {{ end }} +{{ end }} diff --git a/internal/http/html/static/templates/content/vcs_provider_github_new.tmpl b/internal/http/html/static/templates/content/vcs_provider_github_new.tmpl deleted file mode 100644 index dc5e24497..000000000 --- a/internal/http/html/static/templates/content/vcs_provider_github_new.tmpl +++ /dev/null @@ -1,13 +0,0 @@ -{{ template "layout" . }} - -{{ define "content-header-title" }} -
New Github VCS Provider
-{{ end }} - -{{ define "content" }} -
- Create a Github VCS provider with a personal token with the repo scope. -
- - {{ template "vcs_provider_form" . }} -{{ end }} diff --git a/internal/http/html/static/templates/content/vcs_provider_gitlab_new.tmpl b/internal/http/html/static/templates/content/vcs_provider_gitlab_new.tmpl deleted file mode 100644 index f82d32abf..000000000 --- a/internal/http/html/static/templates/content/vcs_provider_gitlab_new.tmpl +++ /dev/null @@ -1,12 +0,0 @@ -{{ template "layout" . }} - -{{ define "content-header-title" }} -
New Gitlab VCS Provider
-{{ end }} - -{{ define "content" }} -
- Create a Gitlab VCS provider using a personal token with the api scope. -
- {{ template "vcs_provider_form" . }} -{{ end }} diff --git a/internal/http/html/static/templates/content/vcs_provider_list.tmpl b/internal/http/html/static/templates/content/vcs_provider_list.tmpl index ec63cba01..cee90e53d 100644 --- a/internal/http/html/static/templates/content/vcs_provider_list.tmpl +++ b/internal/http/html/static/templates/content/vcs_provider_list.tmpl @@ -16,17 +16,26 @@
- {{ range .CloudConfigs }} -
- - + + + +
+
+ + +
+ {{ if .GithubApp }} +
+
+ {{ else }} + Alternatively, create a GitHub app and you will be able to create VCS providers using a Github app installation. {{ end }}
{{ end }} {{ define "content-list-item" }} -
+
{{ .String }} diff --git a/internal/http/html/static/templates/content/vcs_provider_pat_new.tmpl b/internal/http/html/static/templates/content/vcs_provider_pat_new.tmpl new file mode 100644 index 000000000..35ab249bb --- /dev/null +++ b/internal/http/html/static/templates/content/vcs_provider_pat_new.tmpl @@ -0,0 +1,13 @@ +{{ template "layout" . }} + +{{ define "content-header-title" }} +
New {{ title .Kind }} VCS Provider
+{{ end }} + +{{ define "content" }} +
+ Create a {{ title .Kind }} VCS provider with a personal token with the {{ .Scope }} scope. +
+ + {{ template "vcs_provider_form" . }} +{{ end }} diff --git a/internal/http/html/static/templates/content/workspace_edit.tmpl b/internal/http/html/static/templates/content/workspace_edit.tmpl index 26e93a599..d45447879 100644 --- a/internal/http/html/static/templates/content/workspace_edit.tmpl +++ b/internal/http/html/static/templates/content/workspace_edit.tmpl @@ -17,7 +17,7 @@ {{ with .Workspace.Connection }}
{{ else }} diff --git a/internal/http/html/static/templates/content/workspace_get.tmpl b/internal/http/html/static/templates/content/workspace_get.tmpl index be773ab7a..ac2c25ab1 100644 --- a/internal/http/html/static/templates/content/workspace_get.tmpl +++ b/internal/http/html/static/templates/content/workspace_get.tmpl @@ -41,7 +41,7 @@
{{ end }} -

Terraform Version

v{{ .Workspace.TerraformVersion }}
+

Terraform Version

{{ .Workspace.TerraformVersion }}

Locking

{{ with .LockButton }} @@ -56,7 +56,7 @@ {{ end }}
{{ with .Workspace.Connection }} -
Connected to {{ .Repo }} ({{ $.VCSProvider.CloudConfig }})
+
Connected to {{ .Repo }} ({{ $.VCSProvider.String }})
{{ end }}

Tags

diff --git a/internal/http/html/static/templates/content/workspace_vcs_provider_list.tmpl b/internal/http/html/static/templates/content/workspace_vcs_provider_list.tmpl index 00369c69e..5d2b9a1b9 100644 --- a/internal/http/html/static/templates/content/workspace_vcs_provider_list.tmpl +++ b/internal/http/html/static/templates/content/workspace_vcs_provider_list.tmpl @@ -10,7 +10,7 @@ {{ define "content" }}
- Select a VCS provider to use to connect this workspace to a repository. + Select a VCS provider to connect this workspace to a repository.
@@ -26,7 +26,7 @@
{{ else }} - No VCS providers are currently configured. + No VCS providers are currently configured. Create a VCS provider here. {{ end }}
{{ end }} diff --git a/internal/http/html/static/templates/layout.tmpl b/internal/http/html/static/templates/layout.tmpl index 859b725eb..00f1bba35 100644 --- a/internal/http/html/static/templates/layout.tmpl +++ b/internal/http/html/static/templates/layout.tmpl @@ -20,10 +20,10 @@ {{ with .CurrentUser }} {{ end }} @@ -40,9 +40,10 @@ logo
- {{ if .CurrentUser }} + {{ with .CurrentUser }} + {{ if not .VCSProvider.GithubApp }}
+ {{ end }} {{ if .EditMode }} {{ else }} - + {{ end }} {{ end }} diff --git a/internal/http/transport.go b/internal/http/transport.go index 32eae979f..5a16c120f 100644 --- a/internal/http/transport.go +++ b/internal/http/transport.go @@ -5,24 +5,14 @@ import ( "net/http" ) -var insecureTransport http.RoundTripper +var DefaultTransport http.RoundTripper = http.DefaultTransport +var InsecureTransport http.RoundTripper func init() { - // http.DefaultTransport is a pkg variable, so we need to clone it to - // avoid disabling TLS verification globally. + // Assign InsecureTransport package variable. clone := http.DefaultTransport.(*http.Transport).Clone() clone.TLSClientConfig = &tls.Config{ InsecureSkipVerify: true, } - insecureTransport = clone -} - -// DefaultTransport wraps the stdlib http.DefaultTransport, returning either -// http.DefaultTransport, or if skipTLSVerification is true, a clone of -// http.DefaultTransport configured to skip TLS verification. -func DefaultTransport(skipTLSVerification bool) http.RoundTripper { - if skipTLSVerification { - return insecureTransport - } - return http.DefaultTransport + InsecureTransport = clone } diff --git a/internal/inmem/cloud_service.go b/internal/inmem/cloud_service.go deleted file mode 100644 index 45b54bcaf..000000000 --- a/internal/inmem/cloud_service.go +++ /dev/null @@ -1,46 +0,0 @@ -package inmem - -import ( - "fmt" - - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/github" - "github.com/leg100/otf/internal/gitlab" -) - -type CloudService struct { - db map[string]cloud.Config // keyed by cloud name -} - -func NewCloudService(configs ...cloud.Config) (*CloudService, error) { - db := make(map[string]cloud.Config, len(configs)) - for _, cfg := range configs { - db[cfg.Name] = cfg - } - return &CloudService{db}, nil -} - -func (cs *CloudService) GetCloudConfig(name string) (cloud.Config, error) { - cfg, ok := cs.db[name] - if !ok { - return cloud.Config{}, fmt.Errorf("unknown cloud: %s", cfg) - } - return cfg, nil -} - -func (cs *CloudService) ListCloudConfigs() []cloud.Config { - var configs []cloud.Config - for _, cfg := range cs.db { - configs = append(configs, cfg) - } - return configs -} - -func NewCloudServiceWithDefaults() *CloudService { - return &CloudService{ - db: map[string]cloud.Config{ - "github": github.Defaults(), - "gitlab": gitlab.Defaults(), - }, - } -} diff --git a/internal/integration/connect_repo_e2e_test.go b/internal/integration/connect_repo_e2e_test.go index 88bdb924d..f7b0de7ed 100644 --- a/internal/integration/connect_repo_e2e_test.go +++ b/internal/integration/connect_repo_e2e_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/chromedp/chromedp" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/run" "github.com/leg100/otf/internal/testutils" @@ -19,9 +18,8 @@ func TestConnectRepoE2E(t *testing.T) { // create an otf daemon with a fake github backend, serve up a repo and its // contents via tarball. And register a callback to test receipt of commit // statuses - repo := cloud.NewTestRepo() daemon, org, ctx := setup(t, nil, - github.WithRepo(repo), + github.WithRepo("leg100/tfc-workspaces"), github.WithCommit("0335fb07bb0244b7a169ee89d15c7703e4aaf7de"), github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), ) @@ -104,6 +102,6 @@ func TestConnectRepoE2E(t *testing.T) { // click delete button for one and only vcs provider chromedp.Click(`//button[text()='delete']`), screenshot(t), - matchText(t, "//div[@role='alert']", "deleted provider: "+provider.String()), + matchText(t, "//div[@role='alert']", `deleted provider: github \(token\)`), }) } diff --git a/internal/integration/daemon_helpers_test.go b/internal/integration/daemon_helpers_test.go index bd5f5c387..10e805cc8 100644 --- a/internal/integration/daemon_helpers_test.go +++ b/internal/integration/daemon_helpers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "io" + "net/url" "os" "os/exec" "testing" @@ -28,6 +29,7 @@ import ( "github.com/leg100/otf/internal/state" "github.com/leg100/otf/internal/tokens" "github.com/leg100/otf/internal/variable" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/require" @@ -62,7 +64,6 @@ func setup(t *testing.T, cfg *config, gopts ...github.TestServerOption) (*testDa if cfg == nil { cfg = &config{} } - // Setup database if not specified if cfg.Database == "" { cfg.Database = sql.NewTestDB(t) @@ -76,14 +77,22 @@ func setup(t *testing.T, cfg *config, gopts ...github.TestServerOption) (*testDa if cfg.DisableLatestChecker == nil || !*cfg.DisableLatestChecker { cfg.DisableLatestChecker = internal.Bool(true) } + // Skip TLS verification for tests because they'll be standing up various + // stub TLS servers with self-certified certs. + cfg.SkipTLSVerification = true + daemon.ApplyDefaults(&cfg.Config) cfg.SSL = true cfg.CertFile = "./fixtures/cert.pem" cfg.KeyFile = "./fixtures/key.pem" - // Start stub github server - githubServer, githubCfg := github.NewTestServer(t, gopts...) - cfg.Github.Config = githubCfg + // Start stub github server, unless test has set its own github stub + var githubServer *github.TestServer + if cfg.GithubHostname == "" { + var githubURL *url.URL + githubServer, githubURL = github.NewTestServer(t, gopts...) + cfg.GithubHostname = githubURL.Host + } // Configure logger; discard logs by default var logger logr.Logger @@ -200,8 +209,8 @@ func (s *testDaemon) createVCSProvider(t *testing.T, ctx context.Context, org *o Organization: org.Name, // tests require a legitimate cloud name to avoid invalid foreign // key error upon insert/update - Cloud: "github", - Token: uuid.NewString(), + Kind: vcs.KindPtr(vcs.GithubKind), + Token: internal.String(uuid.NewString()), }) require.NoError(t, err) return provider @@ -426,7 +435,6 @@ func (s *testDaemon) startAgent(t *testing.T, ctx context.Context, organization cfg.HTTPConfig = http.NewConfig() cfg.HTTPConfig.Token = string(token) cfg.HTTPConfig.Address = s.Hostname() - cfg.HTTPConfig.Insecure = true // daemon uses self-signed cert agent, err := agent.NewExternalAgent(ctx, logger, cfg) require.NoError(t, err) diff --git a/internal/integration/fixtures/github_app_push.json b/internal/integration/fixtures/github_app_push.json new file mode 100644 index 000000000..f4488adb4 --- /dev/null +++ b/internal/integration/fixtures/github_app_push.json @@ -0,0 +1,200 @@ +{ + "ref": "refs/heads/master", + "before": "f602c779bb6f16b7ec1ea7be3cc3432b97a3c958", + "after": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "repository": { + "id": 590586738, + "node_id": "R_kgDOIzOjcg", + "name": "otf-workspaces", + "full_name": "leg100/otf-workspaces", + "private": true, + "owner": { + "name": "leg100", + "email": "75728+leg100@users.noreply.github.com", + "login": "leg100", + "id": 75728, + "node_id": "MDQ6VXNlcjc1NzI4", + "avatar_url": "https://avatars.githubusercontent.com/u/75728?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/leg100", + "html_url": "https://github.com/leg100", + "followers_url": "https://api.github.com/users/leg100/followers", + "following_url": "https://api.github.com/users/leg100/following{/other_user}", + "gists_url": "https://api.github.com/users/leg100/gists{/gist_id}", + "starred_url": "https://api.github.com/users/leg100/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/leg100/subscriptions", + "organizations_url": "https://api.github.com/users/leg100/orgs", + "repos_url": "https://api.github.com/users/leg100/repos", + "events_url": "https://api.github.com/users/leg100/events{/privacy}", + "received_events_url": "https://api.github.com/users/leg100/received_events", + "type": "User", + "site_admin": false + }, + "html_url": "https://github.com/leg100/otf-workspaces", + "description": "Sample workspaces for OTF", + "fork": false, + "url": "https://github.com/leg100/otf-workspaces", + "forks_url": "https://api.github.com/repos/leg100/otf-workspaces/forks", + "keys_url": "https://api.github.com/repos/leg100/otf-workspaces/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/leg100/otf-workspaces/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/leg100/otf-workspaces/teams", + "hooks_url": "https://api.github.com/repos/leg100/otf-workspaces/hooks", + "issue_events_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/events{/number}", + "events_url": "https://api.github.com/repos/leg100/otf-workspaces/events", + "assignees_url": "https://api.github.com/repos/leg100/otf-workspaces/assignees{/user}", + "branches_url": "https://api.github.com/repos/leg100/otf-workspaces/branches{/branch}", + "tags_url": "https://api.github.com/repos/leg100/otf-workspaces/tags", + "blobs_url": "https://api.github.com/repos/leg100/otf-workspaces/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/leg100/otf-workspaces/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/leg100/otf-workspaces/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/leg100/otf-workspaces/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/leg100/otf-workspaces/statuses/{sha}", + "languages_url": "https://api.github.com/repos/leg100/otf-workspaces/languages", + "stargazers_url": "https://api.github.com/repos/leg100/otf-workspaces/stargazers", + "contributors_url": "https://api.github.com/repos/leg100/otf-workspaces/contributors", + "subscribers_url": "https://api.github.com/repos/leg100/otf-workspaces/subscribers", + "subscription_url": "https://api.github.com/repos/leg100/otf-workspaces/subscription", + "commits_url": "https://api.github.com/repos/leg100/otf-workspaces/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/leg100/otf-workspaces/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/leg100/otf-workspaces/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/leg100/otf-workspaces/contents/{+path}", + "compare_url": "https://api.github.com/repos/leg100/otf-workspaces/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/leg100/otf-workspaces/merges", + "archive_url": "https://api.github.com/repos/leg100/otf-workspaces/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/leg100/otf-workspaces/downloads", + "issues_url": "https://api.github.com/repos/leg100/otf-workspaces/issues{/number}", + "pulls_url": "https://api.github.com/repos/leg100/otf-workspaces/pulls{/number}", + "milestones_url": "https://api.github.com/repos/leg100/otf-workspaces/milestones{/number}", + "notifications_url": "https://api.github.com/repos/leg100/otf-workspaces/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/leg100/otf-workspaces/labels{/name}", + "releases_url": "https://api.github.com/repos/leg100/otf-workspaces/releases{/id}", + "deployments_url": "https://api.github.com/repos/leg100/otf-workspaces/deployments", + "created_at": 1674067797, + "updated_at": "2023-01-18T18:50:12Z", + "pushed_at": 1697565914, + "git_url": "git://github.com/leg100/otf-workspaces.git", + "ssh_url": "git@github.com:leg100/otf-workspaces.git", + "clone_url": "https://github.com/leg100/otf-workspaces.git", + "svn_url": "https://github.com/leg100/otf-workspaces", + "homepage": null, + "size": 12, + "stargazers_count": 0, + "watchers_count": 0, + "language": "HCL", + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": true, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 4, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [ + + ], + "visibility": "private", + "forks": 0, + "open_issues": 4, + "watchers": 0, + "default_branch": "master", + "stargazers": 0, + "master_branch": "master" + }, + "pusher": { + "name": "leg100", + "email": "75728+leg100@users.noreply.github.com" + }, + "sender": { + "login": "leg100", + "id": 75728, + "node_id": "MDQ6VXNlcjc1NzI4", + "avatar_url": "https://avatars.githubusercontent.com/u/75728?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/leg100", + "html_url": "https://github.com/leg100", + "followers_url": "https://api.github.com/users/leg100/followers", + "following_url": "https://api.github.com/users/leg100/following{/other_user}", + "gists_url": "https://api.github.com/users/leg100/gists{/gist_id}", + "starred_url": "https://api.github.com/users/leg100/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/leg100/subscriptions", + "organizations_url": "https://api.github.com/users/leg100/orgs", + "repos_url": "https://api.github.com/users/leg100/repos", + "events_url": "https://api.github.com/users/leg100/events{/privacy}", + "received_events_url": "https://api.github.com/users/leg100/received_events", + "type": "User", + "site_admin": false + }, + "installation": { + "id": 42997659, + "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDI5OTc2NTk=" + }, + "created": false, + "deleted": false, + "forced": false, + "base_ref": null, + "compare": "https://github.com/leg100/otf-workspaces/compare/f602c779bb6f...0a2d223fa1a3", + "commits": [ + { + "id": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "tree_id": "9f6882422d87bdceea343daa23826d73ae9a6eff", + "distinct": true, + "message": "empty commit", + "timestamp": "2023-10-17T19:05:12+01:00", + "url": "https://github.com/leg100/otf-workspaces/commit/0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "author": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "committer": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "added": [ + + ], + "removed": [ + + ], + "modified": [ + + ] + } + ], + "head_commit": { + "id": "0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "tree_id": "9f6882422d87bdceea343daa23826d73ae9a6eff", + "distinct": true, + "message": "empty commit", + "timestamp": "2023-10-17T19:05:12+01:00", + "url": "https://github.com/leg100/otf-workspaces/commit/0a2d223fa1a3844480e3b7716cf87aacb658b91f", + "author": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "committer": { + "name": "Louis Garman", + "email": "louisgarman@gmail.com", + "username": "leg100" + }, + "added": [ + + ], + "removed": [ + + ], + "modified": [ + + ] + } +} diff --git a/internal/integration/fixtures/github_pull_update.json b/internal/integration/fixtures/github_pull_update.json index 787b2f935..5ae8e518f 100644 --- a/internal/integration/fixtures/github_pull_update.json +++ b/internal/integration/fixtures/github_pull_update.json @@ -1,6 +1,6 @@ { "action": "synchronize", - "number": 1, + "number": 2, "pull_request": { "url": "https://api.github.com/repos/leg100/otf-workspaces/pulls/1", "id": 1319867586, @@ -9,10 +9,10 @@ "diff_url": "https://github.com/leg100/otf-workspaces/pull/1.diff", "patch_url": "https://github.com/leg100/otf-workspaces/pull/1.patch", "issue_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/1", - "number": 1, + "number": 2, "state": "open", "locked": false, - "title": "pr-1", + "title": "pr-2", "user": { "login": "leg100", "id": 75728, @@ -60,8 +60,8 @@ "comments_url": "https://api.github.com/repos/leg100/otf-workspaces/issues/1/comments", "statuses_url": "https://api.github.com/repos/leg100/otf-workspaces/statuses/067e2b4c6394b3dad3c0ec89ffc428ab60ae7e5d", "head": { - "label": "leg100:pr-1", - "ref": "pr-1", + "label": "leg100:pr-2", + "ref": "pr-2", "sha": "067e2b4c6394b3dad3c0ec89ffc428ab60ae7e5d", "user": { "login": "leg100", diff --git a/internal/integration/fixtures/github_push.json b/internal/integration/fixtures/github_push.json index 92f072865..2a9548cba 100644 --- a/internal/integration/fixtures/github_push.json +++ b/internal/integration/fixtures/github_push.json @@ -6,7 +6,7 @@ "id": 481653257, "node_id": "R_kgDOHLVyCQ", "name": "tfc-workspaces", - "full_name": "%s", + "full_name": "leg100/tfc-workspaces", "private": true, "owner": { "name": "leg100", diff --git a/internal/integration/github_app_test.go b/internal/integration/github_app_test.go new file mode 100644 index 000000000..2dc665d8e --- /dev/null +++ b/internal/integration/github_app_test.go @@ -0,0 +1,243 @@ +package integration + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/chromedp/cdproto/input" + "github.com/chromedp/chromedp" + gogithub "github.com/google/go-github/v55/github" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/daemon" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/http/decode" + "github.com/leg100/otf/internal/run" + "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcsprovider" + "github.com/leg100/otf/internal/workspace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_GithubAppNewUI demonstrates creation of a github app via the +// UI. +func TestIntegration_GithubAppNewUI(t *testing.T) { + integrationTest(t) + + // creating a github app requires site-admin role + ctx := internal.AddSubjectToContext(context.Background(), &auth.SiteAdmin) + + tests := []struct { + name string + public bool // whether to tick 'public' checkbox + organization string // install in organization github account + path string // form should submitted to this path on github + }{ + { + "create private app in personal github account", + false, + "", + "/settings/apps/new", + }, + { + "create public app in personal github account", + true, + "", + "/settings/apps/new", + }, + { + "create private app in organization github account", + false, + "acme-corp", + "/organizations/acme-corp/settings/apps/new", + }, + { + "create public app in organization github account", + true, + "acme-corp", + "/organizations/acme-corp/settings/apps/new", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + githubHostname := func(t *testing.T, path string, public bool) string { + mux := http.NewServeMux() + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + type manifest struct { + Public bool `json:"public"` + } + var params struct { + Manifest manifest + } + require.NoError(t, decode.All(¶ms, r)) + assert.Equal(t, public, params.Manifest.Public) + }) + stub := httptest.NewTLSServer(mux) + t.Cleanup(stub.Close) + + u, err := url.Parse(stub.URL) + require.NoError(t, err) + return u.Host + }(t, tt.path, tt.public) + + daemon, _, _ := setup(t, &config{Config: daemon.Config{GithubHostname: githubHostname}}) + tasks := chromedp.Tasks{ + // go to site settings page + chromedp.Navigate("https://" + daemon.Hostname() + "/app/admin"), + screenshot(t, "site_settings"), + // go to github app page + chromedp.Click("//a[text()='GitHub app']"), + screenshot(t, "empty_github_app_page"), + // go to page for creating a new github app + chromedp.Click("//a[@id='new-github-app-link']"), + screenshot(t, "new_github_app"), + } + if tt.public { + tasks = append(tasks, chromedp.Click(`//input[@type='checkbox' and @id='public']`)) + } + if tt.organization != "" { + tasks = append(tasks, chromedp.Focus(`//input[@id="organization"]`, chromedp.NodeVisible)) + tasks = append(tasks, input.InsertText(tt.organization)) + } + tasks = append(tasks, chromedp.Click(`//button[text()='Create']`)) + browser.Run(t, ctx, tasks) + }) + } + + // demonstrate the completion of creating a github app, by taking over from + // where Github would redirect back to OTF, exchanging the code with a + // stub Github server, and receiving back the app config, and then + // redirecting to the github app page showing the created app. + t.Run("complete creation of github app", func(t *testing.T) { + handlers := []github.TestServerOption{ + github.WithHandler("/api/v3/app-manifests/anything/conversions", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&gogithub.AppConfig{ + ID: internal.Int64(123), + Slug: internal.String("my-otf-app"), + WebhookSecret: internal.String("top-secret"), + PEM: internal.String(string(testutils.ReadFile(t, "./fixtures/key.pem"))), + Owner: &gogithub.User{}, + }) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }), + github.WithHandler("/api/v3/app/installations", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + }), + } + daemon, _, _ := setup(t, nil, handlers...) + browser.Run(t, ctx, chromedp.Tasks{ + // go to the exchange code endpoint + chromedp.Navigate((&url.URL{ + Scheme: "https", + Host: daemon.Hostname(), + Path: "/app/github-apps/exchange-code", + RawQuery: "code=anything", + }).String()), + chromedp.WaitVisible(`//div[@class='widget']//a[contains(text(), "my-otf-app")]`), + screenshot(t, "github_app_created"), + }) + }) + + // demonstrate the listing of github installations + t.Run("list github app installs", func(t *testing.T) { + handlers := []github.TestServerOption{ + github.WithHandler("/api/v3/app/installations", func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + out, err := json.Marshal([]*gogithub.Installation{ + { + ID: internal.Int64(123), + Account: &gogithub.User{Login: internal.String("leg100")}, + }, + }) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }), + } + daemon, _, _ := setup(t, nil, handlers...) + _, err := daemon.CreateGithubApp(ctx, github.CreateAppOptions{ + AppID: 123, + Slug: "otf-123", + PrivateKey: string(testutils.ReadFile(t, "./fixtures/key.pem")), + }) + require.NoError(t, err) + browser.Run(t, ctx, chromedp.Tasks{ + chromedp.Navigate(daemon.HostnameService.URL("/app/github-apps")), + chromedp.WaitVisible(`//div[@id='installations']//a[contains(text(), "user/leg100")]`), + screenshot(t, "github_app_install_list"), + }) + }) +} + +// TestIntegration_GithubApp_Event demonstrates an event from a github +// app installation triggering a run. +func TestIntegration_GithubApp_Event(t *testing.T) { + integrationTest(t) + + daemon, org, ctx := setup(t, nil, + github.WithRepo("leg100/otf-workspaces"), + github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), + github.WithHandler("/api/v3/app/installations/42997659", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&gogithub.Installation{ + ID: internal.Int64(42997659), + Account: &gogithub.User{Login: internal.String("leg100")}, + TargetType: internal.String("User"), + }) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }), + github.WithHandler("/api/v3/app/installations/42997659/access_tokens", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&gogithub.InstallationToken{}) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }), + ) + // creating a github app requires site-admin role + ctx = internal.AddSubjectToContext(ctx, &auth.SiteAdmin) + // create an OTF daemon with a fake github backend, and serve up a repo and + // its contents via tarball. + _, err := daemon.CreateGithubApp(ctx, github.CreateAppOptions{ + // any key will do, the stub github server won't actually authenticate it. + PrivateKey: string(testutils.ReadFile(t, "./fixtures/key.pem")), + Slug: "test-app", + WebhookSecret: "secret", + }) + require.NoError(t, err) + + provider, err := daemon.CreateVCSProvider(ctx, vcsprovider.CreateOptions{ + Organization: org.Name, + GithubAppInstallID: internal.Int64(42997659), + }) + require.NoError(t, err) + + // create and connect a workspace to a repo using the app install + _, err = daemon.CreateWorkspace(ctx, workspace.CreateOptions{ + Name: internal.String("dev"), + Organization: internal.String(org.Name), + ConnectOptions: &workspace.ConnectOptions{ + VCSProviderID: &provider.ID, + RepoPath: internal.String("leg100/otf-workspaces"), + }, + }) + require.NoError(t, err) + + // send event + push := testutils.ReadFile(t, "./fixtures/github_app_push.json") + github.SendEventRequest(t, github.PushEvent, daemon.HostnameService.URL(github.AppEventsPath), "secret", push) + + // wait for run to be created + for event := range daemon.sub { + if _, ok := event.Payload.(*run.Run); ok { + return + } + } +} diff --git a/internal/integration/github_login_test.go b/internal/integration/github_login_test.go index 0533a7ca7..5dfa480af 100644 --- a/internal/integration/github_login_test.go +++ b/internal/integration/github_login_test.go @@ -4,11 +4,8 @@ import ( "testing" "github.com/chromedp/chromedp" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/daemon" "github.com/leg100/otf/internal/github" - "golang.org/x/oauth2" - oauth2github "golang.org/x/oauth2/github" ) // TestGithubLogin demonstrates logging into the UI via Github OAuth. @@ -18,20 +15,14 @@ func TestGithubLogin(t *testing.T) { // Start daemon with a stub github server populated with a user. cfg := config{ Config: daemon.Config{ - Github: cloud.CloudOAuthConfig{ - // specifying oauth credentials turns on the option to login via - // github - OAuthConfig: &oauth2.Config{ - Endpoint: oauth2github.Endpoint, - Scopes: []string{"user:email", "read:org"}, - ClientID: "stub-client-id", - ClientSecret: "stub-client-secret", - }, - }, + // specifying oauth credentials turns on the option to login via + // github + GithubClientID: "stub-client-id", + GithubClientSecret: "stub-client-secret", }, } - user := cloud.User{Name: "bobby"} - svc, _, _ := setup(t, &cfg, github.WithUser(&user)) + username := "bobby" + svc, _, _ := setup(t, &cfg, github.WithUser(&username)) browser.Run(t, nil, chromedp.Tasks{ // go to login page diff --git a/internal/integration/github_pr_test.go b/internal/integration/github_pr_test.go deleted file mode 100644 index 71da71a90..000000000 --- a/internal/integration/github_pr_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package integration - -import ( - "testing" - - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/github" - "github.com/leg100/otf/internal/testutils" - "github.com/leg100/otf/internal/workspace" - "github.com/stretchr/testify/require" -) - -// TestIntegration_GithubPR demonstrates the spawning of runs in response to -// opening and updating a pull-request on github. -func TestIntegration_GithubPR(t *testing.T) { - integrationTest(t) - - // create an otf daemon with a fake github backend, serve up a repo and its - // contents via tarball. - repo := cloud.NewTestRepo() - daemon, org, ctx := setup(t, nil, - github.WithRepo(repo), - github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), - ) - - // create workspace connected to github repo - provider := daemon.createVCSProvider(t, ctx, org) - _, err := daemon.CreateWorkspace(ctx, workspace.CreateOptions{ - Name: internal.String("workspace-1"), - Organization: &provider.Organization, - ConnectOptions: &workspace.ConnectOptions{ - VCSProviderID: &provider.ID, - RepoPath: &repo, - }, - }) - require.NoError(t, err) - - // a pull request is opened on github which triggers an event - push := testutils.ReadFile(t, "./fixtures/github_pull_opened.json") - daemon.SendEvent(t, github.PullRequest, push) - - // github should receive multiple pending status updates followed by a final - // update with details of planned resources - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - got := daemon.GetStatus(t, ctx) - require.Equal(t, "success", got.GetState()) - require.Equal(t, "planned: +2/~0/−0", got.GetDescription()) - - // the pull request is updated with another commit - update := testutils.ReadFile(t, "./fixtures/github_pull_update.json") - daemon.SendEvent(t, github.PullRequest, update) - - // github should receive multiple pending status updates followed by a final - // update with details of planned resources - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - got = daemon.GetStatus(t, ctx) - require.Equal(t, "success", got.GetState()) - require.Equal(t, "planned: +2/~0/−0", got.GetDescription()) -} diff --git a/internal/integration/github_pull_request_test.go b/internal/integration/github_pull_request_test.go index 6b2a75133..2b4eaaa80 100644 --- a/internal/integration/github_pull_request_test.go +++ b/internal/integration/github_pull_request_test.go @@ -1,11 +1,11 @@ package integration import ( + "fmt" "testing" "github.com/chromedp/chromedp" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/testutils" "github.com/leg100/otf/internal/workspace" @@ -19,9 +19,8 @@ func TestGithubPullRequest(t *testing.T) { // create an OTF daemon with a fake github backend, serve up a repo and its // contents via tarball, and setup a fake pull request with a list of files // it has changed. - repo := cloud.NewTestRepo() daemon, org, ctx := setup(t, nil, - github.WithRepo(repo), + github.WithRepo("leg100/otf-workspaces"), github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), github.WithPullRequest("2", "/nomatch.tf", "/foo/bar/match.tf"), ) @@ -33,34 +32,49 @@ func TestGithubPullRequest(t *testing.T) { TriggerPatterns: []string{"/foo/**/*.tf"}, ConnectOptions: &workspace.ConnectOptions{ VCSProviderID: &provider.ID, - RepoPath: &repo, + RepoPath: internal.String("leg100/otf-workspaces"), }, }) require.NoError(t, err) - // open pull request - pull := testutils.ReadFile(t, "fixtures/github_pull_opened.json") - daemon.SendEvent(t, github.PullRequest, pull) + // send events + events := []struct { + path string + commit string + }{ + { + path: "fixtures/github_pull_opened.json", + commit: "c560613", + }, + { + path: "fixtures/github_pull_update.json", + commit: "067e2b4", + }, + } + for _, event := range events { + pull := testutils.ReadFile(t, event.path) + daemon.SendEvent(t, github.PullRequest, pull) - // commit-triggered run should appear as latest run on workspace - browser.Run(t, ctx, chromedp.Tasks{ - // go to runs - chromedp.Navigate(runsURL(daemon.Hostname(), ws.ID)), - screenshot(t), - // should be one run widget with info matching the pull request - chromedp.WaitVisible(`//div[@class='widget']//a[@id='pull-request-link' and text()='#2']`), - chromedp.WaitVisible(`//div[@class='widget']//a[@id='vcs-username' and text()='@leg100']`), - chromedp.WaitVisible(`//div[@class='widget']//a[@id='commit-sha-abbrev' and text()='c560613']`), - screenshot(t), - }) + // commit-triggered run should appear as latest run on workspace + browser.Run(t, ctx, chromedp.Tasks{ + // go to runs + chromedp.Navigate(runsURL(daemon.Hostname(), ws.ID)), + screenshot(t), + // should be one run widget with info matching the pull request + chromedp.WaitVisible(`//div[@class='widget']//a[@id='pull-request-link' and text()='#2']`), + chromedp.WaitVisible(`//div[@class='widget']//a[@id='vcs-username' and text()='@leg100']`), + chromedp.WaitVisible(fmt.Sprintf(`//div[@class='widget']//a[@id='commit-sha-abbrev' and text()='%s']`, event.commit)), + screenshot(t), + }) - // github should receive several pending status updates followed by a final - // update with details of planned resources - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) - got := daemon.GetStatus(t, ctx) - require.Equal(t, "success", got.GetState()) - require.Equal(t, "planned: +2/~0/−0", got.GetDescription()) + // github should receive several pending status updates followed by a final + // update with details of planned resources + require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) + require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) + require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) + require.Equal(t, "pending", daemon.GetStatus(t, ctx).GetState()) + got := daemon.GetStatus(t, ctx) + require.Equal(t, "success", got.GetState()) + require.Equal(t, "planned: +2/~0/−0", got.GetDescription()) + } } diff --git a/internal/integration/module_e2e_test.go b/internal/integration/module_e2e_test.go index 1597a3488..38167db3c 100644 --- a/internal/integration/module_e2e_test.go +++ b/internal/integration/module_e2e_test.go @@ -7,9 +7,9 @@ import ( "testing" "github.com/chromedp/chromedp" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/require" ) @@ -20,7 +20,7 @@ func TestModuleE2E(t *testing.T) { // create an otf daemon with a fake github backend, ready to serve up a repo // and its contents via tarball. - repo := cloud.NewTestModuleRepo("aws", "mod") + repo := vcs.NewTestModuleRepo("aws", "mod") svc, org, ctx := setup(t, nil, github.WithRepo(repo), github.WithRefs("tags/v0.0.1", "tags/v0.0.2", "tags/v0.1.0"), diff --git a/internal/integration/oidc_test.go b/internal/integration/oidc_test.go index c0e7806dd..4f5fda616 100644 --- a/internal/integration/oidc_test.go +++ b/internal/integration/oidc_test.go @@ -5,7 +5,6 @@ import ( "github.com/chromedp/chromedp" "github.com/leg100/otf/internal/authenticator" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/daemon" ) @@ -16,7 +15,7 @@ func TestIntegration_OIDC(t *testing.T) { // Start daemon with a stub github server populated with a user. cfg := config{ Config: daemon.Config{ - OIDC: cloud.OIDCConfig{ + OIDC: authenticator.OIDCConfig{ Name: "google", IssuerURL: authenticator.NewOIDCIssuer(t, "bobby", "stub-client-id", "google"), ClientID: "stub-client-id", diff --git a/internal/integration/repo_test.go b/internal/integration/repo_test.go index 8565eb13d..ad83c8287 100644 --- a/internal/integration/repo_test.go +++ b/internal/integration/repo_test.go @@ -3,8 +3,8 @@ package integration import ( "testing" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/github" - "github.com/leg100/otf/internal/repo" "github.com/stretchr/testify/require" ) @@ -17,8 +17,8 @@ func TestRepo(t *testing.T) { vcsprov := svc.createVCSProvider(t, ctx, org) mod1 := svc.createModule(t, ctx, org) - _, err := svc.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.ModuleConnection, + _, err := svc.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.ModuleConnection, VCSProviderID: vcsprov.ID, ResourceID: mod1.ID, RepoPath: "test/dummy", @@ -29,8 +29,8 @@ func TestRepo(t *testing.T) { require.Equal(t, github.WebhookCreated, hook.Action) mod2 := svc.createModule(t, ctx, org) - _, err = svc.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.ModuleConnection, + _, err = svc.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.ModuleConnection, VCSProviderID: vcsprov.ID, ResourceID: mod2.ID, RepoPath: "test/dummy", @@ -41,8 +41,8 @@ func TestRepo(t *testing.T) { require.Equal(t, github.WebhookUpdated, hook.Action) ws1 := svc.createWorkspace(t, ctx, org) - _, err = svc.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.WorkspaceConnection, + _, err = svc.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.WorkspaceConnection, VCSProviderID: vcsprov.ID, ResourceID: ws1.ID, RepoPath: "test/dummy", @@ -53,8 +53,8 @@ func TestRepo(t *testing.T) { require.Equal(t, github.WebhookUpdated, hook.Action) ws2 := svc.createWorkspace(t, ctx, org) - _, err = svc.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.WorkspaceConnection, + _, err = svc.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.WorkspaceConnection, VCSProviderID: vcsprov.ID, ResourceID: ws2.ID, RepoPath: "test/dummy", @@ -65,26 +65,26 @@ func TestRepo(t *testing.T) { require.Equal(t, github.WebhookUpdated, hook.Action) t.Run("delete multiple connections", func(t *testing.T) { - err = svc.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.WorkspaceConnection, + err = svc.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: ws2.ID, }) require.NoError(t, err) - err = svc.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.WorkspaceConnection, + err = svc.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: ws1.ID, }) require.NoError(t, err) - err := svc.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.ModuleConnection, + err := svc.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.ModuleConnection, ResourceID: mod2.ID, }) require.NoError(t, err) - err = svc.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.ModuleConnection, + err = svc.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.ModuleConnection, ResourceID: mod1.ID, }) require.NoError(t, err) diff --git a/internal/integration/run_api_test.go b/internal/integration/run_api_test.go index 67425671d..6567864f6 100644 --- a/internal/integration/run_api_test.go +++ b/internal/integration/run_api_test.go @@ -5,10 +5,10 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/run" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,7 +21,7 @@ func TestIntegration_RunAPI(t *testing.T) { integrationTest(t) // setup daemon along with fake github repo - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() daemon, org, ctx := setup(t, nil, github.WithRepo(repo), github.WithCommit("0335fb07bb0244b7a169ee89d15c7703e4aaf7de"), diff --git a/internal/integration/run_test.go b/internal/integration/run_test.go index 76e2b3582..1b650047a 100644 --- a/internal/integration/run_test.go +++ b/internal/integration/run_test.go @@ -5,13 +5,13 @@ import ( "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" "github.com/leg100/otf/internal/daemon" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/resource" "github.com/leg100/otf/internal/run" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,7 +35,7 @@ func TestRun(t *testing.T) { t.Run("create run using config from repo", func(t *testing.T) { // setup daemon along with fake github repo - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() daemon, _, ctx := setup(t, nil, github.WithRepo(repo), github.WithCommit("0335fb07bb0244b7a169ee89d15c7703e4aaf7de"), diff --git a/internal/integration/vcsprovider_test.go b/internal/integration/vcsprovider_test.go index d351e2e11..4ae66d528 100644 --- a/internal/integration/vcsprovider_test.go +++ b/internal/integration/vcsprovider_test.go @@ -4,6 +4,8 @@ import ( "testing" "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -17,8 +19,8 @@ func TestVCSProvider(t *testing.T) { _, err := svc.CreateVCSProvider(ctx, vcsprovider.CreateOptions{ Organization: org.Name, - Token: uuid.NewString(), - Cloud: "github", + Token: internal.String(uuid.NewString()), + Kind: vcs.KindPtr(vcs.GithubKind), }) require.NoError(t, err) }) diff --git a/internal/integration/vcsprovider_ui_test.go b/internal/integration/vcsprovider_ui_test.go index 553744b6a..f743a566b 100644 --- a/internal/integration/vcsprovider_ui_test.go +++ b/internal/integration/vcsprovider_ui_test.go @@ -1,19 +1,32 @@ package integration import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" "testing" "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" + gogithub "github.com/google/go-github/v55/github" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/daemon" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/testutils" + "github.com/stretchr/testify/require" ) -// TestIntegration_VCSProviderUI demonstrates management of vcs providers via +// TestIntegration_VCSProviderUI demonstrates management of personal token vcs providers via // the UI. -func TestIntegration_VCSProviderUI(t *testing.T) { +func TestIntegration_VCSProviderTokenUI(t *testing.T) { integrationTest(t) daemon, org, ctx := setup(t, nil) + // create a vcs provider with a github personal access token browser.Run(t, ctx, chromedp.Tasks{ // go to org chromedp.Navigate(organizationURL(daemon.Hostname(), org.Name)), @@ -22,14 +35,15 @@ func TestIntegration_VCSProviderUI(t *testing.T) { chromedp.Click("#vcs_providers > a", chromedp.ByQuery), screenshot(t, "vcs_providers_list"), // click 'New Github VCS Provider' button - chromedp.Click(`//button[text()='New Github VCS Provider']`), + chromedp.Click(`//button[text()='New Github VCS Provider (Personal Token)']`), screenshot(t, "new_github_vcs_provider_form"), // enter fake github token chromedp.Focus("textarea#token", chromedp.NodeVisible, chromedp.ByQuery), input.InsertText("fake-github-personal-token"), // submit form to create provider chromedp.Submit("textarea#token", chromedp.ByQuery), - matchText(t, "//div[@role='alert']", "created provider: github"), + matchText(t, "//div[@role='alert']", `created provider: github \(token\)`), + screenshot(t, "vcs_provider_created_github_pat_provider"), // edit provider chromedp.Click(`//a[@id='edit-vcs-provider-link']`), waitLoaded, // give it a name @@ -48,10 +62,80 @@ func TestIntegration_VCSProviderUI(t *testing.T) { chromedp.Focus("input#name", chromedp.ByQuery, chromedp.NodeVisible), chromedp.Clear("input#name", chromedp.ByQuery), chromedp.Click(`//button[text()='Update']`), - matchText(t, "//div[@role='alert']", "updated provider: github"), + matchText(t, "//div[@role='alert']", `updated provider: github \(token\)`), // delete token chromedp.Click(`//a[@id='edit-vcs-provider-link']`), waitLoaded, chromedp.Click(`//button[@id='delete-vcs-provider-button']`), - matchText(t, "//div[@role='alert']", "deleted provider: github"), + matchText(t, "//div[@role='alert']", `deleted provider: github \(token\)`), + }) +} + +// TestIntegration_VCSProviderAppUI demonstrates management of github app vcs +// providers via the UI. +func TestIntegration_VCSProviderAppUI(t *testing.T) { + integrationTest(t) + + // create github stub server and return its hostname. + githubHostname := func(t *testing.T) string { + install := &gogithub.Installation{ + ID: internal.Int64(123), + Account: &gogithub.User{Login: internal.String("leg100")}, + TargetType: internal.String("User"), + } + mux := http.NewServeMux() + mux.HandleFunc("/api/v3/app/installations", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal([]*gogithub.Installation{install}) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }) + mux.HandleFunc("/api/v3/app/installations/123", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(install) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }) + mux.HandleFunc("/api/v3/installation/repositories", func(w http.ResponseWriter, r *http.Request) { + out, err := json.Marshal(&gogithub.ListRepositories{ + Repositories: []*gogithub.Repository{{FullName: internal.String("leg100/otf-workspaces")}}, + }) + require.NoError(t, err) + w.Header().Add("Content-Type", "application/json") + w.Write(out) + }) + stub := httptest.NewTLSServer(mux) + t.Cleanup(stub.Close) + + u, err := url.Parse(stub.URL) + require.NoError(t, err) + return u.Host + }(t) + + daemon, org, _ := setup(t, &config{Config: daemon.Config{GithubHostname: githubHostname}}) + + // creating a github app requires site-admin role + ctx := internal.AddSubjectToContext(context.Background(), &auth.SiteAdmin) + + // create app + _, err := daemon.CreateGithubApp(ctx, github.CreateAppOptions{ + AppID: 123, + Slug: "otf-123", + PrivateKey: string(testutils.ReadFile(t, "./fixtures/key.pem")), + }) + require.NoError(t, err) + + // create github app vcs provider via UI. + browser.Run(t, ctx, chromedp.Tasks{ + // go to org + chromedp.Navigate(organizationURL(daemon.Hostname(), org.Name)), + // go to vcs providers + chromedp.Click("#vcs_providers > a", chromedp.ByQuery), + screenshot(t, "vcs_provider_list_including_github_app"), + // click button for creating a new vcs provider with a github app + chromedp.Click(`//button[text()='New Github VCS Provider (App)']`), + // one github app installation should be listed + chromedp.WaitEnabled(`//select[@id='select-install-id']/option[text()='user/leg100']`), + chromedp.Click(`//button[text()='Create']`), + matchText(t, "//div[@role='alert']", `created provider: github \(app\)`), }) } diff --git a/internal/integration/webhook_test.go b/internal/integration/webhook_test.go index e2e6f57cd..f6f72a023 100644 --- a/internal/integration/webhook_test.go +++ b/internal/integration/webhook_test.go @@ -5,8 +5,8 @@ import ( "github.com/chromedp/chromedp" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/require" ) @@ -22,7 +22,7 @@ import ( func TestWebhook(t *testing.T) { integrationTest(t) - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() // create otf daemon with fake github server, on which to create/delete // webhooks. @@ -73,7 +73,7 @@ func TestWebhook(t *testing.T) { func TestWebhook_Purger(t *testing.T) { integrationTest(t) - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() // create an otf daemon with a fake github backend, ready to sign in a user, // serve up a repo and its contents via tarball. And register a callback to diff --git a/internal/integration/workspace_allow_cli_apply_test.go b/internal/integration/workspace_allow_cli_apply_test.go index 0f88ed5f9..19060963b 100644 --- a/internal/integration/workspace_allow_cli_apply_test.go +++ b/internal/integration/workspace_allow_cli_apply_test.go @@ -4,9 +4,9 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +18,7 @@ import ( func TestIntegration_AllowCLIApply(t *testing.T) { integrationTest(t) - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() daemon, org, ctx := setup(t, nil, github.WithRepo(repo), github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), diff --git a/internal/integration/workspace_api_test.go b/internal/integration/workspace_api_test.go index be6d86eac..c2fc9584b 100644 --- a/internal/integration/workspace_api_test.go +++ b/internal/integration/workspace_api_test.go @@ -10,11 +10,11 @@ import ( "github.com/DataDog/jsonapi" tfe "github.com/hashicorp/go-tfe" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/run" "github.com/leg100/otf/internal/testutils" "github.com/leg100/otf/internal/tfeapi/types" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -64,7 +64,7 @@ func TestIntegration_WorkspaceAPI_CreateConnected(t *testing.T) { integrationTest(t) // setup daemon along with fake github repo - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() daemon, org, ctx := setup(t, nil, github.WithRepo(repo), github.WithCommit("0335fb07bb0244b7a169ee89d15c7703e4aaf7de"), @@ -82,7 +82,7 @@ func TestIntegration_WorkspaceAPI_CreateConnected(t *testing.T) { provider := daemon.createVCSProvider(t, ctx, org) oauth, err := client.OAuthClients.Create(ctx, org.Name, tfe.OAuthClientCreateOptions{ - OAuthToken: internal.String(provider.Token), + OAuthToken: provider.Token, APIURL: internal.String(vcsprovider.GithubAPIURL), HTTPURL: internal.String(vcsprovider.GithubHTTPURL), ServiceProvider: tfe.ServiceProvider(tfe.ServiceProviderGithub), diff --git a/internal/integration/workspace_test.go b/internal/integration/workspace_test.go index a0b1986e8..2d86ed671 100644 --- a/internal/integration/workspace_test.go +++ b/internal/integration/workspace_test.go @@ -6,10 +6,10 @@ import ( "github.com/google/uuid" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/rbac" - "github.com/leg100/otf/internal/repo" "github.com/leg100/otf/internal/resource" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" @@ -61,8 +61,8 @@ func TestWorkspace(t *testing.T) { require.Equal(t, github.WebhookCreated, hook.Action) t.Run("delete workspace connection", func(t *testing.T) { - err := daemon.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.WorkspaceConnection, + err := daemon.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: ws.ID, }) require.NoError(t, err) @@ -105,19 +105,19 @@ func TestWorkspace(t *testing.T) { ws := svc.createWorkspace(t, ctx, org) vcsprov := svc.createVCSProvider(t, ctx, org) - got, err := svc.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.WorkspaceConnection, + got, err := svc.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.WorkspaceConnection, VCSProviderID: vcsprov.ID, ResourceID: ws.ID, RepoPath: "test/dummy", }) require.NoError(t, err) - want := &repo.Connection{VCSProviderID: vcsprov.ID, Repo: "test/dummy"} + want := &connections.Connection{VCSProviderID: vcsprov.ID, Repo: "test/dummy"} assert.Equal(t, want, got) t.Run("delete workspace connection", func(t *testing.T) { - err := svc.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.WorkspaceConnection, + err := svc.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: ws.ID, }) require.NoError(t, err) diff --git a/internal/integration/workspace_ui_test.go b/internal/integration/workspace_ui_test.go index ec321aa79..1720f18c7 100644 --- a/internal/integration/workspace_ui_test.go +++ b/internal/integration/workspace_ui_test.go @@ -7,9 +7,9 @@ import ( "github.com/chromedp/cdproto/input" "github.com/chromedp/chromedp" "github.com/chromedp/chromedp/kb" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/require" ) @@ -17,7 +17,7 @@ import ( func TestIntegration_WorkspaceUI(t *testing.T) { integrationTest(t) - repo := cloud.NewTestRepo() + repo := vcs.NewTestRepo() daemon, org, ctx := setup(t, nil, github.WithRepo(repo), github.WithArchive(testutils.ReadFile(t, "../testdata/github.tar.gz")), diff --git a/internal/module/db.go b/internal/module/db.go index a1936fd41..c7373aab6 100644 --- a/internal/module/db.go +++ b/internal/module/db.go @@ -4,9 +4,8 @@ import ( "context" "sort" - "github.com/google/uuid" "github.com/jackc/pgtype" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/semver" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/sql/pggen" @@ -28,7 +27,6 @@ type ( Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *pggen.RepoConnections `json:"module_connection"` - Webhook *pggen.Webhooks `json:"webhook"` Versions []pggen.ModuleVersions `json:"versions"` } ) @@ -89,8 +87,8 @@ func (db *pgdb) getModuleByID(ctx context.Context, id string) (*Module, error) { return moduleRow(row).toModule(), nil } -func (db *pgdb) getModuleByWebhookID(ctx context.Context, id uuid.UUID) (*Module, error) { - row, err := db.Conn(ctx).FindModuleByWebhookID(ctx, sql.UUID(id)) +func (db *pgdb) getModuleByConnection(ctx context.Context, vcsProviderID, repoPath string) (*Module, error) { + row, err := db.Conn(ctx).FindModuleByConnection(ctx, sql.String(vcsProviderID), sql.String(repoPath)) if err != nil { return nil, sql.Error(err) } @@ -153,7 +151,7 @@ func (db *pgdb) getTarball(ctx context.Context, versionID string) ([]byte, error return tarball, nil } -// UnmarshalModuleRow unmarshals a database row into a module +// toModule converts a database row into a module func (row moduleRow) toModule() *Module { module := &Module{ ID: row.ModuleID.String, @@ -165,9 +163,9 @@ func (row moduleRow) toModule() *Module { Organization: row.OrganizationName.String, } if row.ModuleConnection != nil { - module.Connection = &repo.Connection{ - VCSProviderID: row.Webhook.VCSProviderID.String, - Repo: row.Webhook.Identifier.String, + module.Connection = &connections.Connection{ + VCSProviderID: row.ModuleConnection.VCSProviderID.String, + Repo: row.ModuleConnection.RepoPath.String, } } // versions are always maintained in descending order diff --git a/internal/module/module.go b/internal/module/module.go index 2cbbb68ef..8a5e62e79 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -8,9 +8,9 @@ import ( "log/slog" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/resource" + "github.com/leg100/otf/internal/vcs" ) const ( @@ -39,8 +39,8 @@ type ( Provider string Organization string // Module belongs to an organization Status ModuleStatus - Versions []ModuleVersion // versions sorted in descending order - Connection *repo.Connection // optional vcs repo connection + Versions []ModuleVersion // versions sorted in descending order + Connection *connections.Connection // optional vcs repo connection } ModuleStatus string @@ -67,7 +67,7 @@ type ( Version string Ref string Repo Repo - Client cloud.Client + Client vcs.Client } CreateOptions struct { Name string diff --git a/internal/module/publisher.go b/internal/module/publisher.go index 870e2a769..fc1b3db65 100644 --- a/internal/module/publisher.go +++ b/internal/module/publisher.go @@ -7,9 +7,9 @@ import ( "github.com/go-logr/logr" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/semver" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" ) @@ -23,7 +23,7 @@ type ( } ) -func (p *publisher) handle(event cloud.VCSEvent) { +func (p *publisher) handle(event vcs.Event) { logger := p.Logger.WithValues( "sha", event.CommitSHA, "type", event.Type, @@ -38,24 +38,26 @@ func (p *publisher) handle(event cloud.VCSEvent) { } // handlerWithError publishes a module version in response to a vcs event. -func (p *publisher) handleWithError(logger logr.Logger, event cloud.VCSEvent) error { +func (p *publisher) handleWithError(logger logr.Logger, event vcs.Event) error { // no parent context; handler is called asynchronously ctx := context.Background() // give spawner unlimited powers ctx = internal.AddSubjectToContext(ctx, &internal.Superuser{Username: "run-spawner"}) // only create-tag events trigger the publishing of new module version - if event.Type != cloud.VCSEventTypeTag { + if event.Type != vcs.EventTypeTag { return nil } - if event.Action != cloud.VCSActionCreated { + if event.Action != vcs.ActionCreated { return nil } // only interested in tags that look like semantic versions if !semver.IsValid(event.Tag) { return nil } - module, err := p.GetModuleByRepoID(ctx, event.RepoID) + // TODO: we're only retrieving *one* module, but can not *multiple* modules + // be connected to a repo? + module, err := p.GetModuleByConnection(ctx, event.VCSProviderID, event.RepoPath) if err != nil { return err } diff --git a/internal/module/service.go b/internal/module/service.go index 0042ff52a..7b4d63544 100644 --- a/internal/module/service.go +++ b/internal/module/service.go @@ -6,17 +6,17 @@ import ( "strings" "github.com/go-logr/logr" - "github.com/google/uuid" "github.com/gorilla/mux" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/rbac" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/repohooks" "github.com/leg100/otf/internal/semver" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/sql/pggen" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/surl" ) @@ -33,7 +33,7 @@ type ( ListModules(context.Context, ListModulesOptions) ([]*Module, error) GetModule(ctx context.Context, opts GetModuleOptions) (*Module, error) GetModuleByID(ctx context.Context, id string) (*Module, error) - GetModuleByRepoID(ctx context.Context, repoID uuid.UUID) (*Module, error) + GetModuleByConnection(ctx context.Context, vcsProviderID, repoPath string) (*Module, error) DeleteModule(ctx context.Context, id string) (*Module, error) GetModuleInfo(ctx context.Context, versionID string) (*TerraformModule, error) @@ -47,11 +47,11 @@ type ( service struct { vcsprovider.VCSProviderService + connections.ConnectionService logr.Logger *publisher - db *pgdb - repo repo.Service + db *pgdb organization internal.Authorizer @@ -67,7 +67,10 @@ type ( vcsprovider.VCSProviderService *surl.Signer html.Renderer - repo.RepoService + connections.ConnectionService + repohooks.RepohookService + + VCSEventSubscriber vcs.Subscriber } ) @@ -75,9 +78,9 @@ func NewService(opts Options) *service { svc := service{ Logger: opts.Logger, VCSProviderService: opts.VCSProviderService, + ConnectionService: opts.ConnectionService, organization: &organization.Authorizer{Logger: opts.Logger}, db: &pgdb{opts.DB}, - repo: opts.RepoService, } svc.api = &api{ svc: &svc, @@ -95,7 +98,7 @@ func NewService(opts Options) *service { ModuleService: &svc, } // Subscribe module publisher to incoming vcs events - opts.RepoService.Subscribe(publisher.handle) + opts.VCSEventSubscriber.Subscribe(publisher.handle) return &svc } @@ -146,12 +149,12 @@ func (s *service) publishModule(ctx context.Context, organization string, opts P } var ( - client cloud.Client + client vcs.Client tags []string ) setup := func() (err error) { - mod.Connection, err = s.repo.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.ModuleConnection, + mod.Connection, err = s.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.ModuleConnection, ResourceID: mod.ID, VCSProviderID: opts.VCSProviderID, RepoPath: string(opts.Repo), @@ -163,7 +166,7 @@ func (s *service) publishModule(ctx context.Context, organization string, opts P if err != nil { return err } - tags, err = client.ListTags(ctx, cloud.ListTagsOptions{ + tags, err = client.ListTags(ctx, vcs.ListTagsOptions{ Repo: string(opts.Repo), }) if err != nil { @@ -217,7 +220,7 @@ func (s *service) PublishVersion(ctx context.Context, opts PublishVersionOptions return err } - tarball, _, err := opts.Client.GetRepoTarball(ctx, cloud.GetRepoTarballOptions{ + tarball, _, err := opts.Client.GetRepoTarball(ctx, vcs.GetRepoTarballOptions{ Repo: string(opts.Repo), Ref: &opts.Ref, }) @@ -295,8 +298,8 @@ func (s *service) GetModuleByID(ctx context.Context, id string) (*Module, error) return module, nil } -func (s *service) GetModuleByRepoID(ctx context.Context, id uuid.UUID) (*Module, error) { - return s.db.getModuleByWebhookID(ctx, id) +func (s *service) GetModuleByConnection(ctx context.Context, vcsProviderID, repoPath string) (*Module, error) { + return s.db.getModuleByConnection(ctx, vcsProviderID, repoPath) } func (s *service) DeleteModule(ctx context.Context, id string) (*Module, error) { @@ -314,8 +317,8 @@ func (s *service) DeleteModule(ctx context.Context, id string) (*Module, error) err = s.db.Tx(ctx, func(ctx context.Context, _ pggen.Querier) error { // disconnect module prior to deletion if module.Connection != nil { - err := s.repo.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.ModuleConnection, + err := s.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.ModuleConnection, ResourceID: module.ID, }) if err != nil { diff --git a/internal/module/web.go b/internal/module/web.go index 14b6bf71c..4acb67505 100644 --- a/internal/module/web.go +++ b/internal/module/web.go @@ -8,13 +8,13 @@ import ( "github.com/gorilla/mux" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/decode" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/rbac" "github.com/leg100/otf/internal/resource" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" ) @@ -207,7 +207,7 @@ func (h *webHandlers) newModuleRepo(w http.ResponseWriter, r *http.Request) { // Retrieve repos and filter according to required naming format // '--' - results, err := client.ListRepositories(r.Context(), cloud.ListRepositoriesOptions{ + results, err := client.ListRepositories(r.Context(), vcs.ListRepositoriesOptions{ PageSize: resource.MaxPageSize, }) if err != nil { diff --git a/internal/module/web_test.go b/internal/module/web_test.go index 0d2c9f819..c3ad87765 100644 --- a/internal/module/web_test.go +++ b/internal/module/web_test.go @@ -8,10 +8,10 @@ import ( "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/http/html/paths" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,7 +59,7 @@ func TestGetModule(t *testing.T) { { name: "setup complete", mod: Module{ - Connection: &repo.Connection{}, + Connection: &connections.Connection{}, Status: ModuleStatusSetupComplete, Versions: []ModuleVersion{{Version: "1.0.0"}}, }, @@ -99,8 +99,8 @@ func TestNewModule_Repo(t *testing.T) { h := newTestWebHandlers(t, withVCSProviders(&vcsprovider.VCSProvider{}), withRepos( - cloud.NewTestModuleRepo("aws", "vpc"), - cloud.NewTestModuleRepo("aws", "s3"), + vcs.NewTestModuleRepo("aws", "vpc"), + vcs.NewTestModuleRepo("aws", "s3"), ), ) @@ -241,7 +241,7 @@ func (f *fakeWebServices) ListVCSProviders(context.Context, string) ([]*vcsprovi return f.vcsprovs, nil } -func (f *fakeWebServices) GetVCSClient(ctx context.Context, providerID string) (cloud.Client, error) { +func (f *fakeWebServices) GetVCSClient(ctx context.Context, providerID string) (vcs.Client, error) { return &fakeModulesCloudClient{repos: f.repos}, nil } @@ -256,9 +256,9 @@ func (f *fakeWebServices) Hostname() string { type fakeModulesCloudClient struct { repos []string - cloud.Client + vcs.Client } -func (f *fakeModulesCloudClient) ListRepositories(ctx context.Context, opts cloud.ListRepositoriesOptions) ([]string, error) { +func (f *fakeModulesCloudClient) ListRepositories(ctx context.Context, opts vcs.ListRepositoriesOptions) ([]string, error) { return f.repos, nil } diff --git a/internal/pubsub/converter.go b/internal/pubsub/converter.go index f35556cc1..465c13949 100644 --- a/internal/pubsub/converter.go +++ b/internal/pubsub/converter.go @@ -22,8 +22,8 @@ type ( GetterFunc func(context.Context, string, DBAction) (any, error) ) -func (f GetterFunc) GetByID(ctx context.Context, workspaceID string, action DBAction) (any, error) { - return f(ctx, workspaceID, action) +func (f GetterFunc) GetByID(ctx context.Context, id string, action DBAction) (any, error) { + return f(ctx, id, action) } func newConverter() *converter { diff --git a/internal/rbac/action.go b/internal/rbac/action.go index 2eb4e2b2f..e1cb4b4fa 100644 --- a/internal/rbac/action.go +++ b/internal/rbac/action.go @@ -128,4 +128,12 @@ const ( ListNotificationConfigurationsAction GetNotificationConfigurationAction DeleteNotificationConfigurationAction + + CreateGithubAppAction + UpdateGithubAppAction + GetGithubAppAction + ListGithubAppsAction + DeleteGithubAppAction + CreateGithubAppInstallAction + DeleteGithubAppInstallAction ) diff --git a/internal/rbac/action_string.go b/internal/rbac/action_string.go index 60c0ae894..f832cc22f 100644 --- a/internal/rbac/action_string.go +++ b/internal/rbac/action_string.go @@ -110,11 +110,18 @@ func _() { _ = x[ListNotificationConfigurationsAction-99] _ = x[GetNotificationConfigurationAction-100] _ = x[DeleteNotificationConfigurationAction-101] + _ = x[CreateGithubAppAction-102] + _ = x[UpdateGithubAppAction-103] + _ = x[GetGithubAppAction-104] + _ = x[ListGithubAppsAction-105] + _ = x[DeleteGithubAppAction-106] + _ = x[CreateGithubAppInstallAction-107] + _ = x[DeleteGithubAppInstallAction-108] } -const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateOrganizationTokenActionDeleteOrganizationTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateWorkspaceVariableActionUpdateWorkspaceVariableActionListWorkspaceVariablesActionGetWorkspaceVariableActionDeleteWorkspaceVariableActionCreateVariableSetActionUpdateVariableSetActionListVariableSetsActionGetVariableSetActionDeleteVariableSetActionCreateVariableSetVariableActionUpdateVariableSetVariableActionGetVariableSetVariableActionDeleteVariableSetVariableActionAddVariableToSetActionRemoveVariableFromSetActionApplyVariableSetToWorkspacesActionDeleteVariableSetFromWorkspacesActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionListTagsActionDeleteTagsActionTagWorkspacesActionAddTagsActionRemoveTagsActionListWorkspaceTagsLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipActionCreateNotificationConfigurationActionUpdateNotificationConfigurationActionListNotificationConfigurationsActionGetNotificationConfigurationActionDeleteNotificationConfigurationAction" +const _Action_name = "WatchActionCreateOrganizationActionUpdateOrganizationActionGetOrganizationActionListOrganizationsActionGetEntitlementsActionDeleteOrganizationActionCreateVCSProviderActionGetVCSProviderActionListVCSProvidersActionDeleteVCSProviderActionCreateAgentTokenActionListAgentTokensActionDeleteAgentTokenActionCreateOrganizationTokenActionDeleteOrganizationTokenActionCreateRunTokenActionCreateModuleActionCreateModuleVersionActionUpdateModuleActionListModulesActionGetModuleActionDeleteModuleActionDeleteModuleVersionActionCreateWorkspaceVariableActionUpdateWorkspaceVariableActionListWorkspaceVariablesActionGetWorkspaceVariableActionDeleteWorkspaceVariableActionCreateVariableSetActionUpdateVariableSetActionListVariableSetsActionGetVariableSetActionDeleteVariableSetActionCreateVariableSetVariableActionUpdateVariableSetVariableActionGetVariableSetVariableActionDeleteVariableSetVariableActionAddVariableToSetActionRemoveVariableFromSetActionApplyVariableSetToWorkspacesActionDeleteVariableSetFromWorkspacesActionGetRunActionListRunsActionApplyRunActionCreateRunActionDiscardRunActionDeleteRunActionCancelRunActionEnqueuePlanActionStartPhaseActionFinishPhaseActionPutChunkActionTailLogsActionGetPlanFileActionUploadPlanFileActionGetLockFileActionUploadLockFileActionListWorkspacesActionGetWorkspaceActionCreateWorkspaceActionDeleteWorkspaceActionSetWorkspacePermissionActionUnsetWorkspacePermissionActionUpdateWorkspaceActionListTagsActionDeleteTagsActionTagWorkspacesActionAddTagsActionRemoveTagsActionListWorkspaceTagsLockWorkspaceActionUnlockWorkspaceActionForceUnlockWorkspaceActionCreateStateVersionActionListStateVersionsActionGetStateVersionActionDeleteStateVersionActionRollbackStateVersionActionDownloadStateActionGetStateVersionOutputActionCreateConfigurationVersionActionListConfigurationVersionsActionGetConfigurationVersionActionDownloadConfigurationVersionActionDeleteConfigurationVersionActionCreateUserActionListUsersActionGetUserActionDeleteUserActionCreateTeamActionUpdateTeamActionGetTeamActionListTeamsActionDeleteTeamActionAddTeamMembershipActionRemoveTeamMembershipActionCreateNotificationConfigurationActionUpdateNotificationConfigurationActionListNotificationConfigurationsActionGetNotificationConfigurationActionDeleteNotificationConfigurationActionCreateGithubAppActionUpdateGithubAppActionGetGithubAppActionListGithubAppsActionDeleteGithubAppActionCreateGithubAppInstallActionDeleteGithubAppInstallAction" -var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 330, 359, 379, 397, 422, 440, 457, 472, 490, 515, 544, 573, 601, 627, 656, 679, 702, 724, 744, 767, 798, 829, 857, 888, 910, 937, 971, 1008, 1020, 1034, 1048, 1063, 1079, 1094, 1109, 1126, 1142, 1159, 1173, 1187, 1204, 1224, 1241, 1261, 1281, 1299, 1320, 1341, 1369, 1399, 1420, 1434, 1450, 1469, 1482, 1498, 1515, 1534, 1555, 1581, 1605, 1628, 1649, 1673, 1699, 1718, 1745, 1777, 1808, 1837, 1871, 1903, 1919, 1934, 1947, 1963, 1979, 1995, 2008, 2023, 2039, 2062, 2088, 2125, 2162, 2198, 2232, 2269} +var _Action_index = [...]uint16{0, 11, 35, 59, 80, 103, 124, 148, 171, 191, 213, 236, 258, 279, 301, 330, 359, 379, 397, 422, 440, 457, 472, 490, 515, 544, 573, 601, 627, 656, 679, 702, 724, 744, 767, 798, 829, 857, 888, 910, 937, 971, 1008, 1020, 1034, 1048, 1063, 1079, 1094, 1109, 1126, 1142, 1159, 1173, 1187, 1204, 1224, 1241, 1261, 1281, 1299, 1320, 1341, 1369, 1399, 1420, 1434, 1450, 1469, 1482, 1498, 1515, 1534, 1555, 1581, 1605, 1628, 1649, 1673, 1699, 1718, 1745, 1777, 1808, 1837, 1871, 1903, 1919, 1934, 1947, 1963, 1979, 1995, 2008, 2023, 2039, 2062, 2088, 2125, 2162, 2198, 2232, 2269, 2290, 2311, 2329, 2349, 2370, 2398, 2426} func (i Action) String() string { if i < 0 || i >= Action(len(_Action_index)-1) { diff --git a/internal/releases/downloader_test.go b/internal/releases/downloader_test.go index 5c6ccae2c..d4dceb6f5 100644 --- a/internal/releases/downloader_test.go +++ b/internal/releases/downloader_test.go @@ -27,7 +27,7 @@ func TestDownloader(t *testing.T) { dl := NewDownloader(t.TempDir()) dl.host = u.Host dl.client = &http.Client{ - Transport: otfhttp.DefaultTransport(true), + Transport: otfhttp.InsecureTransport, } buf := new(bytes.Buffer) diff --git a/internal/releases/releases.go b/internal/releases/releases.go index 9d757e621..bdc268cf8 100644 --- a/internal/releases/releases.go +++ b/internal/releases/releases.go @@ -80,7 +80,7 @@ func (s *service) StartLatestChecker(ctx context.Context) { return nil } // perform sanity check - if n := semver.Compare(after, before); n <= 0 { + if n := semver.Compare(after, before); n < 0 { return fmt.Errorf("endpoint returned older version: before: %s; after: %s", before, after) } // update db (even if version hasn't changed we need to update the diff --git a/internal/repo/factory.go b/internal/repo/factory.go deleted file mode 100644 index 0767cc171..000000000 --- a/internal/repo/factory.go +++ /dev/null @@ -1,82 +0,0 @@ -package repo - -import ( - "fmt" - "net/url" - "path" - - "github.com/google/uuid" - "github.com/jackc/pgtype" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" -) - -type ( - factory struct { - cloud.Service - internal.HostnameService - } - - newHookOptions struct { - id *uuid.UUID - vcsProviderID string - secret *string - identifier string - cloud string // cloud name - cloudID *string // cloud's webhook id - } -) - -// fromRow creates a hook from a database row -func (f factory) fromRow(row hookRow) (*hook, error) { - opts := newHookOptions{ - id: internal.UUID(row.WebhookID.Bytes), - vcsProviderID: row.VCSProviderID.String, - secret: internal.String(row.Secret.String), - identifier: row.Identifier.String, - cloud: row.Cloud.String, - } - if row.VCSID.Status == pgtype.Present { - opts.cloudID = internal.String(row.VCSID.String) - } - return f.newHook(opts) -} - -func (f factory) newHook(opts newHookOptions) (*hook, error) { - cloudConfig, err := f.GetCloudConfig(opts.cloud) - if err != nil { - return nil, fmt.Errorf("unknown cloud: %s", opts.cloud) - } - - hook := hook{ - identifier: opts.identifier, - cloud: opts.cloud, - EventHandler: cloudConfig.Cloud, - cloudID: opts.cloudID, - vcsProviderID: opts.vcsProviderID, - } - - if opts.id != nil { - hook.id = *opts.id - } else { - hook.id = uuid.New() - } - - if opts.secret != nil { - hook.secret = *opts.secret - } else { - secret, err := internal.GenerateToken() - if err != nil { - return nil, err - } - hook.secret = secret - } - - hook.endpoint = (&url.URL{ - Scheme: "https", - Host: f.Hostname(), - Path: path.Join(handlerPrefix, hook.id.String()), - }).String() - - return &hook, nil -} diff --git a/internal/repo/factory_test.go b/internal/repo/factory_test.go deleted file mode 100644 index ff2ba3c51..000000000 --- a/internal/repo/factory_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package repo - -import ( - "testing" - - "github.com/google/uuid" - "github.com/leg100/otf/internal" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFactory(t *testing.T) { - id := uuid.New() - - tests := []struct { - name string - hostname string - opts newHookOptions - want *hook - }{ - { - name: "default", - hostname: "fakehost.org", - opts: newHookOptions{ - id: &id, - cloud: "fakecloud", - secret: internal.String("top-secret"), - }, - want: &hook{ - id: id, - secret: "top-secret", - cloud: "fakecloud", - endpoint: "https://fakehost.org/webhooks/vcs/" + id.String(), - EventHandler: &fakeCloud{}, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - f := factory{ - HostnameService: fakeHostnameService{hostname: tt.hostname}, - Service: fakeCloudService{}, - } - got, err := f.newHook(tt.opts) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/repo/handler.go b/internal/repo/handler.go deleted file mode 100644 index f08ad4fda..000000000 --- a/internal/repo/handler.go +++ /dev/null @@ -1,66 +0,0 @@ -package repo - -import ( - "context" - "net/http" - "path" - - "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/gorilla/mux" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/http/decode" -) - -// handlerPrefix is the URL path prefix for the endpoint receiving vcs events -const handlerPrefix = "/webhooks/vcs" - -type ( - // handler is the first point of entry for incoming VCS events, relaying them onto - // a cloud-specific handler. - handler struct { - logr.Logger - - handlerBroker - handlerDB - } - - // handleDB is the database the handler interacts with - handlerDB interface { - getHookByID(context.Context, uuid.UUID) (*hook, error) - } - handlerBroker interface { - publish(cloud.VCSEvent) - } -) - -func (h *handler) AddHandlers(r *mux.Router) { - r.Handle(path.Join(handlerPrefix, "{webhook_id}"), h) -} - -func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - opts := struct { - ID uuid.UUID `schema:"webhook_id,required"` - }{} - if err := decode.All(&opts, r); err != nil { - http.Error(w, err.Error(), http.StatusUnprocessableEntity) - return - } - - hook, err := h.getHookByID(r.Context(), opts.ID) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - h.V(2).Info("received vcs event", "id", opts.ID, "repo", hook.identifier, "cloud", hook.cloud) - - event := hook.HandleEvent(w, r, hook.secret) - if event != nil { - // add non-cloud specific info to event before publishing - event.RepoID = hook.id - event.RepoPath = hook.identifier - event.VCSProviderID = hook.vcsProviderID - - h.publish(*event) - } -} diff --git a/internal/repo/handler_test.go b/internal/repo/handler_test.go deleted file mode 100644 index 3e76e7802..000000000 --- a/internal/repo/handler_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package repo - -import ( - "context" - "net/http/httptest" - "testing" - - "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/stretchr/testify/assert" -) - -func TestWebhookHandler(t *testing.T) { - broker := &fakeBroker{} - f := newTestFactory(t, cloud.VCSEvent{}) - hook := newTestHook(t, f, "vcs-123", internal.String("123")) - want := cloud.VCSEvent{RepoID: hook.id, VCSProviderID: "vcs-123", RepoPath: hook.identifier} - handler := handler{ - Logger: logr.Discard(), - handlerBroker: broker, - handlerDB: &fakeHandlerDB{hook: hook}, - } - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/?webhook_id=158c758a-7090-11ed-a843-d398c839c7ad", nil) - handler.ServeHTTP(w, r) - assert.Equal(t, 200, w.Code) - - assert.Equal(t, want, broker.got) -} - -type ( - fakeHandlerDB struct { - hook *hook - } - fakeBroker struct { - got cloud.VCSEvent - } -) - -func (db *fakeHandlerDB) getHookByID(context.Context, uuid.UUID) (*hook, error) { - return db.hook, nil -} - -func (f *fakeBroker) publish(got cloud.VCSEvent) { f.got = got } diff --git a/internal/repo/hook.go b/internal/repo/hook.go deleted file mode 100644 index b3f6ea68b..000000000 --- a/internal/repo/hook.go +++ /dev/null @@ -1,42 +0,0 @@ -package repo - -import ( - "log/slog" - - "github.com/google/uuid" - "github.com/leg100/otf/internal/cloud" -) - -// defaultEvents are the VCS events that hooks subscribe to. -var defaultEvents = []cloud.VCSEventType{ - cloud.VCSEventTypePush, - cloud.VCSEventTypePull, -} - -// hook is a webhook for a VCS repo -type hook struct { - id uuid.UUID // internal otf ID - cloudID *string // cloud's hook ID; populated following synchronisation - vcsProviderID string - - secret string // secret token - identifier string // repo identifier: / - cloud string // cloud name - endpoint string // otf URL that receives events - - cloud.EventHandler // handles incoming vcs events -} - -func (h *hook) LogValue() slog.Value { - attrs := []slog.Attr{ - slog.String("id", h.id.String()), - slog.String("vcs_provider_id", h.vcsProviderID), - slog.String("cloud", h.cloud), - slog.String("repo", h.identifier), - slog.String("endpoint", h.endpoint), - } - if h.cloudID != nil { - attrs = append(attrs, slog.String("vcs_id", *h.cloudID)) - } - return slog.GroupValue(attrs...) -} diff --git a/internal/repo/purger.go b/internal/repo/purger.go deleted file mode 100644 index d3649a73a..000000000 --- a/internal/repo/purger.go +++ /dev/null @@ -1,92 +0,0 @@ -package repo - -import ( - "context" - - "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/jackc/pgx/v4" - "github.com/leg100/otf/internal/pubsub" - "github.com/leg100/otf/internal/sql/pggen" -) - -// PurgerLockID is a unique ID guaranteeing only one purger on a cluster is running at any time. -const PurgerLockID int64 = 179366396344335598 - -type ( - // Purge purges webhooks that are no longer in use. - Purger struct { - DB purgerDB - - logr.Logger - pubsub.Subscriber - Service - } - - purgerDB interface { - QueryRow(ctx context.Context, sql string, args ...any) pgx.Row - Tx(ctx context.Context, callback func(context.Context, pggen.Querier) error) error - } - - repoConnectionEvent struct { - webhookID uuid.UUID - } -) - -// Start starts the purger daemon. Should be invoked in a go routine. -func (p *Purger) Start(ctx context.Context) error { - // Unsubscribe whenever exiting this routine. - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - // subscribe to webhook database events - sub, err := p.Subscriber.Subscribe(ctx, "purger-") - if err != nil { - return err - } - - // deleted unreferenced webhooks at startup in case: - // (a) any unreferenced webhooks exist prior to the introduction of this - // purger - // (b) an error occured between the database sending an event and the purger - // acting on the event (in which the case the purger should have restarted - // and this will be re-run). - if err := p.deleteUnreferencedWebhooks(ctx); err != nil { - return err - } - - for event := range sub { - _, ok := event.Payload.(*repoConnectionEvent) - if !ok { - continue - } - if event.Type != pubsub.DeletedEvent { - continue - } - - // only repo connection deletion events reach this point - - if err := p.deleteUnreferencedWebhooks(ctx); err != nil { - return err - } - } - return pubsub.ErrSubscriptionTerminated -} - -func (p *Purger) deleteUnreferencedWebhooks(ctx context.Context) error { - // Advisory lock ensures only one purger deletes the webhook from the cloud - // provider. - return p.DB.Tx(ctx, func(ctx context.Context, q pggen.Querier) error { - var locked bool - err := p.DB.QueryRow(ctx, "SELECT pg_try_advisory_xact_lock($1)", PurgerLockID).Scan(&locked) - if err != nil { - return err - } - if !locked { - // Another purger obtained the lock first - return nil - } - - return p.Service.deleteUnreferencedWebhooks(ctx) - }) -} diff --git a/internal/repo/repo.go b/internal/repo/repo.go deleted file mode 100644 index a29f19d7f..000000000 --- a/internal/repo/repo.go +++ /dev/null @@ -1,37 +0,0 @@ -// Package repo handles configuration of VCS repositories. -package repo - -const ( - WorkspaceConnection ConnectionType = iota - ModuleConnection -) - -type ( - // ConnectionType identifies the OTF resource type in a VCS connection. - ConnectionType int - - // Connection is a connection between a VCS repo and an OTF resource. - Connection struct { - VCSProviderID string - Repo string - } - - ConnectOptions struct { - ConnectionType // OTF resource type - - VCSProviderID string // vcs provider of repo - ResourceID string // ID of OTF resource - RepoPath string - } - - DisconnectOptions struct { - ConnectionType // OTF resource type - - ResourceID string // ID of OTF resource - } - - SynchroniseOptions struct { - VCSProviderID string // vcs provider of repo - RepoPath string - } -) diff --git a/internal/repo/service.go b/internal/repo/service.go deleted file mode 100644 index c5aa078cb..000000000 --- a/internal/repo/service.go +++ /dev/null @@ -1,232 +0,0 @@ -package repo - -import ( - "context" - "fmt" - - "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/leg100/otf/internal/organization" - "github.com/leg100/otf/internal/pubsub" - "github.com/leg100/otf/internal/sql" - "github.com/leg100/otf/internal/sql/pggen" - "github.com/leg100/otf/internal/vcsprovider" -) - -type ( - RepoService = Service - - // Service manages VCS repositories - Service interface { - // Connect adds a connection between a VCS repo and an OTF resource. A - // webhook is created if one doesn't exist already. - Connect(ctx context.Context, opts ConnectOptions) (*Connection, error) - - // Disconnect removes a connection between a VCS repo and an OTF - // resource. If there are no more connections then its - // webhook is removed. - Disconnect(ctx context.Context, opts DisconnectOptions) error - - // Subscribe to incoming VCS events - Subscribe(cb Callback) - - deleteUnreferencedWebhooks(ctx context.Context) error - } - - service struct { - logr.Logger - vcsprovider.Service - - *db - - *handler // handles incoming vcs events - factory // produce new hooks - *synchroniser // synchronise hooks - *broker // relay VCS events to subscribers - } - - Options struct { - logr.Logger - - CloudService cloud.Service - - *sql.DB - *pubsub.Broker - internal.HostnameService - VCSProviderService vcsprovider.Service - organization.OrganizationService - } -) - -func NewService(ctx context.Context, opts Options) *service { - factory := factory{ - HostnameService: opts.HostnameService, - Service: opts.CloudService, - } - db := &db{opts.DB, factory} - broker := &broker{} - handler := &handler{ - Logger: opts.Logger, - handlerDB: db, - handlerBroker: broker, - } - svc := &service{ - Logger: opts.Logger, - Service: opts.VCSProviderService, - db: db, - factory: factory, - handler: handler, - synchroniser: &synchroniser{Logger: opts.Logger, syncdb: db}, - broker: broker, - } - - // Delete webhooks prior to the deletion of VCS providers. VCS providers are - // necessary for the deletion of webhooks from VCS repos. Hence we need to - // first delete webhooks that reference the VCS provider before the VCS - // provider is deleted. - opts.VCSProviderService.BeforeDeleteVCSProvider(svc.deleteProviderWebhooks) - // Delete webhooks prior to the deletion of organizations. Deleting - // organizations cascades deletion of VCS providers (see above). - opts.OrganizationService.BeforeDeleteOrganization(svc.deleteOrganizationWebhooks) - - // Register with broker - when a repo connection is deleted in postgres, a - // postgres trigger sends a message to the broker, which calls this function - // to convert the message into an event. The purger subsystem then uses this - // event to delete the corresponding webhook if it is no longer in use. - deleteEventFunc := func(ctx context.Context, rawWebhookID string, action pubsub.DBAction) (any, error) { - webhookID, err := uuid.Parse(rawWebhookID) - if err != nil { - return nil, err - } - if action != pubsub.DeleteDBAction { - return nil, fmt.Errorf("trigger not registered for action: %s", action) - } - return &repoConnectionEvent{webhookID: webhookID}, nil - } - opts.Register("repo_connections", pubsub.GetterFunc(deleteEventFunc)) - - return svc -} - -// Connect an OTF resource to a VCS repo. -func (s *service) Connect(ctx context.Context, opts ConnectOptions) (*Connection, error) { - vcsProvider, err := s.GetVCSProvider(ctx, opts.VCSProviderID) - if err != nil { - return nil, fmt.Errorf("retrieving vcs provider: %w", err) - } - client, err := s.GetVCSClient(ctx, opts.VCSProviderID) - if err != nil { - return nil, fmt.Errorf("retrieving vcs client: %w", err) - } - _, err = client.GetRepository(ctx, opts.RepoPath) - if err != nil { - return nil, fmt.Errorf("checking repository exists: %w", err) - } - - hook, err := s.newHook(newHookOptions{ - identifier: opts.RepoPath, - cloud: vcsProvider.CloudConfig.Name, - vcsProviderID: vcsProvider.ID, - }) - if err != nil { - return nil, fmt.Errorf("constructing webhook: %w", err) - } - - // lock webhooks table to prevent concurrent updates (a row-level lock is - // insufficient) - err = s.db.Lock(ctx, "webhooks", func(ctx context.Context, q pggen.Querier) error { - hook, err = s.db.getOrCreateHook(ctx, hook) - if err != nil { - return fmt.Errorf("getting or creating webhook: %w", err) - } - if err := s.sync(ctx, client, hook); err != nil { - return fmt.Errorf("synchronising webhook: %w", err) - } - return s.db.createConnection(ctx, hook.id, opts) - }) - if err != nil { - return nil, err - } - return &Connection{ - Repo: opts.RepoPath, - VCSProviderID: opts.VCSProviderID, - }, nil -} - -// Disconnect resource from repo -func (s *service) Disconnect(ctx context.Context, opts DisconnectOptions) error { - return s.db.deleteConnection(ctx, opts) -} - -func (s *service) deleteOrganizationWebhooks(ctx context.Context, org *organization.Organization) error { - providers, err := s.ListVCSProviders(ctx, org.Name) - if err != nil { - return err - } - hooks, err := s.db.listHooks(ctx) - if err != nil { - return err - } - for _, p := range providers { - for _, h := range hooks { - if h.vcsProviderID == p.ID { - if err := s.deleteWebhook(ctx, h); err != nil { - return err - } - } - } - } - return nil -} - -func (s *service) deleteProviderWebhooks(ctx context.Context, provider *vcsprovider.VCSProvider) error { - hooks, err := s.db.listHooks(ctx) - if err != nil { - return err - } - for _, h := range hooks { - if h.vcsProviderID == provider.ID { - if err := s.deleteWebhook(ctx, h); err != nil { - return err - } - } - } - return nil -} - -func (s *service) deleteUnreferencedWebhooks(ctx context.Context) error { - hooks, err := s.db.listUnreferencedWebhooks(ctx) - if err != nil { - return fmt.Errorf("listing unreferenced webhooks: %w", err) - } - for _, h := range hooks { - if err := s.deleteWebhook(ctx, h); err != nil { - return err - } - } - return nil -} - -func (s *service) deleteWebhook(ctx context.Context, webhook *hook) error { - if err := s.db.deleteHook(ctx, webhook.id); err != nil { - return fmt.Errorf("deleting webhook from db: %w", err) - } - client, err := s.GetVCSClient(ctx, webhook.vcsProviderID) - if err != nil { - return fmt.Errorf("retrieving vcs client from db: %w", err) - } - err = client.DeleteWebhook(ctx, cloud.DeleteWebhookOptions{ - Repo: webhook.identifier, - ID: *webhook.cloudID, - }) - if err != nil { - s.Error(err, "deleting webhook", "repo", webhook.identifier, "cloud", webhook.cloud) - } else { - s.V(0).Info("deleted webhook", "repo", webhook.identifier, "cloud", webhook.cloud) - } - // Failure to delete the webhook from the cloud provider is not deemed a - // fatal error. - return nil -} diff --git a/internal/repo/test_helpers.go b/internal/repo/test_helpers.go deleted file mode 100644 index bad7beaa8..000000000 --- a/internal/repo/test_helpers.go +++ /dev/null @@ -1,90 +0,0 @@ -package repo - -import ( - "context" - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" - "github.com/stretchr/testify/require" -) - -type ( - fakeCloudService struct { - event cloud.VCSEvent - cloud.Service - } - fakeCloud struct { - event cloud.VCSEvent - - cloud.Cloud - } - fakeHostnameService struct { - hostname string - - internal.HostnameService - } - fakeCloudClient struct { - hook cloud.Webhook // seed cloud with hook - gotUpdate bool - - cloud.Client - } - fakeDB struct { - hook *hook - } -) - -func newTestHook(t *testing.T, f factory, vcsProviderID string, cloudID *string) *hook { - want, err := f.newHook(newHookOptions{ - id: internal.UUID(uuid.New()), - vcsProviderID: vcsProviderID, - secret: internal.String("top-secret"), - identifier: "leg100/" + uuid.NewString(), - cloud: "github", - cloudID: cloudID, - }) - require.NoError(t, err) - return want -} - -func newTestFactory(t *testing.T, event cloud.VCSEvent) factory { - return factory{ - HostnameService: fakeHostnameService{}, - Service: fakeCloudService{event: event}, - } -} - -func (f fakeCloudService) GetCloudConfig(string) (cloud.Config, error) { - return cloud.Config{Cloud: &fakeCloud{event: f.event}}, nil -} - -func (f *fakeCloud) HandleEvent(http.ResponseWriter, *http.Request, string) *cloud.VCSEvent { - return &f.event -} - -func (f fakeHostnameService) Hostname() string { return f.hostname } - -func (f *fakeCloudClient) CreateWebhook(context.Context, cloud.CreateWebhookOptions) (string, error) { - return f.hook.ID, nil -} - -func (f *fakeCloudClient) GetWebhook(ctx context.Context, opts cloud.GetWebhookOptions) (cloud.Webhook, error) { - if f.hook.ID == opts.ID { - return f.hook, nil - } - return cloud.Webhook{}, internal.ErrResourceNotFound -} - -func (f *fakeCloudClient) UpdateWebhook(context.Context, string, cloud.UpdateWebhookOptions) error { - f.gotUpdate = true - - return nil -} - -func (f *fakeDB) updateHookCloudID(ctx context.Context, id uuid.UUID, cloudID string) error { - f.hook.cloudID = &cloudID - return nil -} diff --git a/internal/repo/db.go b/internal/repohooks/db.go similarity index 55% rename from internal/repo/db.go rename to internal/repohooks/db.go index d7ac0173b..00adc69b0 100644 --- a/internal/repo/db.go +++ b/internal/repohooks/db.go @@ -1,4 +1,4 @@ -package repo +package repohooks import ( "context" @@ -6,24 +6,26 @@ import ( "github.com/google/uuid" "github.com/jackc/pgtype" + "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/sql/pggen" + "github.com/leg100/otf/internal/vcs" ) type ( db struct { *sql.DB - factory + internal.HostnameService } hookRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` + RepohookID pgtype.UUID `json:"repohook_id"` VCSID pgtype.Text `json:"vcs_id"` VCSProviderID pgtype.Text `json:"vcs_provider_id"` Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` } ) @@ -39,11 +41,11 @@ func (db *db) GetByID(ctx context.Context, rawID string, action pubsub.DBAction) return db.getHookByID(ctx, id) } -// getOrCreate gets a hook if it exists or creates it if it does not. Should be +// getOrCreateHook gets a hook if it exists or creates it if it does not. Should be // called within a tx to avoid concurrent access causing unpredictible results. func (db *db) getOrCreateHook(ctx context.Context, hook *hook) (*hook, error) { q := db.Conn(ctx) - result, err := q.FindWebhookByRepoAndProvider(ctx, sql.String(hook.identifier), sql.String(hook.vcsProviderID)) + result, err := q.FindRepohookByRepoAndProvider(ctx, sql.String(hook.repoPath), sql.String(hook.vcsProviderID)) if err != nil { return nil, sql.Error(err) } @@ -53,10 +55,10 @@ func (db *db) getOrCreateHook(ctx context.Context, hook *hook) (*hook, error) { // not found; create instead - insertResult, err := q.InsertWebhook(ctx, pggen.InsertWebhookParams{ - WebhookID: sql.UUID(hook.id), + insertResult, err := q.InsertRepohook(ctx, pggen.InsertRepohookParams{ + RepohookID: sql.UUID(hook.id), Secret: sql.String(hook.secret), - Identifier: sql.String(hook.identifier), + RepoPath: sql.String(hook.repoPath), VCSID: sql.StringPtr(hook.cloudID), VCSProviderID: sql.String(hook.vcsProviderID), }) @@ -68,7 +70,7 @@ func (db *db) getOrCreateHook(ctx context.Context, hook *hook) (*hook, error) { func (db *db) getHookByID(ctx context.Context, id uuid.UUID) (*hook, error) { q := db.Conn(ctx) - result, err := q.FindWebhookByID(ctx, sql.UUID(id)) + result, err := q.FindRepohookByID(ctx, sql.UUID(id)) if err != nil { return nil, sql.Error(err) } @@ -77,7 +79,7 @@ func (db *db) getHookByID(ctx context.Context, id uuid.UUID) (*hook, error) { func (db *db) listHooks(ctx context.Context) ([]*hook, error) { q := db.Conn(ctx) - result, err := q.FindWebhooks(ctx) + result, err := q.FindRepohooks(ctx) if err != nil { return nil, sql.Error(err) } @@ -92,9 +94,9 @@ func (db *db) listHooks(ctx context.Context) ([]*hook, error) { return hooks, nil } -func (db *db) listUnreferencedWebhooks(ctx context.Context) ([]*hook, error) { +func (db *db) listUnreferencedRepohooks(ctx context.Context) ([]*hook, error) { q := db.Conn(ctx) - result, err := q.FindUnreferencedWebhooks(ctx) + result, err := q.FindUnreferencedRepohooks(ctx) if err != nil { return nil, sql.Error(err) } @@ -111,54 +113,34 @@ func (db *db) listUnreferencedWebhooks(ctx context.Context) ([]*hook, error) { func (db *db) updateHookCloudID(ctx context.Context, id uuid.UUID, cloudID string) error { q := db.Conn(ctx) - _, err := q.UpdateWebhookVCSID(ctx, sql.String(cloudID), sql.UUID(id)) + _, err := q.UpdateRepohookVCSID(ctx, sql.String(cloudID), sql.UUID(id)) if err != nil { return sql.Error(err) } return nil } -func (db *db) createConnection(ctx context.Context, hookID uuid.UUID, opts ConnectOptions) error { +func (db *db) deleteHook(ctx context.Context, id uuid.UUID) error { q := db.Conn(ctx) - params := pggen.InsertRepoConnectionParams{ - WebhookID: sql.UUID(hookID), - } - - switch opts.ConnectionType { - case WorkspaceConnection: - params.WorkspaceID = sql.String(opts.ResourceID) - params.ModuleID = pgtype.Text{Status: pgtype.Null} - case ModuleConnection: - params.ModuleID = sql.String(opts.ResourceID) - params.WorkspaceID = pgtype.Text{Status: pgtype.Null} - default: - return fmt.Errorf("unknown connection type: %v", opts.ConnectionType) - } - - if _, err := q.InsertRepoConnection(ctx, params); err != nil { + _, err := q.DeleteRepohookByID(ctx, sql.UUID(id)) + if err != nil { return sql.Error(err) } return nil } -func (db *db) deleteConnection(ctx context.Context, opts DisconnectOptions) (err error) { - q := db.Conn(ctx) - switch opts.ConnectionType { - case WorkspaceConnection: - _, err = q.DeleteWorkspaceConnectionByID(ctx, sql.String(opts.ResourceID)) - case ModuleConnection: - _, err = q.DeleteModuleConnectionByID(ctx, sql.String(opts.ResourceID)) - default: - return fmt.Errorf("unknown connection type: %v", opts.ConnectionType) - } - return err -} - -func (db *db) deleteHook(ctx context.Context, id uuid.UUID) error { - q := db.Conn(ctx) - _, err := q.DeleteWebhookByID(ctx, sql.UUID(id)) - if err != nil { - return sql.Error(err) +// fromRow creates a hook from a database row +func (db *db) fromRow(row hookRow) (*hook, error) { + opts := newRepohookOptions{ + id: internal.UUID(row.RepohookID.Bytes), + vcsProviderID: row.VCSProviderID.String, + secret: internal.String(row.Secret.String), + repoPath: row.RepoPath.String, + cloud: vcs.Kind(row.VCSKind.String), + HostnameService: db.HostnameService, } - return nil + if row.VCSID.Status == pgtype.Present { + opts.cloudID = internal.String(row.VCSID.String) + } + return newRepohook(opts) } diff --git a/internal/repohooks/handlers.go b/internal/repohooks/handlers.go new file mode 100644 index 000000000..bd82cc548 --- /dev/null +++ b/internal/repohooks/handlers.go @@ -0,0 +1,84 @@ +package repohooks + +import ( + "context" + "net/http" + "path" + + "github.com/go-logr/logr" + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/http/decode" + "github.com/leg100/otf/internal/vcs" +) + +const ( + // handlerPrefix is the URL path prefix for endpoints receiving vcs events + handlerPrefix = "/webhooks/vcs" +) + +type ( + // handlers handle VCS events triggered by webhooks + handlers struct { + logr.Logger + vcs.Publisher + + cloudHandlers *internal.SafeMap[vcs.Kind, EventUnmarshaler] + + handlerDB + } + + // EventUnmarshaler does two things: + // (a) handles incoming request (containing a VCS event) and sends appropriate response + // (b) unmarshals event from the request; if the event is irrelevant or + // invalid then nil is returned. + EventUnmarshaler func(w http.ResponseWriter, r *http.Request, secret string) *vcs.EventPayload + + // handleDB is the database the handler interacts with + handlerDB interface { + getHookByID(context.Context, uuid.UUID) (*hook, error) + } +) + +func newHandler(logger logr.Logger, publisher vcs.Publisher, db handlerDB) *handlers { + return &handlers{ + Logger: logger, + Publisher: publisher, + handlerDB: db, + cloudHandlers: internal.NewSafeMap[vcs.Kind, EventUnmarshaler](), + } +} + +func (h *handlers) AddHandlers(r *mux.Router) { + r.HandleFunc(path.Join(handlerPrefix, "{webhook_id}"), h.repohookHandler) +} + +func (h *handlers) repohookHandler(w http.ResponseWriter, r *http.Request) { + var opts struct { + ID uuid.UUID `schema:"webhook_id,required"` + } + if err := decode.All(&opts, r); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + hook, err := h.getHookByID(r.Context(), opts.ID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + h.V(2).Info("received vcs event", "repohook_id", opts.ID, "repo", hook.repoPath, "cloud", hook.cloud) + + cloudHandler, ok := h.cloudHandlers.Get(hook.cloud) + if !ok { + h.Error(nil, "no event unmarshaler found for event", "repohook_id", opts.ID, "repo", hook.repoPath, "cloud", hook.cloud) + http.Error(w, err.Error(), http.StatusNotFound) + return + } + if payload := cloudHandler(w, r, hook.secret); payload != nil { + h.Publish(vcs.Event{ + EventHeader: vcs.EventHeader{VCSProviderID: hook.vcsProviderID}, + EventPayload: *payload, + }) + } +} diff --git a/internal/repohooks/handlers_test.go b/internal/repohooks/handlers_test.go new file mode 100644 index 000000000..48229b8fb --- /dev/null +++ b/internal/repohooks/handlers_test.go @@ -0,0 +1,64 @@ +package repohooks + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-logr/logr" + "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_repohookHandler(t *testing.T) { + hook, err := newRepohook(newRepohookOptions{ + vcsProviderID: "vcs-123", + cloud: vcs.GithubKind, + HostnameService: internal.NewHostnameService("fakehost.org"), + }) + require.NoError(t, err) + + broker := &fakeBroker{} + handler := newHandler( + logr.Discard(), + broker, + &fakeHandlerDB{ + hook: hook, + }, + ) + handler.cloudHandlers.Set(vcs.GithubKind, func(http.ResponseWriter, *http.Request, string) *vcs.EventPayload { + return &vcs.EventPayload{} + }) + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/?webhook_id=158c758a-7090-11ed-a843-d398c839c7ad", nil) + handler.repohookHandler(w, r) + assert.Equal(t, 200, w.Code, "response body: %s", w.Body.String()) + + want := vcs.Event{ + EventHeader: vcs.EventHeader{ + VCSProviderID: "vcs-123", + }, + EventPayload: vcs.EventPayload{RepoPath: hook.repoPath}, + } + assert.Equal(t, want, broker.got) +} + +type ( + fakeHandlerDB struct { + hook *hook + } + fakeBroker struct { + got vcs.Event + } +) + +func (db *fakeHandlerDB) getHookByID(context.Context, uuid.UUID) (*hook, error) { + return db.hook, nil +} + +func (f *fakeBroker) Publish(got vcs.Event) { f.got = got } diff --git a/internal/repohooks/repohook.go b/internal/repohooks/repohook.go new file mode 100644 index 000000000..c00a33efb --- /dev/null +++ b/internal/repohooks/repohook.go @@ -0,0 +1,82 @@ +// Package repohooks manages webhooks for VCS events +package repohooks + +import ( + "log/slog" + "path" + + "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" +) + +// defaultEvents are the VCS events that repohooks subscribe to. +var defaultEvents = []vcs.EventType{ + vcs.EventTypePush, + vcs.EventTypePull, +} + +type ( + // hook is a webhook for a VCS repo + hook struct { + id uuid.UUID // internal otf ID + cloudID *string // cloud's hook ID; populated following synchronisation + vcsProviderID string + + secret string // secret token + repoPath string // repo identifier: / + cloud vcs.Kind // origin of events + endpoint string // OTF URL that receives events + } + + newRepohookOptions struct { + id *uuid.UUID + vcsProviderID string + secret *string + repoPath string + cloud vcs.Kind + cloudID *string // cloud's webhook id + + // for building endpoint URL + internal.HostnameService + } +) + +func newRepohook(opts newRepohookOptions) (*hook, error) { + hook := hook{ + repoPath: opts.repoPath, + cloud: opts.cloud, + cloudID: opts.cloudID, + vcsProviderID: opts.vcsProviderID, + } + if opts.id != nil { + hook.id = *opts.id + } else { + hook.id = uuid.New() + } + if opts.secret != nil { + hook.secret = *opts.secret + } else { + secret, err := internal.GenerateToken() + if err != nil { + return nil, err + } + hook.secret = secret + } + hook.endpoint = opts.URL(path.Join(handlerPrefix, hook.id.String())) + return &hook, nil +} + +func (h *hook) LogValue() slog.Value { + attrs := []slog.Attr{ + slog.String("id", h.id.String()), + slog.String("vcs_provider_id", h.vcsProviderID), + slog.String("vcs_kind", string(h.cloud)), + slog.String("repo", h.repoPath), + slog.String("endpoint", h.endpoint), + } + if h.cloudID != nil { + attrs = append(attrs, slog.String("vcs_id", *h.cloudID)) + } + return slog.GroupValue(attrs...) +} diff --git a/internal/repohooks/repohook_test.go b/internal/repohooks/repohook_test.go new file mode 100644 index 000000000..3a4251724 --- /dev/null +++ b/internal/repohooks/repohook_test.go @@ -0,0 +1,44 @@ +package repohooks + +import ( + "testing" + + "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_newHook(t *testing.T) { + id := uuid.New() + + tests := []struct { + name string + opts newRepohookOptions + want *hook + }{ + { + name: "default", + opts: newRepohookOptions{ + id: &id, + cloud: vcs.GithubKind, + secret: internal.String("top-secret"), + HostnameService: internal.NewHostnameService("fakehost.org"), + }, + want: &hook{ + id: id, + secret: "top-secret", + cloud: vcs.GithubKind, + endpoint: "https://fakehost.org/webhooks/vcs/" + id.String(), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := newRepohook(tt.opts) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/repohooks/service.go b/internal/repohooks/service.go new file mode 100644 index 000000000..4c1141c8c --- /dev/null +++ b/internal/repohooks/service.go @@ -0,0 +1,204 @@ +package repohooks + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/organization" + "github.com/leg100/otf/internal/sql" + "github.com/leg100/otf/internal/sql/pggen" + "github.com/leg100/otf/internal/vcs" + "github.com/leg100/otf/internal/vcsprovider" +) + +type ( + RepohookService = Service + + // RepohookService manages webhooks + Service interface { + // CreateRepohook creates a webhook on a VCS repository. If the webhook + // already exists, it is updated if there are discrepancies; otherwise + // no action is taken. In any case an identifier is returned uniquely + // identifying the webhook. + CreateRepohook(ctx context.Context, opts CreateRepohookOptions) (uuid.UUID, error) + // RegisterCloudHandler registers a new cloud handler, to handle VCS + // events for a specific vcs hosting provider. + RegisterCloudHandler(kind vcs.Kind, h EventUnmarshaler) + // DeleteUnreferencedRepohooks deletes any repohooks no longer used + // by a VCS connection + DeleteUnreferencedRepohooks(ctx context.Context) error + } + + service struct { + logr.Logger + vcsprovider.Service + + *db + + *handlers // handles incoming vcs events + *synchroniser // synchronise hooks + } + + Options struct { + logr.Logger + + *sql.DB + VCSEventBroker *vcs.Broker + internal.HostnameService + VCSProviderService vcsprovider.Service + organization.OrganizationService + github.GithubAppService + } + + CreateRepohookOptions struct { + VCSProviderID string // vcs provider of repo + RepoPath string + } +) + +func NewService(ctx context.Context, opts Options) *service { + db := &db{opts.DB, opts.HostnameService} + svc := &service{ + Logger: opts.Logger, + Service: opts.VCSProviderService, + db: db, + handlers: newHandler( + opts.Logger, + opts.VCSEventBroker, + db, + ), + synchroniser: &synchroniser{Logger: opts.Logger, syncdb: db}, + } + // Delete webhooks prior to the deletion of VCS providers. VCS providers are + // necessary for the deletion of webhooks from VCS repos. Hence we need to + // first delete webhooks that reference the VCS provider before the VCS + // provider is deleted. + opts.VCSProviderService.BeforeDeleteVCSProvider(svc.deleteProviderRepohooks) + // Delete webhooks prior to the deletion of organizations. Deleting + // organizations cascades deletion of VCS providers (see above). + opts.OrganizationService.BeforeDeleteOrganization(svc.deleteOrganizationRepohooks) + return svc +} + +func (s *service) CreateRepohook(ctx context.Context, opts CreateRepohookOptions) (uuid.UUID, error) { + vcsProvider, err := s.GetVCSProvider(ctx, opts.VCSProviderID) + if err != nil { + return uuid.UUID{}, fmt.Errorf("retrieving vcs provider: %w", err) + } + if vcsProvider.GithubApp != nil { + // github apps don't need a webhook created on each repo. + return uuid.UUID{}, nil + } + client, err := s.GetVCSClient(ctx, opts.VCSProviderID) + if err != nil { + return uuid.UUID{}, fmt.Errorf("retrieving vcs client: %w", err) + } + _, err = client.GetRepository(ctx, opts.RepoPath) + if err != nil { + return uuid.UUID{}, fmt.Errorf("checking repository exists: %w", err) + } + hook, err := newRepohook(newRepohookOptions{ + repoPath: opts.RepoPath, + cloud: vcsProvider.Kind, + vcsProviderID: vcsProvider.ID, + HostnameService: s.HostnameService, + }) + if err != nil { + return uuid.UUID{}, fmt.Errorf("constructing webhook: %w", err) + } + // lock repohooks table to prevent concurrent updates (a row-level lock is + // insufficient) + err = s.db.Lock(ctx, "repohooks", func(ctx context.Context, q pggen.Querier) error { + hook, err = s.db.getOrCreateHook(ctx, hook) + if err != nil { + return fmt.Errorf("getting or creating webhook: %w", err) + } + if err := s.sync(ctx, client, hook); err != nil { + return fmt.Errorf("synchronising webhook: %w", err) + } + return nil + }) + if err != nil { + return uuid.UUID{}, err + } + return hook.id, nil +} + +func (s *service) RegisterCloudHandler(kind vcs.Kind, h EventUnmarshaler) { + s.handlers.cloudHandlers.Set(kind, h) +} + +func (s *service) DeleteUnreferencedRepohooks(ctx context.Context) error { + hooks, err := s.db.listUnreferencedRepohooks(ctx) + if err != nil { + return fmt.Errorf("listing unreferenced webhooks: %w", err) + } + for _, h := range hooks { + if err := s.deleteRepohook(ctx, h); err != nil { + return err + } + } + return nil +} + +func (s *service) deleteOrganizationRepohooks(ctx context.Context, org *organization.Organization) error { + providers, err := s.ListVCSProviders(ctx, org.Name) + if err != nil { + return err + } + hooks, err := s.db.listHooks(ctx) + if err != nil { + return err + } + for _, p := range providers { + for _, h := range hooks { + if h.vcsProviderID == p.ID { + if err := s.deleteRepohook(ctx, h); err != nil { + return err + } + } + } + } + return nil +} + +func (s *service) deleteProviderRepohooks(ctx context.Context, provider *vcsprovider.VCSProvider) error { + hooks, err := s.db.listHooks(ctx) + if err != nil { + return err + } + for _, h := range hooks { + if h.vcsProviderID == provider.ID { + if err := s.deleteRepohook(ctx, h); err != nil { + return err + } + } + } + return nil +} + +func (s *service) deleteRepohook(ctx context.Context, repohook *hook) error { + if err := s.db.deleteHook(ctx, repohook.id); err != nil { + return fmt.Errorf("deleting webhook from db: %w", err) + } + client, err := s.GetVCSClient(ctx, repohook.vcsProviderID) + if err != nil { + return fmt.Errorf("retrieving vcs client from db: %w", err) + } + err = client.DeleteWebhook(ctx, vcs.DeleteWebhookOptions{ + Repo: repohook.repoPath, + ID: *repohook.cloudID, + }) + if err != nil { + s.Error(err, "deleting webhook", "repo", repohook.repoPath, "cloud", repohook.cloud) + } else { + s.V(0).Info("deleted webhook", "repo", repohook.repoPath, "cloud", repohook.cloud) + } + // Failure to delete the webhook from the cloud provider is not deemed a + // fatal error. + return nil +} diff --git a/internal/repo/synchroniser.go b/internal/repohooks/synchroniser.go similarity index 74% rename from internal/repo/synchroniser.go rename to internal/repohooks/synchroniser.go index a0f90eee0..8fe874aaa 100644 --- a/internal/repo/synchroniser.go +++ b/internal/repohooks/synchroniser.go @@ -1,4 +1,4 @@ -package repo +package repohooks import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/go-logr/logr" "github.com/google/uuid" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/vcs" "github.com/pkg/errors" ) @@ -24,10 +24,10 @@ type ( ) // sync should be called from within a tx to avoid inconsistent results. -func (s *synchroniser) sync(ctx context.Context, client cloud.Client, hook *hook) error { +func (s *synchroniser) sync(ctx context.Context, client vcs.Client, hook *hook) error { createAndSync := func() error { - cloudID, err := client.CreateWebhook(ctx, cloud.CreateWebhookOptions{ - Repo: hook.identifier, + cloudID, err := client.CreateWebhook(ctx, vcs.CreateWebhookOptions{ + Repo: hook.repoPath, Secret: hook.secret, Events: defaultEvents, Endpoint: hook.endpoint, @@ -44,8 +44,8 @@ func (s *synchroniser) sync(ctx context.Context, client cloud.Client, hook *hook if hook.cloudID == nil { return createAndSync() } - cloudHook, err := client.GetWebhook(ctx, cloud.GetWebhookOptions{ - Repo: hook.identifier, + cloudHook, err := client.GetWebhook(ctx, vcs.GetWebhookOptions{ + Repo: hook.repoPath, ID: *hook.cloudID, }) if errors.Is(err, internal.ErrResourceNotFound) { @@ -55,8 +55,8 @@ func (s *synchroniser) sync(ctx context.Context, client cloud.Client, hook *hook } // hook is present on the vcs repo, but we update it anyway just to ensure // its configuration is consistent with what we have in the DB - err = client.UpdateWebhook(ctx, cloudHook.ID, cloud.UpdateWebhookOptions{ - Repo: hook.identifier, + err = client.UpdateWebhook(ctx, cloudHook.ID, vcs.UpdateWebhookOptions{ + Repo: hook.repoPath, Secret: hook.secret, Events: defaultEvents, Endpoint: hook.endpoint, diff --git a/internal/repo/synchroniser_test.go b/internal/repohooks/synchroniser_test.go similarity index 83% rename from internal/repo/synchroniser_test.go rename to internal/repohooks/synchroniser_test.go index 75c193b9c..08c5e7cc2 100644 --- a/internal/repo/synchroniser_test.go +++ b/internal/repohooks/synchroniser_test.go @@ -1,4 +1,4 @@ -package repo +package repohooks import ( "context" @@ -6,7 +6,7 @@ import ( "github.com/go-logr/logr" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/vcs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -16,13 +16,13 @@ import ( func TestSynchroniser(t *testing.T) { tests := []struct { name string - cloud cloud.Webhook // seed cloud with hook - got *hook // seed db with hook - want *hook // hook after synchronisation + cloud vcs.Webhook // seed cloud with hook + got *hook // seed db with hook + want *hook // hook after synchronisation }{ { name: "synchronised", - cloud: cloud.Webhook{ + cloud: vcs.Webhook{ ID: "123", Events: defaultEvents, Endpoint: "fake-host.org/xyz", @@ -38,7 +38,7 @@ func TestSynchroniser(t *testing.T) { }, { name: "new hook", - cloud: cloud.Webhook{ID: "123"}, // new id that cloud returns + cloud: vcs.Webhook{ID: "123"}, // new id that cloud returns got: &hook{ endpoint: "fake-host.org/xyz", }, @@ -49,7 +49,7 @@ func TestSynchroniser(t *testing.T) { }, { name: "hook events missing on cloud", - cloud: cloud.Webhook{ + cloud: vcs.Webhook{ ID: "123", Endpoint: "fake-host.org/xyz", }, diff --git a/internal/repohooks/test_helpers.go b/internal/repohooks/test_helpers.go new file mode 100644 index 000000000..fe2d8c11a --- /dev/null +++ b/internal/repohooks/test_helpers.go @@ -0,0 +1,43 @@ +package repohooks + +import ( + "context" + + "github.com/google/uuid" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/vcs" +) + +type ( + fakeCloudClient struct { + hook vcs.Webhook // seed cloud with hook + gotUpdate bool + + vcs.Client + } + fakeDB struct { + hook *hook + } +) + +func (f *fakeCloudClient) CreateWebhook(context.Context, vcs.CreateWebhookOptions) (string, error) { + return f.hook.ID, nil +} + +func (f *fakeCloudClient) GetWebhook(ctx context.Context, opts vcs.GetWebhookOptions) (vcs.Webhook, error) { + if f.hook.ID == opts.ID { + return f.hook, nil + } + return vcs.Webhook{}, internal.ErrResourceNotFound +} + +func (f *fakeCloudClient) UpdateWebhook(context.Context, string, vcs.UpdateWebhookOptions) error { + f.gotUpdate = true + + return nil +} + +func (f *fakeDB) updateHookCloudID(ctx context.Context, id uuid.UUID, cloudID string) error { + f.hook.cloudID = &cloudID + return nil +} diff --git a/internal/run/client.go b/internal/run/client.go index e2ab9752d..70432d855 100644 --- a/internal/run/client.go +++ b/internal/run/client.go @@ -224,8 +224,6 @@ func newSSEClient(config http.Config, notifications chan pubsub.Event, opts Watc client.Headers = map[string]string{ "Authorization": "Bearer " + config.Token, } - if config.Insecure { - client.Connection.Transport = http.DefaultTransport(true) - } + client.Connection.Transport = config.Transport return client, nil } diff --git a/internal/run/client_test.go b/internal/run/client_test.go index d7f712484..3cd23df73 100644 --- a/internal/run/client_test.go +++ b/internal/run/client_test.go @@ -36,8 +36,8 @@ func TestWatchClient(t *testing.T) { // setup client and subscribe to stream client := &Client{ Config: otfhttp.Config{ - Address: webserver.URL, - Insecure: true, + Address: webserver.URL, + Transport: otfhttp.InsecureTransport, }, } diff --git a/internal/run/factory.go b/internal/run/factory.go index 5865b394f..0a0b4d5e9 100644 --- a/internal/run/factory.go +++ b/internal/run/factory.go @@ -4,9 +4,9 @@ import ( "context" "fmt" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" "github.com/leg100/otf/internal/releases" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" ) @@ -73,7 +73,7 @@ func (f *factory) createConfigVersionFromVCS(ctx context.Context, ws *workspace. if branch == "" { branch = repo.DefaultBranch } - tarball, ref, err := client.GetRepoTarball(ctx, cloud.GetRepoTarballOptions{ + tarball, ref, err := client.GetRepoTarball(ctx, vcs.GetRepoTarballOptions{ Repo: ws.Connection.Repo, Ref: &branch, }) diff --git a/internal/run/factory_test.go b/internal/run/factory_test.go index a78fcfdbd..58d9f7df5 100644 --- a/internal/run/factory_test.go +++ b/internal/run/factory_test.go @@ -6,10 +6,10 @@ import ( "time" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/releases" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" @@ -159,7 +159,7 @@ type ( vcsprovider.Service } fakeFactoryCloudClient struct { - cloud.Client + vcs.Client } fakeReleasesService struct { latestVersion string @@ -201,20 +201,20 @@ func (f *fakeFactoryConfigurationVersionService) UploadConfig(context.Context, s return nil } -func (f *fakeFactoryVCSProviderService) GetVCSClient(context.Context, string) (cloud.Client, error) { +func (f *fakeFactoryVCSProviderService) GetVCSClient(context.Context, string) (vcs.Client, error) { return &fakeFactoryCloudClient{}, nil } -func (f *fakeFactoryCloudClient) GetRepoTarball(context.Context, cloud.GetRepoTarballOptions) ([]byte, string, error) { +func (f *fakeFactoryCloudClient) GetRepoTarball(context.Context, vcs.GetRepoTarballOptions) ([]byte, string, error) { return nil, "", nil } -func (f *fakeFactoryCloudClient) GetRepository(context.Context, string) (cloud.Repository, error) { - return cloud.Repository{}, nil +func (f *fakeFactoryCloudClient) GetRepository(context.Context, string) (vcs.Repository, error) { + return vcs.Repository{}, nil } -func (f *fakeFactoryCloudClient) GetCommit(context.Context, string, string) (cloud.Commit, error) { - return cloud.Commit{}, nil +func (f *fakeFactoryCloudClient) GetCommit(context.Context, string, string) (vcs.Commit, error) { + return vcs.Commit{}, nil } func (f *fakeReleasesService) GetLatest(context.Context) (string, time.Time, error) { diff --git a/internal/run/reporter.go b/internal/run/reporter.go index cbee0135c..75ceea449 100644 --- a/internal/run/reporter.go +++ b/internal/run/reporter.go @@ -7,10 +7,10 @@ import ( "github.com/go-logr/logr" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/pubsub" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" ) @@ -83,46 +83,46 @@ func (r *Reporter) handleRun(ctx context.Context, run *Run) error { return nil } + ws, err := r.GetWorkspace(ctx, run.WorkspaceID) + if err != nil { + return err + } + if ws.Connection == nil { + return fmt.Errorf("workspace not connected to repo: %s", ws.ID) + } + + client, err := r.GetVCSClient(ctx, ws.Connection.VCSProviderID) + if err != nil { + return err + } + + // Report the status and description of the run state var ( - status cloud.VCSStatus + status vcs.Status description string ) switch run.Status { case internal.RunPending, internal.RunPlanQueued, internal.RunApplyQueued: - status = cloud.VCSPendingStatus + status = vcs.PendingStatus case internal.RunPlanning, internal.RunApplying, internal.RunPlanned, internal.RunConfirmed: - status = cloud.VCSRunningStatus + status = vcs.RunningStatus case internal.RunPlannedAndFinished: - status = cloud.VCSSuccessStatus + status = vcs.SuccessStatus if run.Plan.ResourceReport != nil { description = fmt.Sprintf("planned: %s", run.Plan.ResourceReport) } case internal.RunApplied: - status = cloud.VCSSuccessStatus + status = vcs.SuccessStatus if run.Apply.ResourceReport != nil { description = fmt.Sprintf("applied: %s", run.Apply.ResourceReport) } case internal.RunErrored, internal.RunCanceled, internal.RunForceCanceled, internal.RunDiscarded: - status = cloud.VCSErrorStatus + status = vcs.ErrorStatus description = run.Status.String() default: return fmt.Errorf("unknown run status: %s", run.Status) } - - ws, err := r.GetWorkspace(ctx, run.WorkspaceID) - if err != nil { - return err - } - if ws.Connection == nil { - return fmt.Errorf("workspace not connected to repo: %s", ws.ID) - } - - client, err := r.GetVCSClient(ctx, ws.Connection.VCSProviderID) - if err != nil { - return err - } - - return client.SetStatus(ctx, cloud.SetStatusOptions{ + return client.SetStatus(ctx, vcs.SetStatusOptions{ Workspace: ws.Name, Ref: cv.IngressAttributes.CommitSHA, Repo: cv.IngressAttributes.Repo, diff --git a/internal/run/reporter_test.go b/internal/run/reporter_test.go index 2b92bea68..249ae8e6e 100644 --- a/internal/run/reporter_test.go +++ b/internal/run/reporter_test.go @@ -5,8 +5,8 @@ import ( "testing" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" @@ -21,7 +21,7 @@ func TestReporter_HandleRun(t *testing.T) { run *Run ws *workspace.Workspace cv *configversion.ConfigurationVersion - want cloud.SetStatusOptions + want vcs.SetStatusOptions }{ { name: "pending run", @@ -36,11 +36,11 @@ func TestReporter_HandleRun(t *testing.T) { Repo: "leg100/otf", }, }, - want: cloud.SetStatusOptions{ + want: vcs.SetStatusOptions{ Workspace: "dev", Ref: "abc123", Repo: "leg100/otf", - Status: cloud.VCSPendingStatus, + Status: vcs.PendingStatus, TargetURL: "https://otf-host.org/app/runs/run-123", }, }, @@ -50,22 +50,22 @@ func TestReporter_HandleRun(t *testing.T) { cv: &configversion.ConfigurationVersion{ IngressAttributes: nil, }, - want: cloud.SetStatusOptions{}, + want: vcs.SetStatusOptions{}, }, { name: "skip UI-triggered run", run: &Run{ID: "run-123", Source: SourceUI}, - want: cloud.SetStatusOptions{}, + want: vcs.SetStatusOptions{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var got cloud.SetStatusOptions + var got vcs.SetStatusOptions reporter := &Reporter{ WorkspaceService: &fakeReporterWorkspaceService{ws: tt.ws}, ConfigurationVersionService: &fakeReporterConfigurationVersionService{cv: tt.cv}, VCSProviderService: &fakeReporterVCSProviderService{got: &got}, - HostnameService: internal.FakeHostnameService{Host: "otf-host.org"}, + HostnameService: internal.NewHostnameService("otf-host.org"), } err := reporter.handleRun(ctx, tt.run) require.NoError(t, err) @@ -98,20 +98,20 @@ func (f *fakeReporterWorkspaceService) GetWorkspace(context.Context, string) (*w type fakeReporterVCSProviderService struct { vcsprovider.VCSProviderService - got *cloud.SetStatusOptions + got *vcs.SetStatusOptions } -func (f *fakeReporterVCSProviderService) GetVCSClient(context.Context, string) (cloud.Client, error) { +func (f *fakeReporterVCSProviderService) GetVCSClient(context.Context, string) (vcs.Client, error) { return &fakeReporterCloudClient{got: f.got}, nil } type fakeReporterCloudClient struct { - cloud.Client + vcs.Client - got *cloud.SetStatusOptions + got *vcs.SetStatusOptions } -func (f *fakeReporterCloudClient) SetStatus(ctx context.Context, opts cloud.SetStatusOptions) error { +func (f *fakeReporterCloudClient) SetStatus(ctx context.Context, opts vcs.SetStatusOptions) error { *f.got = opts return nil } diff --git a/internal/run/service.go b/internal/run/service.go index 76ab3570f..cdecf2702 100644 --- a/internal/run/service.go +++ b/internal/run/service.go @@ -14,10 +14,10 @@ import ( "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/rbac" "github.com/leg100/otf/internal/releases" - "github.com/leg100/otf/internal/repo" "github.com/leg100/otf/internal/resource" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/tfeapi" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/leg100/otf/internal/workspace" "github.com/leg100/surl" @@ -94,6 +94,7 @@ type ( Options struct { WorkspaceAuthorizer internal.Authorizer + VCSEventSubscriber vcs.Subscriber OrganizationService WorkspaceService @@ -108,7 +109,6 @@ type ( *surl.Signer html.Renderer *pubsub.Broker - repo.Subscriber } ) @@ -163,7 +163,7 @@ func NewService(opts Options) *service { opts.Responder.Register(tfeapi.IncludeCurrentRun, svc.api.includeCurrentRun) // Subscribe run spawner to incoming vcs events - opts.Subscriber.Subscribe(spawner.handle) + opts.VCSEventSubscriber.Subscribe(spawner.handle) // After a workspace is created, if auto-queue-runs is set, then create a // run as well. diff --git a/internal/run/spawner.go b/internal/run/spawner.go index 4447e761b..69a936e04 100644 --- a/internal/run/spawner.go +++ b/internal/run/spawner.go @@ -8,9 +8,8 @@ import ( "github.com/go-logr/logr" "github.com/gobwas/glob" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" - "github.com/leg100/otf/internal/repo" + "github.com/leg100/otf/internal/vcs" ) type ( @@ -22,11 +21,10 @@ type ( WorkspaceService VCSProviderService RunService - repo.Subscriber } ) -func (s *Spawner) handle(event cloud.VCSEvent) { +func (s *Spawner) handle(event vcs.Event) { logger := s.Logger.WithValues( "sha", event.CommitSHA, "type", event.Type, @@ -40,7 +38,7 @@ func (s *Spawner) handle(event cloud.VCSEvent) { } } -func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) error { +func (s *Spawner) handleWithError(logger logr.Logger, event vcs.Event) error { // no parent context; handler is called asynchronously ctx := context.Background() // give spawner unlimited powers @@ -48,12 +46,12 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro // skip events other than those that create or update a ref or pull request switch event.Action { - case cloud.VCSActionCreated, cloud.VCSActionUpdated: + case vcs.ActionCreated, vcs.ActionUpdated: default: return nil } - workspaces, err := s.ListWorkspacesByRepoID(ctx, event.RepoID) + workspaces, err := s.ListConnectedWorkspaces(ctx, event.VCSProviderID, event.RepoPath) if err != nil { return err } @@ -66,7 +64,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro n := 0 for _, ws := range workspaces { switch event.Type { - case cloud.VCSEventTypeTag: + case vcs.EventTypeTag: // skip workspaces with a non-nil tag regex that doesn't match the // tag event if ws.Connection.TagsRegex != "" { @@ -75,7 +73,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro continue } } - case cloud.VCSEventTypePush: + case vcs.EventTypePush: if ws.Connection.Branch != "" { // skip workspaces with a user-specified branch that doesn't match the // event branch @@ -97,7 +95,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro // only tag and push events contain a list of changed files switch event.Type { - case cloud.VCSEventTypeTag, cloud.VCSEventTypePush: + case vcs.EventTypeTag, vcs.EventTypePush: // filter workspaces with trigger pattern that doesn't match any of the // files in the event if ws.TriggerPatterns != nil { @@ -120,7 +118,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro if err != nil { return err } - tarball, _, err := client.GetRepoTarball(ctx, cloud.GetRepoTarballOptions{ + tarball, _, err := client.GetRepoTarball(ctx, vcs.GetRepoTarballOptions{ Repo: event.RepoPath, Ref: &event.CommitSHA, }) @@ -130,7 +128,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro // pull request events don't contain a list of changed files; instead an API // call is necsssary to retrieve the list of changed files - if event.Type == cloud.VCSEventTypePull { + if event.Type == vcs.EventTypePull { // only perform API call if at least one workspace has file triggers // enabled. var listFiles bool @@ -162,7 +160,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro for _, ws := range workspaces { cvOpts := configversion.ConfigurationVersionCreateOptions{ // pull request events trigger speculative runs - Speculative: internal.Bool(event.Type == cloud.VCSEventTypePull), + Speculative: internal.Bool(event.Type == vcs.EventTypePull), IngressAttributes: &configversion.IngressAttributes{ // ID string Branch: event.Branch, @@ -172,7 +170,7 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro CommitURL: event.CommitURL, // CompareURL string Repo: ws.Connection.Repo, - IsPullRequest: event.Type == cloud.VCSEventTypePull, + IsPullRequest: event.Type == vcs.EventTypePull, OnDefaultBranch: event.Branch == event.DefaultBranch, PullRequestNumber: event.PullRequestNumber, PullRequestTitle: event.PullRequestTitle, @@ -184,11 +182,11 @@ func (s *Spawner) handleWithError(logger logr.Logger, event cloud.VCSEvent) erro }, } runOpts := CreateOptions{} - switch event.Cloud { - case cloud.Github: + switch event.VCSKind { + case vcs.GithubKind: cvOpts.Source = configversion.SourceGithub runOpts.Source = SourceGithub - case cloud.Gitlab: + case vcs.GitlabKind: cvOpts.Source = configversion.SourceGitlab runOpts.Source = SourceGitlab } diff --git a/internal/run/spawner_test.go b/internal/run/spawner_test.go index feee661c1..dee838b3d 100644 --- a/internal/run/spawner_test.go +++ b/internal/run/spawner_test.go @@ -5,9 +5,8 @@ import ( "testing" "github.com/go-logr/logr" - "github.com/google/uuid" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/configversion" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/workspace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,7 +17,7 @@ func TestSpawner(t *testing.T) { name string ws *workspace.Workspace // incoming event - event cloud.VCSEvent + event vcs.Event // file paths to return from stubbed client.ListPullRequestFiles pullFiles []string // want spawned run @@ -27,61 +26,80 @@ func TestSpawner(t *testing.T) { { name: "spawn run for push to default branch", ws: &workspace.Workspace{Connection: &workspace.Connection{}}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Branch: "main", - DefaultBranch: "main", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Branch: "main", + DefaultBranch: "main", + }, }, spawn: true, }, { name: "skip run for push to non-default branch", ws: &workspace.Workspace{Connection: &workspace.Connection{}}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Branch: "dev", - DefaultBranch: "main", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Branch: "dev", + DefaultBranch: "main", + }, }, spawn: false, }, { name: "spawn run for push event for a workspace with user-specified branch", ws: &workspace.Workspace{Connection: &workspace.Connection{Branch: "dev"}}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Branch: "dev", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Branch: "dev", + }, }, spawn: true, }, { name: "skip run for push event for a workspace with non-matching, user-specified branch", ws: &workspace.Workspace{Connection: &workspace.Connection{Branch: "dev"}}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Branch: "staging", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Branch: "staging", + }, }, spawn: false, }, { - name: "spawn run for opened pull request", - ws: &workspace.Workspace{Connection: &workspace.Connection{}}, - event: cloud.VCSEvent{Type: cloud.VCSEventTypePull, Action: cloud.VCSActionCreated}, + name: "spawn run for opened pull request", + ws: &workspace.Workspace{Connection: &workspace.Connection{}}, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePull, Action: vcs.ActionCreated, + }, + }, spawn: true, }, { - name: "spawn run for update to pull request", - ws: &workspace.Workspace{Connection: &workspace.Connection{}}, - event: cloud.VCSEvent{Type: cloud.VCSEventTypePull, Action: cloud.VCSActionUpdated}, + name: "spawn run for update to pull request", + ws: &workspace.Workspace{Connection: &workspace.Connection{}}, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePull, + Action: vcs.ActionUpdated, + }, + }, spawn: true, }, { - name: "skip run for push event for workspace with tags regex", - ws: &workspace.Workspace{Connection: &workspace.Connection{TagsRegex: "0.1.2"}}, - event: cloud.VCSEvent{Type: cloud.VCSEventTypePush, Action: cloud.VCSActionCreated}, + name: "skip run for push event for workspace with tags regex", + ws: &workspace.Workspace{Connection: &workspace.Connection{TagsRegex: "0.1.2"}}, + event: vcs.Event{ + EventPayload: vcs.EventPayload{Type: vcs.EventTypePush, Action: vcs.ActionCreated}, + }, spawn: false, }, { @@ -89,10 +107,12 @@ func TestSpawner(t *testing.T) { ws: &workspace.Workspace{Connection: &workspace.Connection{ TagsRegex: `^\d+\.\d+\.\d+$`, }}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypeTag, - Action: cloud.VCSActionCreated, - Tag: "0.1.2", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypeTag, + Action: vcs.ActionCreated, + Tag: "0.1.2", + }, }, spawn: true, }, @@ -101,10 +121,12 @@ func TestSpawner(t *testing.T) { ws: &workspace.Workspace{Connection: &workspace.Connection{ TagsRegex: `^\d+\.\d+\.\d+$`, }}, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypeTag, - Action: cloud.VCSActionCreated, - Tag: "v0.1.2", + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypeTag, + Action: vcs.ActionCreated, + Tag: "v0.1.2", + }, }, spawn: false, }, @@ -114,10 +136,12 @@ func TestSpawner(t *testing.T) { TriggerPatterns: []string{"/foo/*.tf"}, Connection: &workspace.Connection{}, }, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Paths: []string{"/foo/bar.tf"}, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Paths: []string{"/foo/bar.tf"}, + }, }, spawn: true, }, @@ -127,10 +151,12 @@ func TestSpawner(t *testing.T) { TriggerPatterns: []string{"/foo/*.tf"}, Connection: &workspace.Connection{}, }, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePush, - Action: cloud.VCSActionCreated, - Paths: []string{"README.md", ".gitignore"}, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePush, + Action: vcs.ActionCreated, + Paths: []string{"README.md", ".gitignore"}, + }, }, spawn: false, }, @@ -140,9 +166,11 @@ func TestSpawner(t *testing.T) { TriggerPatterns: []string{"/foo/*.tf"}, Connection: &workspace.Connection{}, }, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePull, - Action: cloud.VCSActionUpdated, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePull, + Action: vcs.ActionUpdated, + }, }, pullFiles: []string{"/foo/bar.tf"}, spawn: true, @@ -153,9 +181,11 @@ func TestSpawner(t *testing.T) { TriggerPatterns: []string{"/foo/*.tf"}, Connection: &workspace.Connection{}, }, - event: cloud.VCSEvent{ - Type: cloud.VCSEventTypePull, - Action: cloud.VCSActionUpdated, + event: vcs.Event{ + EventPayload: vcs.EventPayload{ + Type: vcs.EventTypePull, + Action: vcs.ActionUpdated, + }, }, pullFiles: []string{"README.md", ".gitignore"}, spawn: false, @@ -197,7 +227,7 @@ type fakeSpawnerServices struct { RunService } -func (f *fakeSpawnerServices) ListWorkspacesByRepoID(ctx context.Context, id uuid.UUID) ([]*workspace.Workspace, error) { +func (f *fakeSpawnerServices) ListConnectedWorkspaces(context.Context, string, string) ([]*workspace.Workspace, error) { return f.workspaces, nil } @@ -219,16 +249,16 @@ func (f *fakeSpawnerServices) CreateRun(context.Context, string, CreateOptions) return nil, nil } -func (f *fakeSpawnerServices) GetVCSClient(context.Context, string) (cloud.Client, error) { +func (f *fakeSpawnerServices) GetVCSClient(context.Context, string) (vcs.Client, error) { return &fakeSpawnerCloudClient{pullFiles: f.pullFiles}, nil } type fakeSpawnerCloudClient struct { - cloud.Client + vcs.Client pullFiles []string } -func (f *fakeSpawnerCloudClient) GetRepoTarball(context.Context, cloud.GetRepoTarballOptions) ([]byte, string, error) { +func (f *fakeSpawnerCloudClient) GetRepoTarball(context.Context, vcs.GetRepoTarballOptions) ([]byte, string, error) { return nil, "", nil } diff --git a/internal/safemap.go b/internal/safemap.go new file mode 100644 index 000000000..d19e0af48 --- /dev/null +++ b/internal/safemap.go @@ -0,0 +1,33 @@ +package internal + +import ( + "sync" +) + +// SafeMap is a concurrency-safe map +type SafeMap[K comparable, V any] struct { + m map[K]V + mu sync.RWMutex +} + +// NewSafeMap constructs an empty SafeMap, with the given key and value types. +func NewSafeMap[K comparable, V any]() *SafeMap[K, V] { + return &SafeMap[K, V]{ + m: make(map[K]V), + } +} + +func (r *SafeMap[K, V]) Set(key K, value V) { + r.mu.Lock() + defer r.mu.Unlock() + + r.m[key] = value +} + +func (r *SafeMap[K, V]) Get(key K) (V, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + value, ok := r.m[key] + return value, ok +} diff --git a/internal/sql/migrations/20231010191540_create_github_apps.sql b/internal/sql/migrations/20231010191540_create_github_apps.sql new file mode 100644 index 000000000..040a33209 --- /dev/null +++ b/internal/sql/migrations/20231010191540_create_github_apps.sql @@ -0,0 +1,98 @@ +-- +goose Up + +-- add github apps table +CREATE TABLE IF NOT EXISTS github_apps ( + github_app_id BIGINT NOT NULL, + webhook_secret TEXT NOT NULL, + private_key TEXT NOT NULL, + slug TEXT NOT NULL, + organization TEXT, + PRIMARY KEY (github_app_id) +); + +-- add github app installs table, with fk to vcs providers; place mutually +-- exclusive constraint on user and org columns +CREATE TABLE IF NOT EXISTS github_app_installs ( + github_app_id BIGINT REFERENCES github_apps ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + install_id BIGINT NOT NULL, + username TEXT, + organization TEXT, + vcs_provider_id TEXT REFERENCES vcs_providers ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + CHECK ((username IS NOT NULL AND organization IS NULL) OR (username IS NULL AND organization IS NOT NULL)) +); + +-- vcs provider token is no longer mandatory; and add an fk to github app, so that when a github app is deleted so is the vcs provider +ALTER TABLE vcs_providers + ALTER COLUMN token DROP NOT NULL, + ADD COLUMN github_app_id BIGINT, + ADD CONSTRAINT github_app_id_fk FOREIGN KEY (github_app_id) + REFERENCES github_apps ON UPDATE CASCADE ON DELETE CASCADE; + +-- alter repo_connections, swapping fk to webhooks with fk to vcs providers; +-- and copy repo path from webhooks to repo_connections +ALTER TABLE repo_connections + ADD COLUMN repo_path TEXT, + ADD COLUMN vcs_provider_id TEXT, + ADD CONSTRAINT vcs_provider_id_fk FOREIGN KEY (vcs_provider_id) + REFERENCES vcs_providers ON UPDATE CASCADE ON DELETE CASCADE; + +UPDATE repo_connections rc +SET vcs_provider_id = w.vcs_provider_id, + repo_path = w.identifier +FROM webhooks w +WHERE rc.webhook_id = w.webhook_id; + +ALTER TABLE repo_connections + ALTER COLUMN repo_path SET NOT NULL, + ALTER COLUMN vcs_provider_id SET NOT NULL; + +ALTER TABLE repo_connections + DROP COLUMN webhook_id; + +-- rename webhooks to repohooks, and rename columns +ALTER TABLE webhooks RENAME TO repohooks; +ALTER TABLE repohooks RENAME COLUMN webhook_id TO repohook_id; +ALTER TABLE repohooks RENAME COLUMN identifier TO repo_path; + +-- rename clouds to vcs_kinds and rename vcs_provider's fk +ALTER TABLE clouds RENAME TO vcs_kinds; +ALTER TABLE vcs_providers RENAME COLUMN cloud TO vcs_kind; + +-- remove trigger function +DROP FUNCTION IF EXISTS repo_connections_notify_event CASCADE; + +-- +goose Down + +-- rename vcs_kinds back to clouds and rename fk back +ALTER TABLE vcs_kinds RENAME TO clouds; +ALTER TABLE vcs_providers RENAME COLUMN vcs_kind TO cloud; + +-- rename repohooks back to webhooks, and rename columns back +ALTER TABLE repohooks RENAME TO webhooks; +ALTER TABLE webhooks RENAME COLUMN repohook_id TO webhook_id; +ALTER TABLE webhooks RENAME COLUMN repo_path TO identifier; + +-- swap repo_connections fk from vcs providers back to webhooks +ALTER TABLE repo_connections + ADD COLUMN webhook_id UUID; + +UPDATE repo_connections rc +SET webhook_id = w.webhook_id +FROM webhooks w +WHERE rc.vcs_provider_id = w.vcs_provider_id; + +ALTER TABLE repo_connections + DROP COLUMN repo_path, + DROP COLUMN vcs_provider_id, + ALTER COLUMN webhook_id SET NOT NULL, + ADD CONSTRAINT webhook_id_fk FOREIGN KEY (webhook_id) + REFERENCES webhooks ON UPDATE CASCADE ON DELETE CASCADE; + +-- make token mandatory on vcs providers, and drop github app fk +ALTER TABLE vcs_providers + ALTER COLUMN token SET NOT NULL, + DROP COLUMN github_app_id; + +-- remove github tables +DROP TABLE IF EXISTS github_app_installs; +DROP TABLE IF EXISTS github_apps; diff --git a/internal/sql/pggen/agent_token.sql.go b/internal/sql/pggen/agent_token.sql.go index 2247fbad4..5c33ad78f 100644 --- a/internal/sql/pggen/agent_token.sql.go +++ b/internal/sql/pggen/agent_token.sql.go @@ -151,6 +151,34 @@ type Querier interface { // DeleteConfigurationVersionByIDScan scans the result of an executed DeleteConfigurationVersionByIDBatch query. DeleteConfigurationVersionByIDScan(results pgx.BatchResults) (pgtype.Text, error) + InsertGithubApp(ctx context.Context, params InsertGithubAppParams) (pgconn.CommandTag, error) + // InsertGithubAppBatch enqueues a InsertGithubApp query into batch to be executed + // later by the batch. + InsertGithubAppBatch(batch genericBatch, params InsertGithubAppParams) + // InsertGithubAppScan scans the result of an executed InsertGithubAppBatch query. + InsertGithubAppScan(results pgx.BatchResults) (pgconn.CommandTag, error) + + FindGithubApp(ctx context.Context) (FindGithubAppRow, error) + // FindGithubAppBatch enqueues a FindGithubApp query into batch to be executed + // later by the batch. + FindGithubAppBatch(batch genericBatch) + // FindGithubAppScan scans the result of an executed FindGithubAppBatch query. + FindGithubAppScan(results pgx.BatchResults) (FindGithubAppRow, error) + + DeleteGithubApp(ctx context.Context, githubAppID pgtype.Int8) (DeleteGithubAppRow, error) + // DeleteGithubAppBatch enqueues a DeleteGithubApp query into batch to be executed + // later by the batch. + DeleteGithubAppBatch(batch genericBatch, githubAppID pgtype.Int8) + // DeleteGithubAppScan scans the result of an executed DeleteGithubAppBatch query. + DeleteGithubAppScan(results pgx.BatchResults) (DeleteGithubAppRow, error) + + InsertGithubAppInstall(ctx context.Context, params InsertGithubAppInstallParams) (pgconn.CommandTag, error) + // InsertGithubAppInstallBatch enqueues a InsertGithubAppInstall query into batch to be executed + // later by the batch. + InsertGithubAppInstallBatch(batch genericBatch, params InsertGithubAppInstallParams) + // InsertGithubAppInstallScan scans the result of an executed InsertGithubAppInstallBatch query. + InsertGithubAppInstallScan(results pgx.BatchResults) (pgconn.CommandTag, error) + InsertIngressAttributes(ctx context.Context, params InsertIngressAttributesParams) (pgconn.CommandTag, error) // InsertIngressAttributesBatch enqueues a InsertIngressAttributes query into batch to be executed // later by the batch. @@ -193,12 +221,12 @@ type Querier interface { // FindModuleByIDScan scans the result of an executed FindModuleByIDBatch query. FindModuleByIDScan(results pgx.BatchResults) (FindModuleByIDRow, error) - FindModuleByWebhookID(ctx context.Context, webhookID pgtype.UUID) (FindModuleByWebhookIDRow, error) - // FindModuleByWebhookIDBatch enqueues a FindModuleByWebhookID query into batch to be executed + FindModuleByConnection(ctx context.Context, vcsProviderID pgtype.Text, repoPath pgtype.Text) (FindModuleByConnectionRow, error) + // FindModuleByConnectionBatch enqueues a FindModuleByConnection query into batch to be executed // later by the batch. - FindModuleByWebhookIDBatch(batch genericBatch, webhookID pgtype.UUID) - // FindModuleByWebhookIDScan scans the result of an executed FindModuleByWebhookIDBatch query. - FindModuleByWebhookIDScan(results pgx.BatchResults) (FindModuleByWebhookIDRow, error) + FindModuleByConnectionBatch(batch genericBatch, vcsProviderID pgtype.Text, repoPath pgtype.Text) + // FindModuleByConnectionScan scans the result of an executed FindModuleByConnectionBatch query. + FindModuleByConnectionScan(results pgx.BatchResults) (FindModuleByConnectionRow, error) FindModuleByModuleVersionID(ctx context.Context, moduleVersionID pgtype.Text) (FindModuleByModuleVersionIDRow, error) // FindModuleByModuleVersionIDBatch enqueues a FindModuleByModuleVersionID query into batch to be executed @@ -496,19 +524,68 @@ type Querier interface { // InsertRepoConnectionScan scans the result of an executed InsertRepoConnectionBatch query. InsertRepoConnectionScan(results pgx.BatchResults) (pgconn.CommandTag, error) - DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (pgtype.UUID, error) + DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (DeleteWorkspaceConnectionByIDRow, error) // DeleteWorkspaceConnectionByIDBatch enqueues a DeleteWorkspaceConnectionByID query into batch to be executed // later by the batch. DeleteWorkspaceConnectionByIDBatch(batch genericBatch, workspaceID pgtype.Text) // DeleteWorkspaceConnectionByIDScan scans the result of an executed DeleteWorkspaceConnectionByIDBatch query. - DeleteWorkspaceConnectionByIDScan(results pgx.BatchResults) (pgtype.UUID, error) + DeleteWorkspaceConnectionByIDScan(results pgx.BatchResults) (DeleteWorkspaceConnectionByIDRow, error) - DeleteModuleConnectionByID(ctx context.Context, moduleID pgtype.Text) (pgtype.UUID, error) + DeleteModuleConnectionByID(ctx context.Context, moduleID pgtype.Text) (DeleteModuleConnectionByIDRow, error) // DeleteModuleConnectionByIDBatch enqueues a DeleteModuleConnectionByID query into batch to be executed // later by the batch. DeleteModuleConnectionByIDBatch(batch genericBatch, moduleID pgtype.Text) // DeleteModuleConnectionByIDScan scans the result of an executed DeleteModuleConnectionByIDBatch query. - DeleteModuleConnectionByIDScan(results pgx.BatchResults) (pgtype.UUID, error) + DeleteModuleConnectionByIDScan(results pgx.BatchResults) (DeleteModuleConnectionByIDRow, error) + + InsertRepohook(ctx context.Context, params InsertRepohookParams) (InsertRepohookRow, error) + // InsertRepohookBatch enqueues a InsertRepohook query into batch to be executed + // later by the batch. + InsertRepohookBatch(batch genericBatch, params InsertRepohookParams) + // InsertRepohookScan scans the result of an executed InsertRepohookBatch query. + InsertRepohookScan(results pgx.BatchResults) (InsertRepohookRow, error) + + UpdateRepohookVCSID(ctx context.Context, vcsID pgtype.Text, repohookID pgtype.UUID) (UpdateRepohookVCSIDRow, error) + // UpdateRepohookVCSIDBatch enqueues a UpdateRepohookVCSID query into batch to be executed + // later by the batch. + UpdateRepohookVCSIDBatch(batch genericBatch, vcsID pgtype.Text, repohookID pgtype.UUID) + // UpdateRepohookVCSIDScan scans the result of an executed UpdateRepohookVCSIDBatch query. + UpdateRepohookVCSIDScan(results pgx.BatchResults) (UpdateRepohookVCSIDRow, error) + + FindRepohooks(ctx context.Context) ([]FindRepohooksRow, error) + // FindRepohooksBatch enqueues a FindRepohooks query into batch to be executed + // later by the batch. + FindRepohooksBatch(batch genericBatch) + // FindRepohooksScan scans the result of an executed FindRepohooksBatch query. + FindRepohooksScan(results pgx.BatchResults) ([]FindRepohooksRow, error) + + FindRepohookByID(ctx context.Context, repohookID pgtype.UUID) (FindRepohookByIDRow, error) + // FindRepohookByIDBatch enqueues a FindRepohookByID query into batch to be executed + // later by the batch. + FindRepohookByIDBatch(batch genericBatch, repohookID pgtype.UUID) + // FindRepohookByIDScan scans the result of an executed FindRepohookByIDBatch query. + FindRepohookByIDScan(results pgx.BatchResults) (FindRepohookByIDRow, error) + + FindRepohookByRepoAndProvider(ctx context.Context, repoPath pgtype.Text, vcsProviderID pgtype.Text) ([]FindRepohookByRepoAndProviderRow, error) + // FindRepohookByRepoAndProviderBatch enqueues a FindRepohookByRepoAndProvider query into batch to be executed + // later by the batch. + FindRepohookByRepoAndProviderBatch(batch genericBatch, repoPath pgtype.Text, vcsProviderID pgtype.Text) + // FindRepohookByRepoAndProviderScan scans the result of an executed FindRepohookByRepoAndProviderBatch query. + FindRepohookByRepoAndProviderScan(results pgx.BatchResults) ([]FindRepohookByRepoAndProviderRow, error) + + FindUnreferencedRepohooks(ctx context.Context) ([]FindUnreferencedRepohooksRow, error) + // FindUnreferencedRepohooksBatch enqueues a FindUnreferencedRepohooks query into batch to be executed + // later by the batch. + FindUnreferencedRepohooksBatch(batch genericBatch) + // FindUnreferencedRepohooksScan scans the result of an executed FindUnreferencedRepohooksBatch query. + FindUnreferencedRepohooksScan(results pgx.BatchResults) ([]FindUnreferencedRepohooksRow, error) + + DeleteRepohookByID(ctx context.Context, repohookID pgtype.UUID) (DeleteRepohookByIDRow, error) + // DeleteRepohookByIDBatch enqueues a DeleteRepohookByID query into batch to be executed + // later by the batch. + DeleteRepohookByIDBatch(batch genericBatch, repohookID pgtype.UUID) + // DeleteRepohookByIDScan scans the result of an executed DeleteRepohookByIDBatch query. + DeleteRepohookByIDScan(results pgx.BatchResults) (DeleteRepohookByIDRow, error) InsertRun(ctx context.Context, params InsertRunParams) (pgconn.CommandTag, error) // InsertRunBatch enqueues a InsertRun query into batch to be executed @@ -1042,6 +1119,13 @@ type Querier interface { // FindVCSProvidersScan scans the result of an executed FindVCSProvidersBatch query. FindVCSProvidersScan(results pgx.BatchResults) ([]FindVCSProvidersRow, error) + FindVCSProvidersByGithubAppInstallID(ctx context.Context, installID pgtype.Int8) ([]FindVCSProvidersByGithubAppInstallIDRow, error) + // FindVCSProvidersByGithubAppInstallIDBatch enqueues a FindVCSProvidersByGithubAppInstallID query into batch to be executed + // later by the batch. + FindVCSProvidersByGithubAppInstallIDBatch(batch genericBatch, installID pgtype.Int8) + // FindVCSProvidersByGithubAppInstallIDScan scans the result of an executed FindVCSProvidersByGithubAppInstallIDBatch query. + FindVCSProvidersByGithubAppInstallIDScan(results pgx.BatchResults) ([]FindVCSProvidersByGithubAppInstallIDRow, error) + FindVCSProvider(ctx context.Context, vcsProviderID pgtype.Text) (FindVCSProviderRow, error) // FindVCSProviderBatch enqueues a FindVCSProvider query into batch to be executed // later by the batch. @@ -1070,55 +1154,6 @@ type Querier interface { // DeleteVCSProviderByIDScan scans the result of an executed DeleteVCSProviderByIDBatch query. DeleteVCSProviderByIDScan(results pgx.BatchResults) (pgtype.Text, error) - InsertWebhook(ctx context.Context, params InsertWebhookParams) (InsertWebhookRow, error) - // InsertWebhookBatch enqueues a InsertWebhook query into batch to be executed - // later by the batch. - InsertWebhookBatch(batch genericBatch, params InsertWebhookParams) - // InsertWebhookScan scans the result of an executed InsertWebhookBatch query. - InsertWebhookScan(results pgx.BatchResults) (InsertWebhookRow, error) - - UpdateWebhookVCSID(ctx context.Context, vcsID pgtype.Text, webhookID pgtype.UUID) (UpdateWebhookVCSIDRow, error) - // UpdateWebhookVCSIDBatch enqueues a UpdateWebhookVCSID query into batch to be executed - // later by the batch. - UpdateWebhookVCSIDBatch(batch genericBatch, vcsID pgtype.Text, webhookID pgtype.UUID) - // UpdateWebhookVCSIDScan scans the result of an executed UpdateWebhookVCSIDBatch query. - UpdateWebhookVCSIDScan(results pgx.BatchResults) (UpdateWebhookVCSIDRow, error) - - FindWebhooks(ctx context.Context) ([]FindWebhooksRow, error) - // FindWebhooksBatch enqueues a FindWebhooks query into batch to be executed - // later by the batch. - FindWebhooksBatch(batch genericBatch) - // FindWebhooksScan scans the result of an executed FindWebhooksBatch query. - FindWebhooksScan(results pgx.BatchResults) ([]FindWebhooksRow, error) - - FindWebhookByID(ctx context.Context, webhookID pgtype.UUID) (FindWebhookByIDRow, error) - // FindWebhookByIDBatch enqueues a FindWebhookByID query into batch to be executed - // later by the batch. - FindWebhookByIDBatch(batch genericBatch, webhookID pgtype.UUID) - // FindWebhookByIDScan scans the result of an executed FindWebhookByIDBatch query. - FindWebhookByIDScan(results pgx.BatchResults) (FindWebhookByIDRow, error) - - FindWebhookByRepoAndProvider(ctx context.Context, identifier pgtype.Text, vcsProviderID pgtype.Text) ([]FindWebhookByRepoAndProviderRow, error) - // FindWebhookByRepoAndProviderBatch enqueues a FindWebhookByRepoAndProvider query into batch to be executed - // later by the batch. - FindWebhookByRepoAndProviderBatch(batch genericBatch, identifier pgtype.Text, vcsProviderID pgtype.Text) - // FindWebhookByRepoAndProviderScan scans the result of an executed FindWebhookByRepoAndProviderBatch query. - FindWebhookByRepoAndProviderScan(results pgx.BatchResults) ([]FindWebhookByRepoAndProviderRow, error) - - FindUnreferencedWebhooks(ctx context.Context) ([]FindUnreferencedWebhooksRow, error) - // FindUnreferencedWebhooksBatch enqueues a FindUnreferencedWebhooks query into batch to be executed - // later by the batch. - FindUnreferencedWebhooksBatch(batch genericBatch) - // FindUnreferencedWebhooksScan scans the result of an executed FindUnreferencedWebhooksBatch query. - FindUnreferencedWebhooksScan(results pgx.BatchResults) ([]FindUnreferencedWebhooksRow, error) - - DeleteWebhookByID(ctx context.Context, webhookID pgtype.UUID) (DeleteWebhookByIDRow, error) - // DeleteWebhookByIDBatch enqueues a DeleteWebhookByID query into batch to be executed - // later by the batch. - DeleteWebhookByIDBatch(batch genericBatch, webhookID pgtype.UUID) - // DeleteWebhookByIDScan scans the result of an executed DeleteWebhookByIDBatch query. - DeleteWebhookByIDScan(results pgx.BatchResults) (DeleteWebhookByIDRow, error) - InsertWorkspace(ctx context.Context, params InsertWorkspaceParams) (pgconn.CommandTag, error) // InsertWorkspaceBatch enqueues a InsertWorkspace query into batch to be executed // later by the batch. @@ -1140,12 +1175,12 @@ type Querier interface { // CountWorkspacesScan scans the result of an executed CountWorkspacesBatch query. CountWorkspacesScan(results pgx.BatchResults) (pgtype.Int8, error) - FindWorkspacesByWebhookID(ctx context.Context, webhookID pgtype.UUID) ([]FindWorkspacesByWebhookIDRow, error) - // FindWorkspacesByWebhookIDBatch enqueues a FindWorkspacesByWebhookID query into batch to be executed + FindWorkspacesByConnection(ctx context.Context, vcsProviderID pgtype.Text, repoPath pgtype.Text) ([]FindWorkspacesByConnectionRow, error) + // FindWorkspacesByConnectionBatch enqueues a FindWorkspacesByConnection query into batch to be executed // later by the batch. - FindWorkspacesByWebhookIDBatch(batch genericBatch, webhookID pgtype.UUID) - // FindWorkspacesByWebhookIDScan scans the result of an executed FindWorkspacesByWebhookIDBatch query. - FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]FindWorkspacesByWebhookIDRow, error) + FindWorkspacesByConnectionBatch(batch genericBatch, vcsProviderID pgtype.Text, repoPath pgtype.Text) + // FindWorkspacesByConnectionScan scans the result of an executed FindWorkspacesByConnectionBatch query. + FindWorkspacesByConnectionScan(results pgx.BatchResults) ([]FindWorkspacesByConnectionRow, error) FindWorkspacesByUsername(ctx context.Context, params FindWorkspacesByUsernameParams) ([]FindWorkspacesByUsernameRow, error) // FindWorkspacesByUsernameBatch enqueues a FindWorkspacesByUsername query into batch to be executed @@ -1396,6 +1431,18 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, deleteConfigurationVersionByIDSQL, deleteConfigurationVersionByIDSQL); err != nil { return fmt.Errorf("prepare query 'DeleteConfigurationVersionByID': %w", err) } + if _, err := p.Prepare(ctx, insertGithubAppSQL, insertGithubAppSQL); err != nil { + return fmt.Errorf("prepare query 'InsertGithubApp': %w", err) + } + if _, err := p.Prepare(ctx, findGithubAppSQL, findGithubAppSQL); err != nil { + return fmt.Errorf("prepare query 'FindGithubApp': %w", err) + } + if _, err := p.Prepare(ctx, deleteGithubAppSQL, deleteGithubAppSQL); err != nil { + return fmt.Errorf("prepare query 'DeleteGithubApp': %w", err) + } + if _, err := p.Prepare(ctx, insertGithubAppInstallSQL, insertGithubAppInstallSQL); err != nil { + return fmt.Errorf("prepare query 'InsertGithubAppInstall': %w", err) + } if _, err := p.Prepare(ctx, insertIngressAttributesSQL, insertIngressAttributesSQL); err != nil { return fmt.Errorf("prepare query 'InsertIngressAttributes': %w", err) } @@ -1414,8 +1461,8 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, findModuleByIDSQL, findModuleByIDSQL); err != nil { return fmt.Errorf("prepare query 'FindModuleByID': %w", err) } - if _, err := p.Prepare(ctx, findModuleByWebhookIDSQL, findModuleByWebhookIDSQL); err != nil { - return fmt.Errorf("prepare query 'FindModuleByWebhookID': %w", err) + if _, err := p.Prepare(ctx, findModuleByConnectionSQL, findModuleByConnectionSQL); err != nil { + return fmt.Errorf("prepare query 'FindModuleByConnection': %w", err) } if _, err := p.Prepare(ctx, findModuleByModuleVersionIDSQL, findModuleByModuleVersionIDSQL); err != nil { return fmt.Errorf("prepare query 'FindModuleByModuleVersionID': %w", err) @@ -1549,6 +1596,27 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, deleteModuleConnectionByIDSQL, deleteModuleConnectionByIDSQL); err != nil { return fmt.Errorf("prepare query 'DeleteModuleConnectionByID': %w", err) } + if _, err := p.Prepare(ctx, insertRepohookSQL, insertRepohookSQL); err != nil { + return fmt.Errorf("prepare query 'InsertRepohook': %w", err) + } + if _, err := p.Prepare(ctx, updateRepohookVCSIDSQL, updateRepohookVCSIDSQL); err != nil { + return fmt.Errorf("prepare query 'UpdateRepohookVCSID': %w", err) + } + if _, err := p.Prepare(ctx, findRepohooksSQL, findRepohooksSQL); err != nil { + return fmt.Errorf("prepare query 'FindRepohooks': %w", err) + } + if _, err := p.Prepare(ctx, findRepohookByIDSQL, findRepohookByIDSQL); err != nil { + return fmt.Errorf("prepare query 'FindRepohookByID': %w", err) + } + if _, err := p.Prepare(ctx, findRepohookByRepoAndProviderSQL, findRepohookByRepoAndProviderSQL); err != nil { + return fmt.Errorf("prepare query 'FindRepohookByRepoAndProvider': %w", err) + } + if _, err := p.Prepare(ctx, findUnreferencedRepohooksSQL, findUnreferencedRepohooksSQL); err != nil { + return fmt.Errorf("prepare query 'FindUnreferencedRepohooks': %w", err) + } + if _, err := p.Prepare(ctx, deleteRepohookByIDSQL, deleteRepohookByIDSQL); err != nil { + return fmt.Errorf("prepare query 'DeleteRepohookByID': %w", err) + } if _, err := p.Prepare(ctx, insertRunSQL, insertRunSQL); err != nil { return fmt.Errorf("prepare query 'InsertRun': %w", err) } @@ -1777,6 +1845,9 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, findVCSProvidersSQL, findVCSProvidersSQL); err != nil { return fmt.Errorf("prepare query 'FindVCSProviders': %w", err) } + if _, err := p.Prepare(ctx, findVCSProvidersByGithubAppInstallIDSQL, findVCSProvidersByGithubAppInstallIDSQL); err != nil { + return fmt.Errorf("prepare query 'FindVCSProvidersByGithubAppInstallID': %w", err) + } if _, err := p.Prepare(ctx, findVCSProviderSQL, findVCSProviderSQL); err != nil { return fmt.Errorf("prepare query 'FindVCSProvider': %w", err) } @@ -1789,27 +1860,6 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, deleteVCSProviderByIDSQL, deleteVCSProviderByIDSQL); err != nil { return fmt.Errorf("prepare query 'DeleteVCSProviderByID': %w", err) } - if _, err := p.Prepare(ctx, insertWebhookSQL, insertWebhookSQL); err != nil { - return fmt.Errorf("prepare query 'InsertWebhook': %w", err) - } - if _, err := p.Prepare(ctx, updateWebhookVCSIDSQL, updateWebhookVCSIDSQL); err != nil { - return fmt.Errorf("prepare query 'UpdateWebhookVCSID': %w", err) - } - if _, err := p.Prepare(ctx, findWebhooksSQL, findWebhooksSQL); err != nil { - return fmt.Errorf("prepare query 'FindWebhooks': %w", err) - } - if _, err := p.Prepare(ctx, findWebhookByIDSQL, findWebhookByIDSQL); err != nil { - return fmt.Errorf("prepare query 'FindWebhookByID': %w", err) - } - if _, err := p.Prepare(ctx, findWebhookByRepoAndProviderSQL, findWebhookByRepoAndProviderSQL); err != nil { - return fmt.Errorf("prepare query 'FindWebhookByRepoAndProvider': %w", err) - } - if _, err := p.Prepare(ctx, findUnreferencedWebhooksSQL, findUnreferencedWebhooksSQL); err != nil { - return fmt.Errorf("prepare query 'FindUnreferencedWebhooks': %w", err) - } - if _, err := p.Prepare(ctx, deleteWebhookByIDSQL, deleteWebhookByIDSQL); err != nil { - return fmt.Errorf("prepare query 'DeleteWebhookByID': %w", err) - } if _, err := p.Prepare(ctx, insertWorkspaceSQL, insertWorkspaceSQL); err != nil { return fmt.Errorf("prepare query 'InsertWorkspace': %w", err) } @@ -1819,8 +1869,8 @@ func PrepareAllQueries(ctx context.Context, p preparer) error { if _, err := p.Prepare(ctx, countWorkspacesSQL, countWorkspacesSQL); err != nil { return fmt.Errorf("prepare query 'CountWorkspaces': %w", err) } - if _, err := p.Prepare(ctx, findWorkspacesByWebhookIDSQL, findWorkspacesByWebhookIDSQL); err != nil { - return fmt.Errorf("prepare query 'FindWorkspacesByWebhookID': %w", err) + if _, err := p.Prepare(ctx, findWorkspacesByConnectionSQL, findWorkspacesByConnectionSQL); err != nil { + return fmt.Errorf("prepare query 'FindWorkspacesByConnection': %w", err) } if _, err := p.Prepare(ctx, findWorkspacesByUsernameSQL, findWorkspacesByUsernameSQL); err != nil { return fmt.Errorf("prepare query 'FindWorkspacesByUsername': %w", err) @@ -1883,6 +1933,24 @@ type ConfigurationVersionStatusTimestamps struct { Timestamp pgtype.Timestamptz `json:"timestamp"` } +// GithubAppInstalls represents the Postgres composite type "github_app_installs". +type GithubAppInstalls struct { + GithubAppID pgtype.Int8 `json:"github_app_id"` + InstallID pgtype.Int8 `json:"install_id"` + Username pgtype.Text `json:"username"` + Organization pgtype.Text `json:"organization"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` +} + +// GithubApps represents the Postgres composite type "github_apps". +type GithubApps struct { + GithubAppID pgtype.Int8 `json:"github_app_id"` + WebhookSecret pgtype.Text `json:"webhook_secret"` + PrivateKey pgtype.Text `json:"private_key"` + Slug pgtype.Text `json:"slug"` + Organization pgtype.Text `json:"organization"` +} + // IngressAttributes represents the Postgres composite type "ingress_attributes". type IngressAttributes struct { Branch pgtype.Text `json:"branch"` @@ -1922,9 +1990,10 @@ type PhaseStatusTimestamps struct { // RepoConnections represents the Postgres composite type "repo_connections". type RepoConnections struct { - WebhookID pgtype.UUID `json:"webhook_id"` - ModuleID pgtype.Text `json:"module_id"` - WorkspaceID pgtype.Text `json:"workspace_id"` + ModuleID pgtype.Text `json:"module_id"` + WorkspaceID pgtype.Text `json:"workspace_id"` + RepoPath pgtype.Text `json:"repo_path"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` } // Report represents the Postgres composite type "report". @@ -2018,15 +2087,6 @@ type Variables struct { VersionID pgtype.Text `json:"version_id"` } -// Webhooks represents the Postgres composite type "webhooks". -type Webhooks struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` -} - // typeResolver looks up the pgtype.ValueTranscoder by Postgres type name. type typeResolver struct { connInfo *pgtype.ConnInfo // types by Postgres type name @@ -2124,6 +2184,32 @@ func (tr *typeResolver) newConfigurationVersionStatusTimestamps() pgtype.ValueTr ) } +// newGithubAppInstalls creates a new pgtype.ValueTranscoder for the Postgres +// composite type 'github_app_installs'. +func (tr *typeResolver) newGithubAppInstalls() pgtype.ValueTranscoder { + return tr.newCompositeValue( + "github_app_installs", + compositeField{"github_app_id", "int8", &pgtype.Int8{}}, + compositeField{"install_id", "int8", &pgtype.Int8{}}, + compositeField{"username", "text", &pgtype.Text{}}, + compositeField{"organization", "text", &pgtype.Text{}}, + compositeField{"vcs_provider_id", "text", &pgtype.Text{}}, + ) +} + +// newGithubApps creates a new pgtype.ValueTranscoder for the Postgres +// composite type 'github_apps'. +func (tr *typeResolver) newGithubApps() pgtype.ValueTranscoder { + return tr.newCompositeValue( + "github_apps", + compositeField{"github_app_id", "int8", &pgtype.Int8{}}, + compositeField{"webhook_secret", "text", &pgtype.Text{}}, + compositeField{"private_key", "text", &pgtype.Text{}}, + compositeField{"slug", "text", &pgtype.Text{}}, + compositeField{"organization", "text", &pgtype.Text{}}, + ) +} + // newIngressAttributes creates a new pgtype.ValueTranscoder for the Postgres // composite type 'ingress_attributes'. func (tr *typeResolver) newIngressAttributes() pgtype.ValueTranscoder { @@ -2178,9 +2264,10 @@ func (tr *typeResolver) newPhaseStatusTimestamps() pgtype.ValueTranscoder { func (tr *typeResolver) newRepoConnections() pgtype.ValueTranscoder { return tr.newCompositeValue( "repo_connections", - compositeField{"webhook_id", "uuid", &pgtype.UUID{}}, compositeField{"module_id", "text", &pgtype.Text{}}, compositeField{"workspace_id", "text", &pgtype.Text{}}, + compositeField{"repo_path", "text", &pgtype.Text{}}, + compositeField{"vcs_provider_id", "text", &pgtype.Text{}}, ) } @@ -2307,19 +2394,6 @@ func (tr *typeResolver) newVariables() pgtype.ValueTranscoder { ) } -// newWebhooks creates a new pgtype.ValueTranscoder for the Postgres -// composite type 'webhooks'. -func (tr *typeResolver) newWebhooks() pgtype.ValueTranscoder { - return tr.newCompositeValue( - "webhooks", - compositeField{"webhook_id", "uuid", &pgtype.UUID{}}, - compositeField{"vcs_id", "text", &pgtype.Text{}}, - compositeField{"secret", "text", &pgtype.Text{}}, - compositeField{"identifier", "text", &pgtype.Text{}}, - compositeField{"vcs_provider_id", "text", &pgtype.Text{}}, - ) -} - // newConfigurationVersionStatusTimestampsArray creates a new pgtype.ValueTranscoder for the Postgres // '_configuration_version_status_timestamps' array type. func (tr *typeResolver) newConfigurationVersionStatusTimestampsArray() pgtype.ValueTranscoder { diff --git a/internal/sql/pggen/github_app.sql.go b/internal/sql/pggen/github_app.sql.go new file mode 100644 index 000000000..c40a0166f --- /dev/null +++ b/internal/sql/pggen/github_app.sql.go @@ -0,0 +1,180 @@ +// Code generated by pggen. DO NOT EDIT. + +package pggen + +import ( + "context" + "fmt" + + "github.com/jackc/pgconn" + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" +) + +const insertGithubAppSQL = `INSERT INTO github_apps ( + github_app_id, + webhook_secret, + private_key, + slug, + organization +) VALUES ( + $1, + $2, + $3, + $4, + $5 +);` + +type InsertGithubAppParams struct { + GithubAppID pgtype.Int8 + WebhookSecret pgtype.Text + PrivateKey pgtype.Text + Slug pgtype.Text + Organization pgtype.Text +} + +// InsertGithubApp implements Querier.InsertGithubApp. +func (q *DBQuerier) InsertGithubApp(ctx context.Context, params InsertGithubAppParams) (pgconn.CommandTag, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertGithubApp") + cmdTag, err := q.conn.Exec(ctx, insertGithubAppSQL, params.GithubAppID, params.WebhookSecret, params.PrivateKey, params.Slug, params.Organization) + if err != nil { + return cmdTag, fmt.Errorf("exec query InsertGithubApp: %w", err) + } + return cmdTag, err +} + +// InsertGithubAppBatch implements Querier.InsertGithubAppBatch. +func (q *DBQuerier) InsertGithubAppBatch(batch genericBatch, params InsertGithubAppParams) { + batch.Queue(insertGithubAppSQL, params.GithubAppID, params.WebhookSecret, params.PrivateKey, params.Slug, params.Organization) +} + +// InsertGithubAppScan implements Querier.InsertGithubAppScan. +func (q *DBQuerier) InsertGithubAppScan(results pgx.BatchResults) (pgconn.CommandTag, error) { + cmdTag, err := results.Exec() + if err != nil { + return cmdTag, fmt.Errorf("exec InsertGithubAppBatch: %w", err) + } + return cmdTag, err +} + +const findGithubAppSQL = `SELECT * +FROM github_apps;` + +type FindGithubAppRow struct { + GithubAppID pgtype.Int8 `json:"github_app_id"` + WebhookSecret pgtype.Text `json:"webhook_secret"` + PrivateKey pgtype.Text `json:"private_key"` + Slug pgtype.Text `json:"slug"` + Organization pgtype.Text `json:"organization"` +} + +// FindGithubApp implements Querier.FindGithubApp. +func (q *DBQuerier) FindGithubApp(ctx context.Context) (FindGithubAppRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindGithubApp") + row := q.conn.QueryRow(ctx, findGithubAppSQL) + var item FindGithubAppRow + if err := row.Scan(&item.GithubAppID, &item.WebhookSecret, &item.PrivateKey, &item.Slug, &item.Organization); err != nil { + return item, fmt.Errorf("query FindGithubApp: %w", err) + } + return item, nil +} + +// FindGithubAppBatch implements Querier.FindGithubAppBatch. +func (q *DBQuerier) FindGithubAppBatch(batch genericBatch) { + batch.Queue(findGithubAppSQL) +} + +// FindGithubAppScan implements Querier.FindGithubAppScan. +func (q *DBQuerier) FindGithubAppScan(results pgx.BatchResults) (FindGithubAppRow, error) { + row := results.QueryRow() + var item FindGithubAppRow + if err := row.Scan(&item.GithubAppID, &item.WebhookSecret, &item.PrivateKey, &item.Slug, &item.Organization); err != nil { + return item, fmt.Errorf("scan FindGithubAppBatch row: %w", err) + } + return item, nil +} + +const deleteGithubAppSQL = `DELETE +FROM github_apps +WHERE github_app_id = $1 +RETURNING *;` + +type DeleteGithubAppRow struct { + GithubAppID pgtype.Int8 `json:"github_app_id"` + WebhookSecret pgtype.Text `json:"webhook_secret"` + PrivateKey pgtype.Text `json:"private_key"` + Slug pgtype.Text `json:"slug"` + Organization pgtype.Text `json:"organization"` +} + +// DeleteGithubApp implements Querier.DeleteGithubApp. +func (q *DBQuerier) DeleteGithubApp(ctx context.Context, githubAppID pgtype.Int8) (DeleteGithubAppRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "DeleteGithubApp") + row := q.conn.QueryRow(ctx, deleteGithubAppSQL, githubAppID) + var item DeleteGithubAppRow + if err := row.Scan(&item.GithubAppID, &item.WebhookSecret, &item.PrivateKey, &item.Slug, &item.Organization); err != nil { + return item, fmt.Errorf("query DeleteGithubApp: %w", err) + } + return item, nil +} + +// DeleteGithubAppBatch implements Querier.DeleteGithubAppBatch. +func (q *DBQuerier) DeleteGithubAppBatch(batch genericBatch, githubAppID pgtype.Int8) { + batch.Queue(deleteGithubAppSQL, githubAppID) +} + +// DeleteGithubAppScan implements Querier.DeleteGithubAppScan. +func (q *DBQuerier) DeleteGithubAppScan(results pgx.BatchResults) (DeleteGithubAppRow, error) { + row := results.QueryRow() + var item DeleteGithubAppRow + if err := row.Scan(&item.GithubAppID, &item.WebhookSecret, &item.PrivateKey, &item.Slug, &item.Organization); err != nil { + return item, fmt.Errorf("scan DeleteGithubAppBatch row: %w", err) + } + return item, nil +} + +const insertGithubAppInstallSQL = `INSERT INTO github_app_installs ( + github_app_id, + install_id, + username, + organization, + vcs_provider_id +) VALUES ( + $1, + $2, + $3, + $4, + $5 +);` + +type InsertGithubAppInstallParams struct { + GithubAppID pgtype.Int8 + InstallID pgtype.Int8 + Username pgtype.Text + Organization pgtype.Text + VCSProviderID pgtype.Text +} + +// InsertGithubAppInstall implements Querier.InsertGithubAppInstall. +func (q *DBQuerier) InsertGithubAppInstall(ctx context.Context, params InsertGithubAppInstallParams) (pgconn.CommandTag, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertGithubAppInstall") + cmdTag, err := q.conn.Exec(ctx, insertGithubAppInstallSQL, params.GithubAppID, params.InstallID, params.Username, params.Organization, params.VCSProviderID) + if err != nil { + return cmdTag, fmt.Errorf("exec query InsertGithubAppInstall: %w", err) + } + return cmdTag, err +} + +// InsertGithubAppInstallBatch implements Querier.InsertGithubAppInstallBatch. +func (q *DBQuerier) InsertGithubAppInstallBatch(batch genericBatch, params InsertGithubAppInstallParams) { + batch.Queue(insertGithubAppInstallSQL, params.GithubAppID, params.InstallID, params.Username, params.Organization, params.VCSProviderID) +} + +// InsertGithubAppInstallScan implements Querier.InsertGithubAppInstallScan. +func (q *DBQuerier) InsertGithubAppInstallScan(results pgx.BatchResults) (pgconn.CommandTag, error) { + cmdTag, err := results.Exec() + if err != nil { + return cmdTag, fmt.Errorf("exec InsertGithubAppInstallBatch: %w", err) + } + return cmdTag, err +} diff --git a/internal/sql/pggen/module.sql.go b/internal/sql/pggen/module.sql.go index 670266d98..33222375d 100644 --- a/internal/sql/pggen/module.sql.go +++ b/internal/sql/pggen/module.sql.go @@ -134,14 +134,13 @@ const listModulesByOrganizationSQL = `SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.organization_name = $1 ;` @@ -154,7 +153,6 @@ type ListModulesByOrganizationRow struct { Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *RepoConnections `json:"module_connection"` - Webhook *Webhooks `json:"webhook"` Versions []ModuleVersions `json:"versions"` } @@ -168,19 +166,15 @@ func (q *DBQuerier) ListModulesByOrganization(ctx context.Context, organizationN defer rows.Close() items := []ListModulesByOrganizationRow{} moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() for rows.Next() { var item ListModulesByOrganizationRow - if err := rows.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := rows.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return nil, fmt.Errorf("scan ListModulesByOrganization row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) } @@ -206,19 +200,15 @@ func (q *DBQuerier) ListModulesByOrganizationScan(results pgx.BatchResults) ([]L defer rows.Close() items := []ListModulesByOrganizationRow{} moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() for rows.Next() { var item ListModulesByOrganizationRow - if err := rows.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := rows.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return nil, fmt.Errorf("scan ListModulesByOrganizationBatch row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return nil, fmt.Errorf("assign ListModulesByOrganization row: %w", err) } @@ -239,14 +229,13 @@ const findModuleByNameSQL = `SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.organization_name = $1 AND m.name = $2 AND m.provider = $3 @@ -267,7 +256,6 @@ type FindModuleByNameRow struct { Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *RepoConnections `json:"module_connection"` - Webhook *Webhooks `json:"webhook"` Versions []ModuleVersions `json:"versions"` } @@ -277,17 +265,13 @@ func (q *DBQuerier) FindModuleByName(ctx context.Context, params FindModuleByNam row := q.conn.QueryRow(ctx, findModuleByNameSQL, params.OrganizationName, params.Name, params.Provider) var item FindModuleByNameRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("query FindModuleByName: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByName row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByName row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByName row: %w", err) } @@ -304,17 +288,13 @@ func (q *DBQuerier) FindModuleByNameScan(results pgx.BatchResults) (FindModuleBy row := results.QueryRow() var item FindModuleByNameRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("scan FindModuleByNameBatch row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByName row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByName row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByName row: %w", err) } @@ -330,14 +310,13 @@ const findModuleByIDSQL = `SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.module_id = $1 ;` @@ -350,7 +329,6 @@ type FindModuleByIDRow struct { Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *RepoConnections `json:"module_connection"` - Webhook *Webhooks `json:"webhook"` Versions []ModuleVersions `json:"versions"` } @@ -360,17 +338,13 @@ func (q *DBQuerier) FindModuleByID(ctx context.Context, id pgtype.Text) (FindMod row := q.conn.QueryRow(ctx, findModuleByIDSQL, id) var item FindModuleByIDRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("query FindModuleByID: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByID row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByID row: %w", err) } @@ -387,24 +361,20 @@ func (q *DBQuerier) FindModuleByIDScan(results pgx.BatchResults) (FindModuleByID row := results.QueryRow() var item FindModuleByIDRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("scan FindModuleByIDBatch row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByID row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByID row: %w", err) } return item, nil } -const findModuleByWebhookIDSQL = `SELECT +const findModuleByConnectionSQL = `SELECT m.module_id, m.created_at, m.updated_at, @@ -413,18 +383,18 @@ const findModuleByWebhookIDSQL = `SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) -WHERE h.webhook_id = $1 +JOIN repo_connections r USING (module_id) +WHERE r.vcs_provider_id = $1 +AND r.repo_path = $2 ;` -type FindModuleByWebhookIDRow struct { +type FindModuleByConnectionRow struct { ModuleID pgtype.Text `json:"module_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` @@ -433,56 +403,47 @@ type FindModuleByWebhookIDRow struct { Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *RepoConnections `json:"module_connection"` - Webhook *Webhooks `json:"webhook"` Versions []ModuleVersions `json:"versions"` } -// FindModuleByWebhookID implements Querier.FindModuleByWebhookID. -func (q *DBQuerier) FindModuleByWebhookID(ctx context.Context, webhookID pgtype.UUID) (FindModuleByWebhookIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindModuleByWebhookID") - row := q.conn.QueryRow(ctx, findModuleByWebhookIDSQL, webhookID) - var item FindModuleByWebhookIDRow +// FindModuleByConnection implements Querier.FindModuleByConnection. +func (q *DBQuerier) FindModuleByConnection(ctx context.Context, vcsProviderID pgtype.Text, repoPath pgtype.Text) (FindModuleByConnectionRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindModuleByConnection") + row := q.conn.QueryRow(ctx, findModuleByConnectionSQL, vcsProviderID, repoPath) + var item FindModuleByConnectionRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { - return item, fmt.Errorf("query FindModuleByWebhookID: %w", err) + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { + return item, fmt.Errorf("query FindModuleByConnection: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) - } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) + return item, fmt.Errorf("assign FindModuleByConnection row: %w", err) } if err := versionsArray.AssignTo(&item.Versions); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) + return item, fmt.Errorf("assign FindModuleByConnection row: %w", err) } return item, nil } -// FindModuleByWebhookIDBatch implements Querier.FindModuleByWebhookIDBatch. -func (q *DBQuerier) FindModuleByWebhookIDBatch(batch genericBatch, webhookID pgtype.UUID) { - batch.Queue(findModuleByWebhookIDSQL, webhookID) +// FindModuleByConnectionBatch implements Querier.FindModuleByConnectionBatch. +func (q *DBQuerier) FindModuleByConnectionBatch(batch genericBatch, vcsProviderID pgtype.Text, repoPath pgtype.Text) { + batch.Queue(findModuleByConnectionSQL, vcsProviderID, repoPath) } -// FindModuleByWebhookIDScan implements Querier.FindModuleByWebhookIDScan. -func (q *DBQuerier) FindModuleByWebhookIDScan(results pgx.BatchResults) (FindModuleByWebhookIDRow, error) { +// FindModuleByConnectionScan implements Querier.FindModuleByConnectionScan. +func (q *DBQuerier) FindModuleByConnectionScan(results pgx.BatchResults) (FindModuleByConnectionRow, error) { row := results.QueryRow() - var item FindModuleByWebhookIDRow + var item FindModuleByConnectionRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { - return item, fmt.Errorf("scan FindModuleByWebhookIDBatch row: %w", err) + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { + return item, fmt.Errorf("scan FindModuleByConnectionBatch row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) - } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) + return item, fmt.Errorf("assign FindModuleByConnection row: %w", err) } if err := versionsArray.AssignTo(&item.Versions); err != nil { - return item, fmt.Errorf("assign FindModuleByWebhookID row: %w", err) + return item, fmt.Errorf("assign FindModuleByConnection row: %w", err) } return item, nil } @@ -496,7 +457,6 @@ const findModuleByModuleVersionIDSQL = `SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v @@ -504,7 +464,7 @@ const findModuleByModuleVersionIDSQL = `SELECT ) AS versions FROM modules m JOIN module_versions mv USING (module_id) -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE mv.module_version_id = $1 ;` @@ -517,7 +477,6 @@ type FindModuleByModuleVersionIDRow struct { Status pgtype.Text `json:"status"` OrganizationName pgtype.Text `json:"organization_name"` ModuleConnection *RepoConnections `json:"module_connection"` - Webhook *Webhooks `json:"webhook"` Versions []ModuleVersions `json:"versions"` } @@ -527,17 +486,13 @@ func (q *DBQuerier) FindModuleByModuleVersionID(ctx context.Context, moduleVersi row := q.conn.QueryRow(ctx, findModuleByModuleVersionIDSQL, moduleVersionID) var item FindModuleByModuleVersionIDRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("query FindModuleByModuleVersionID: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) } @@ -554,17 +509,13 @@ func (q *DBQuerier) FindModuleByModuleVersionIDScan(results pgx.BatchResults) (F row := results.QueryRow() var item FindModuleByModuleVersionIDRow moduleConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() versionsArray := q.types.newModuleVersionsArray() - if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, webhookRow, versionsArray); err != nil { + if err := row.Scan(&item.ModuleID, &item.CreatedAt, &item.UpdatedAt, &item.Name, &item.Provider, &item.Status, &item.OrganizationName, moduleConnectionRow, versionsArray); err != nil { return item, fmt.Errorf("scan FindModuleByModuleVersionIDBatch row: %w", err) } if err := moduleConnectionRow.AssignTo(&item.ModuleConnection); err != nil { return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) - } if err := versionsArray.AssignTo(&item.Versions); err != nil { return item, fmt.Errorf("assign FindModuleByModuleVersionID row: %w", err) } diff --git a/internal/sql/pggen/repo_connections.sql.go b/internal/sql/pggen/repo_connections.sql.go index 21278d1eb..618bc9ef8 100644 --- a/internal/sql/pggen/repo_connections.sql.go +++ b/internal/sql/pggen/repo_connections.sql.go @@ -12,25 +12,28 @@ import ( ) const insertRepoConnectionSQL = `INSERT INTO repo_connections ( - webhook_id, + vcs_provider_id, + repo_path, workspace_id, module_id ) VALUES ( $1, $2, - $3 + $3, + $4 );` type InsertRepoConnectionParams struct { - WebhookID pgtype.UUID - WorkspaceID pgtype.Text - ModuleID pgtype.Text + VCSProviderID pgtype.Text + RepoPath pgtype.Text + WorkspaceID pgtype.Text + ModuleID pgtype.Text } // InsertRepoConnection implements Querier.InsertRepoConnection. func (q *DBQuerier) InsertRepoConnection(ctx context.Context, params InsertRepoConnectionParams) (pgconn.CommandTag, error) { ctx = context.WithValue(ctx, "pggen_query_name", "InsertRepoConnection") - cmdTag, err := q.conn.Exec(ctx, insertRepoConnectionSQL, params.WebhookID, params.WorkspaceID, params.ModuleID) + cmdTag, err := q.conn.Exec(ctx, insertRepoConnectionSQL, params.VCSProviderID, params.RepoPath, params.WorkspaceID, params.ModuleID) if err != nil { return cmdTag, fmt.Errorf("exec query InsertRepoConnection: %w", err) } @@ -39,7 +42,7 @@ func (q *DBQuerier) InsertRepoConnection(ctx context.Context, params InsertRepoC // InsertRepoConnectionBatch implements Querier.InsertRepoConnectionBatch. func (q *DBQuerier) InsertRepoConnectionBatch(batch genericBatch, params InsertRepoConnectionParams) { - batch.Queue(insertRepoConnectionSQL, params.WebhookID, params.WorkspaceID, params.ModuleID) + batch.Queue(insertRepoConnectionSQL, params.VCSProviderID, params.RepoPath, params.WorkspaceID, params.ModuleID) } // InsertRepoConnectionScan implements Querier.InsertRepoConnectionScan. @@ -54,14 +57,21 @@ func (q *DBQuerier) InsertRepoConnectionScan(results pgx.BatchResults) (pgconn.C const deleteWorkspaceConnectionByIDSQL = `DELETE FROM repo_connections WHERE workspace_id = $1 -RETURNING webhook_id;` +RETURNING *;` + +type DeleteWorkspaceConnectionByIDRow struct { + ModuleID pgtype.Text `json:"module_id"` + WorkspaceID pgtype.Text `json:"workspace_id"` + RepoPath pgtype.Text `json:"repo_path"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` +} // DeleteWorkspaceConnectionByID implements Querier.DeleteWorkspaceConnectionByID. -func (q *DBQuerier) DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (pgtype.UUID, error) { +func (q *DBQuerier) DeleteWorkspaceConnectionByID(ctx context.Context, workspaceID pgtype.Text) (DeleteWorkspaceConnectionByIDRow, error) { ctx = context.WithValue(ctx, "pggen_query_name", "DeleteWorkspaceConnectionByID") row := q.conn.QueryRow(ctx, deleteWorkspaceConnectionByIDSQL, workspaceID) - var item pgtype.UUID - if err := row.Scan(&item); err != nil { + var item DeleteWorkspaceConnectionByIDRow + if err := row.Scan(&item.ModuleID, &item.WorkspaceID, &item.RepoPath, &item.VCSProviderID); err != nil { return item, fmt.Errorf("query DeleteWorkspaceConnectionByID: %w", err) } return item, nil @@ -73,10 +83,10 @@ func (q *DBQuerier) DeleteWorkspaceConnectionByIDBatch(batch genericBatch, works } // DeleteWorkspaceConnectionByIDScan implements Querier.DeleteWorkspaceConnectionByIDScan. -func (q *DBQuerier) DeleteWorkspaceConnectionByIDScan(results pgx.BatchResults) (pgtype.UUID, error) { +func (q *DBQuerier) DeleteWorkspaceConnectionByIDScan(results pgx.BatchResults) (DeleteWorkspaceConnectionByIDRow, error) { row := results.QueryRow() - var item pgtype.UUID - if err := row.Scan(&item); err != nil { + var item DeleteWorkspaceConnectionByIDRow + if err := row.Scan(&item.ModuleID, &item.WorkspaceID, &item.RepoPath, &item.VCSProviderID); err != nil { return item, fmt.Errorf("scan DeleteWorkspaceConnectionByIDBatch row: %w", err) } return item, nil @@ -85,14 +95,21 @@ func (q *DBQuerier) DeleteWorkspaceConnectionByIDScan(results pgx.BatchResults) const deleteModuleConnectionByIDSQL = `DELETE FROM repo_connections WHERE module_id = $1 -RETURNING webhook_id;` +RETURNING *;` + +type DeleteModuleConnectionByIDRow struct { + ModuleID pgtype.Text `json:"module_id"` + WorkspaceID pgtype.Text `json:"workspace_id"` + RepoPath pgtype.Text `json:"repo_path"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` +} // DeleteModuleConnectionByID implements Querier.DeleteModuleConnectionByID. -func (q *DBQuerier) DeleteModuleConnectionByID(ctx context.Context, moduleID pgtype.Text) (pgtype.UUID, error) { +func (q *DBQuerier) DeleteModuleConnectionByID(ctx context.Context, moduleID pgtype.Text) (DeleteModuleConnectionByIDRow, error) { ctx = context.WithValue(ctx, "pggen_query_name", "DeleteModuleConnectionByID") row := q.conn.QueryRow(ctx, deleteModuleConnectionByIDSQL, moduleID) - var item pgtype.UUID - if err := row.Scan(&item); err != nil { + var item DeleteModuleConnectionByIDRow + if err := row.Scan(&item.ModuleID, &item.WorkspaceID, &item.RepoPath, &item.VCSProviderID); err != nil { return item, fmt.Errorf("query DeleteModuleConnectionByID: %w", err) } return item, nil @@ -104,10 +121,10 @@ func (q *DBQuerier) DeleteModuleConnectionByIDBatch(batch genericBatch, moduleID } // DeleteModuleConnectionByIDScan implements Querier.DeleteModuleConnectionByIDScan. -func (q *DBQuerier) DeleteModuleConnectionByIDScan(results pgx.BatchResults) (pgtype.UUID, error) { +func (q *DBQuerier) DeleteModuleConnectionByIDScan(results pgx.BatchResults) (DeleteModuleConnectionByIDRow, error) { row := results.QueryRow() - var item pgtype.UUID - if err := row.Scan(&item); err != nil { + var item DeleteModuleConnectionByIDRow + if err := row.Scan(&item.ModuleID, &item.WorkspaceID, &item.RepoPath, &item.VCSProviderID); err != nil { return item, fmt.Errorf("scan DeleteModuleConnectionByIDBatch row: %w", err) } return item, nil diff --git a/internal/sql/pggen/repohook.sql.go b/internal/sql/pggen/repohook.sql.go new file mode 100644 index 000000000..861680640 --- /dev/null +++ b/internal/sql/pggen/repohook.sql.go @@ -0,0 +1,412 @@ +// Code generated by pggen. DO NOT EDIT. + +package pggen + +import ( + "context" + "fmt" + + "github.com/jackc/pgtype" + "github.com/jackc/pgx/v4" +) + +const insertRepohookSQL = `WITH inserted AS ( + INSERT INTO repohooks ( + repohook_id, + vcs_id, + vcs_provider_id, + secret, + repo_path + ) VALUES ( + $1, + $2, + $3, + $4, + $5 + ) + RETURNING * +) +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM inserted w +JOIN vcs_providers v USING (vcs_provider_id);` + +type InsertRepohookParams struct { + RepohookID pgtype.UUID + VCSID pgtype.Text + VCSProviderID pgtype.Text + Secret pgtype.Text + RepoPath pgtype.Text +} + +type InsertRepohookRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` +} + +// InsertRepohook implements Querier.InsertRepohook. +func (q *DBQuerier) InsertRepohook(ctx context.Context, params InsertRepohookParams) (InsertRepohookRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "InsertRepohook") + row := q.conn.QueryRow(ctx, insertRepohookSQL, params.RepohookID, params.VCSID, params.VCSProviderID, params.Secret, params.RepoPath) + var item InsertRepohookRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return item, fmt.Errorf("query InsertRepohook: %w", err) + } + return item, nil +} + +// InsertRepohookBatch implements Querier.InsertRepohookBatch. +func (q *DBQuerier) InsertRepohookBatch(batch genericBatch, params InsertRepohookParams) { + batch.Queue(insertRepohookSQL, params.RepohookID, params.VCSID, params.VCSProviderID, params.Secret, params.RepoPath) +} + +// InsertRepohookScan implements Querier.InsertRepohookScan. +func (q *DBQuerier) InsertRepohookScan(results pgx.BatchResults) (InsertRepohookRow, error) { + row := results.QueryRow() + var item InsertRepohookRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return item, fmt.Errorf("scan InsertRepohookBatch row: %w", err) + } + return item, nil +} + +const updateRepohookVCSIDSQL = `UPDATE repohooks +SET vcs_id = $1 +WHERE repohook_id = $2 +RETURNING *;` + +type UpdateRepohookVCSIDRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` +} + +// UpdateRepohookVCSID implements Querier.UpdateRepohookVCSID. +func (q *DBQuerier) UpdateRepohookVCSID(ctx context.Context, vcsID pgtype.Text, repohookID pgtype.UUID) (UpdateRepohookVCSIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "UpdateRepohookVCSID") + row := q.conn.QueryRow(ctx, updateRepohookVCSIDSQL, vcsID, repohookID) + var item UpdateRepohookVCSIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.Secret, &item.RepoPath, &item.VCSProviderID); err != nil { + return item, fmt.Errorf("query UpdateRepohookVCSID: %w", err) + } + return item, nil +} + +// UpdateRepohookVCSIDBatch implements Querier.UpdateRepohookVCSIDBatch. +func (q *DBQuerier) UpdateRepohookVCSIDBatch(batch genericBatch, vcsID pgtype.Text, repohookID pgtype.UUID) { + batch.Queue(updateRepohookVCSIDSQL, vcsID, repohookID) +} + +// UpdateRepohookVCSIDScan implements Querier.UpdateRepohookVCSIDScan. +func (q *DBQuerier) UpdateRepohookVCSIDScan(results pgx.BatchResults) (UpdateRepohookVCSIDRow, error) { + row := results.QueryRow() + var item UpdateRepohookVCSIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.Secret, &item.RepoPath, &item.VCSProviderID); err != nil { + return item, fmt.Errorf("scan UpdateRepohookVCSIDBatch row: %w", err) + } + return item, nil +} + +const findRepohooksSQL = `SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id);` + +type FindRepohooksRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` +} + +// FindRepohooks implements Querier.FindRepohooks. +func (q *DBQuerier) FindRepohooks(ctx context.Context) ([]FindRepohooksRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindRepohooks") + rows, err := q.conn.Query(ctx, findRepohooksSQL) + if err != nil { + return nil, fmt.Errorf("query FindRepohooks: %w", err) + } + defer rows.Close() + items := []FindRepohooksRow{} + for rows.Next() { + var item FindRepohooksRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindRepohooks row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindRepohooks rows: %w", err) + } + return items, err +} + +// FindRepohooksBatch implements Querier.FindRepohooksBatch. +func (q *DBQuerier) FindRepohooksBatch(batch genericBatch) { + batch.Queue(findRepohooksSQL) +} + +// FindRepohooksScan implements Querier.FindRepohooksScan. +func (q *DBQuerier) FindRepohooksScan(results pgx.BatchResults) ([]FindRepohooksRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindRepohooksBatch: %w", err) + } + defer rows.Close() + items := []FindRepohooksRow{} + for rows.Next() { + var item FindRepohooksRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindRepohooksBatch row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindRepohooksBatch rows: %w", err) + } + return items, err +} + +const findRepohookByIDSQL = `SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE w.repohook_id = $1;` + +type FindRepohookByIDRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` +} + +// FindRepohookByID implements Querier.FindRepohookByID. +func (q *DBQuerier) FindRepohookByID(ctx context.Context, repohookID pgtype.UUID) (FindRepohookByIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindRepohookByID") + row := q.conn.QueryRow(ctx, findRepohookByIDSQL, repohookID) + var item FindRepohookByIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return item, fmt.Errorf("query FindRepohookByID: %w", err) + } + return item, nil +} + +// FindRepohookByIDBatch implements Querier.FindRepohookByIDBatch. +func (q *DBQuerier) FindRepohookByIDBatch(batch genericBatch, repohookID pgtype.UUID) { + batch.Queue(findRepohookByIDSQL, repohookID) +} + +// FindRepohookByIDScan implements Querier.FindRepohookByIDScan. +func (q *DBQuerier) FindRepohookByIDScan(results pgx.BatchResults) (FindRepohookByIDRow, error) { + row := results.QueryRow() + var item FindRepohookByIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return item, fmt.Errorf("scan FindRepohookByIDBatch row: %w", err) + } + return item, nil +} + +const findRepohookByRepoAndProviderSQL = `SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE repo_path = $1 +AND vcs_provider_id = $2;` + +type FindRepohookByRepoAndProviderRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` +} + +// FindRepohookByRepoAndProvider implements Querier.FindRepohookByRepoAndProvider. +func (q *DBQuerier) FindRepohookByRepoAndProvider(ctx context.Context, repoPath pgtype.Text, vcsProviderID pgtype.Text) ([]FindRepohookByRepoAndProviderRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindRepohookByRepoAndProvider") + rows, err := q.conn.Query(ctx, findRepohookByRepoAndProviderSQL, repoPath, vcsProviderID) + if err != nil { + return nil, fmt.Errorf("query FindRepohookByRepoAndProvider: %w", err) + } + defer rows.Close() + items := []FindRepohookByRepoAndProviderRow{} + for rows.Next() { + var item FindRepohookByRepoAndProviderRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindRepohookByRepoAndProvider row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindRepohookByRepoAndProvider rows: %w", err) + } + return items, err +} + +// FindRepohookByRepoAndProviderBatch implements Querier.FindRepohookByRepoAndProviderBatch. +func (q *DBQuerier) FindRepohookByRepoAndProviderBatch(batch genericBatch, repoPath pgtype.Text, vcsProviderID pgtype.Text) { + batch.Queue(findRepohookByRepoAndProviderSQL, repoPath, vcsProviderID) +} + +// FindRepohookByRepoAndProviderScan implements Querier.FindRepohookByRepoAndProviderScan. +func (q *DBQuerier) FindRepohookByRepoAndProviderScan(results pgx.BatchResults) ([]FindRepohookByRepoAndProviderRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindRepohookByRepoAndProviderBatch: %w", err) + } + defer rows.Close() + items := []FindRepohookByRepoAndProviderRow{} + for rows.Next() { + var item FindRepohookByRepoAndProviderRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindRepohookByRepoAndProviderBatch row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindRepohookByRepoAndProviderBatch rows: %w", err) + } + return items, err +} + +const findUnreferencedRepohooksSQL = `SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE NOT EXISTS ( + SELECT FROM repo_connections rc + WHERE rc.vcs_provider_id = w.vcs_provider_id + AND rc.repo_path = w.repo_path +);` + +type FindUnreferencedRepohooksRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSKind pgtype.Text `json:"vcs_kind"` +} + +// FindUnreferencedRepohooks implements Querier.FindUnreferencedRepohooks. +func (q *DBQuerier) FindUnreferencedRepohooks(ctx context.Context) ([]FindUnreferencedRepohooksRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindUnreferencedRepohooks") + rows, err := q.conn.Query(ctx, findUnreferencedRepohooksSQL) + if err != nil { + return nil, fmt.Errorf("query FindUnreferencedRepohooks: %w", err) + } + defer rows.Close() + items := []FindUnreferencedRepohooksRow{} + for rows.Next() { + var item FindUnreferencedRepohooksRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindUnreferencedRepohooks row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindUnreferencedRepohooks rows: %w", err) + } + return items, err +} + +// FindUnreferencedRepohooksBatch implements Querier.FindUnreferencedRepohooksBatch. +func (q *DBQuerier) FindUnreferencedRepohooksBatch(batch genericBatch) { + batch.Queue(findUnreferencedRepohooksSQL) +} + +// FindUnreferencedRepohooksScan implements Querier.FindUnreferencedRepohooksScan. +func (q *DBQuerier) FindUnreferencedRepohooksScan(results pgx.BatchResults) ([]FindUnreferencedRepohooksRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindUnreferencedRepohooksBatch: %w", err) + } + defer rows.Close() + items := []FindUnreferencedRepohooksRow{} + for rows.Next() { + var item FindUnreferencedRepohooksRow + if err := rows.Scan(&item.RepohookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.RepoPath, &item.VCSKind); err != nil { + return nil, fmt.Errorf("scan FindUnreferencedRepohooksBatch row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindUnreferencedRepohooksBatch rows: %w", err) + } + return items, err +} + +const deleteRepohookByIDSQL = `DELETE +FROM repohooks +WHERE repohook_id = $1 +RETURNING *;` + +type DeleteRepohookByIDRow struct { + RepohookID pgtype.UUID `json:"repohook_id"` + VCSID pgtype.Text `json:"vcs_id"` + Secret pgtype.Text `json:"secret"` + RepoPath pgtype.Text `json:"repo_path"` + VCSProviderID pgtype.Text `json:"vcs_provider_id"` +} + +// DeleteRepohookByID implements Querier.DeleteRepohookByID. +func (q *DBQuerier) DeleteRepohookByID(ctx context.Context, repohookID pgtype.UUID) (DeleteRepohookByIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "DeleteRepohookByID") + row := q.conn.QueryRow(ctx, deleteRepohookByIDSQL, repohookID) + var item DeleteRepohookByIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.Secret, &item.RepoPath, &item.VCSProviderID); err != nil { + return item, fmt.Errorf("query DeleteRepohookByID: %w", err) + } + return item, nil +} + +// DeleteRepohookByIDBatch implements Querier.DeleteRepohookByIDBatch. +func (q *DBQuerier) DeleteRepohookByIDBatch(batch genericBatch, repohookID pgtype.UUID) { + batch.Queue(deleteRepohookByIDSQL, repohookID) +} + +// DeleteRepohookByIDScan implements Querier.DeleteRepohookByIDScan. +func (q *DBQuerier) DeleteRepohookByIDScan(results pgx.BatchResults) (DeleteRepohookByIDRow, error) { + row := results.QueryRow() + var item DeleteRepohookByIDRow + if err := row.Scan(&item.RepohookID, &item.VCSID, &item.Secret, &item.RepoPath, &item.VCSProviderID); err != nil { + return item, fmt.Errorf("scan DeleteRepohookByIDBatch row: %w", err) + } + return item, nil +} diff --git a/internal/sql/pggen/vcs_provider.sql.go b/internal/sql/pggen/vcs_provider.sql.go index 77e43bb2f..36ff27f66 100644 --- a/internal/sql/pggen/vcs_provider.sql.go +++ b/internal/sql/pggen/vcs_provider.sql.go @@ -13,10 +13,11 @@ import ( const insertVCSProviderSQL = `INSERT INTO vcs_providers ( vcs_provider_id, - token, created_at, name, - cloud, + vcs_kind, + token, + github_app_id, organization_name ) VALUES ( $1, @@ -24,22 +25,24 @@ const insertVCSProviderSQL = `INSERT INTO vcs_providers ( $3, $4, $5, - $6 + $6, + $7 );` type InsertVCSProviderParams struct { VCSProviderID pgtype.Text - Token pgtype.Text CreatedAt pgtype.Timestamptz Name pgtype.Text - Cloud pgtype.Text + VCSKind pgtype.Text + Token pgtype.Text + GithubAppID pgtype.Int8 OrganizationName pgtype.Text } // InsertVCSProvider implements Querier.InsertVCSProvider. func (q *DBQuerier) InsertVCSProvider(ctx context.Context, params InsertVCSProviderParams) (pgconn.CommandTag, error) { ctx = context.WithValue(ctx, "pggen_query_name", "InsertVCSProvider") - cmdTag, err := q.conn.Exec(ctx, insertVCSProviderSQL, params.VCSProviderID, params.Token, params.CreatedAt, params.Name, params.Cloud, params.OrganizationName) + cmdTag, err := q.conn.Exec(ctx, insertVCSProviderSQL, params.VCSProviderID, params.CreatedAt, params.Name, params.VCSKind, params.Token, params.GithubAppID, params.OrganizationName) if err != nil { return cmdTag, fmt.Errorf("exec query InsertVCSProvider: %w", err) } @@ -48,7 +51,7 @@ func (q *DBQuerier) InsertVCSProvider(ctx context.Context, params InsertVCSProvi // InsertVCSProviderBatch implements Querier.InsertVCSProviderBatch. func (q *DBQuerier) InsertVCSProviderBatch(batch genericBatch, params InsertVCSProviderParams) { - batch.Queue(insertVCSProviderSQL, params.VCSProviderID, params.Token, params.CreatedAt, params.Name, params.Cloud, params.OrganizationName) + batch.Queue(insertVCSProviderSQL, params.VCSProviderID, params.CreatedAt, params.Name, params.VCSKind, params.Token, params.GithubAppID, params.OrganizationName) } // InsertVCSProviderScan implements Querier.InsertVCSProviderScan. @@ -60,9 +63,13 @@ func (q *DBQuerier) InsertVCSProviderScan(results pgx.BatchResults) (pgconn.Comm return cmdTag, err } -const findVCSProvidersByOrganizationSQL = `SELECT * -FROM vcs_providers -WHERE organization_name = $1 +const findVCSProvidersByOrganizationSQL = `SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.organization_name = $1 ;` type FindVCSProvidersByOrganizationRow struct { @@ -70,8 +77,11 @@ type FindVCSProvidersByOrganizationRow struct { Token pgtype.Text `json:"token"` CreatedAt pgtype.Timestamptz `json:"created_at"` Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` + VCSKind pgtype.Text `json:"vcs_kind"` OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *GithubApps `json:"github_app"` + GithubAppInstall *GithubAppInstalls `json:"github_app_install"` } // FindVCSProvidersByOrganization implements Querier.FindVCSProvidersByOrganization. @@ -83,11 +93,19 @@ func (q *DBQuerier) FindVCSProvidersByOrganization(ctx context.Context, organiza } defer rows.Close() items := []FindVCSProvidersByOrganizationRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() for rows.Next() { var item FindVCSProvidersByOrganizationRow - if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return nil, fmt.Errorf("scan FindVCSProvidersByOrganization row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByOrganization row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByOrganization row: %w", err) + } items = append(items, item) } if err := rows.Err(); err != nil { @@ -109,11 +127,19 @@ func (q *DBQuerier) FindVCSProvidersByOrganizationScan(results pgx.BatchResults) } defer rows.Close() items := []FindVCSProvidersByOrganizationRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() for rows.Next() { var item FindVCSProvidersByOrganizationRow - if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return nil, fmt.Errorf("scan FindVCSProvidersByOrganizationBatch row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByOrganization row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByOrganization row: %w", err) + } items = append(items, item) } if err := rows.Err(); err != nil { @@ -122,8 +148,12 @@ func (q *DBQuerier) FindVCSProvidersByOrganizationScan(results pgx.BatchResults) return items, err } -const findVCSProvidersSQL = `SELECT * -FROM vcs_providers +const findVCSProvidersSQL = `SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) ;` type FindVCSProvidersRow struct { @@ -131,8 +161,11 @@ type FindVCSProvidersRow struct { Token pgtype.Text `json:"token"` CreatedAt pgtype.Timestamptz `json:"created_at"` Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` + VCSKind pgtype.Text `json:"vcs_kind"` OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *GithubApps `json:"github_app"` + GithubAppInstall *GithubAppInstalls `json:"github_app_install"` } // FindVCSProviders implements Querier.FindVCSProviders. @@ -144,11 +177,19 @@ func (q *DBQuerier) FindVCSProviders(ctx context.Context) ([]FindVCSProvidersRow } defer rows.Close() items := []FindVCSProvidersRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() for rows.Next() { var item FindVCSProvidersRow - if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return nil, fmt.Errorf("scan FindVCSProviders row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProviders row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProviders row: %w", err) + } items = append(items, item) } if err := rows.Err(); err != nil { @@ -170,11 +211,19 @@ func (q *DBQuerier) FindVCSProvidersScan(results pgx.BatchResults) ([]FindVCSPro } defer rows.Close() items := []FindVCSProvidersRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() for rows.Next() { var item FindVCSProvidersRow - if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return nil, fmt.Errorf("scan FindVCSProvidersBatch row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProviders row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProviders row: %w", err) + } items = append(items, item) } if err := rows.Err(); err != nil { @@ -183,9 +232,98 @@ func (q *DBQuerier) FindVCSProvidersScan(results pgx.BatchResults) ([]FindVCSPro return items, err } -const findVCSProviderSQL = `SELECT * -FROM vcs_providers -WHERE vcs_provider_id = $1 +const findVCSProvidersByGithubAppInstallIDSQL = `SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE gi.install_id = $1 +;` + +type FindVCSProvidersByGithubAppInstallIDRow struct { + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Token pgtype.Text `json:"token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Name pgtype.Text `json:"name"` + VCSKind pgtype.Text `json:"vcs_kind"` + OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *GithubApps `json:"github_app"` + GithubAppInstall *GithubAppInstalls `json:"github_app_install"` +} + +// FindVCSProvidersByGithubAppInstallID implements Querier.FindVCSProvidersByGithubAppInstallID. +func (q *DBQuerier) FindVCSProvidersByGithubAppInstallID(ctx context.Context, installID pgtype.Int8) ([]FindVCSProvidersByGithubAppInstallIDRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindVCSProvidersByGithubAppInstallID") + rows, err := q.conn.Query(ctx, findVCSProvidersByGithubAppInstallIDSQL, installID) + if err != nil { + return nil, fmt.Errorf("query FindVCSProvidersByGithubAppInstallID: %w", err) + } + defer rows.Close() + items := []FindVCSProvidersByGithubAppInstallIDRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + for rows.Next() { + var item FindVCSProvidersByGithubAppInstallIDRow + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { + return nil, fmt.Errorf("scan FindVCSProvidersByGithubAppInstallID row: %w", err) + } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByGithubAppInstallID row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByGithubAppInstallID row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindVCSProvidersByGithubAppInstallID rows: %w", err) + } + return items, err +} + +// FindVCSProvidersByGithubAppInstallIDBatch implements Querier.FindVCSProvidersByGithubAppInstallIDBatch. +func (q *DBQuerier) FindVCSProvidersByGithubAppInstallIDBatch(batch genericBatch, installID pgtype.Int8) { + batch.Queue(findVCSProvidersByGithubAppInstallIDSQL, installID) +} + +// FindVCSProvidersByGithubAppInstallIDScan implements Querier.FindVCSProvidersByGithubAppInstallIDScan. +func (q *DBQuerier) FindVCSProvidersByGithubAppInstallIDScan(results pgx.BatchResults) ([]FindVCSProvidersByGithubAppInstallIDRow, error) { + rows, err := results.Query() + if err != nil { + return nil, fmt.Errorf("query FindVCSProvidersByGithubAppInstallIDBatch: %w", err) + } + defer rows.Close() + items := []FindVCSProvidersByGithubAppInstallIDRow{} + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + for rows.Next() { + var item FindVCSProvidersByGithubAppInstallIDRow + if err := rows.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { + return nil, fmt.Errorf("scan FindVCSProvidersByGithubAppInstallIDBatch row: %w", err) + } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByGithubAppInstallID row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return nil, fmt.Errorf("assign FindVCSProvidersByGithubAppInstallID row: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("close FindVCSProvidersByGithubAppInstallIDBatch rows: %w", err) + } + return items, err +} + +const findVCSProviderSQL = `SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.vcs_provider_id = $1 ;` type FindVCSProviderRow struct { @@ -193,8 +331,11 @@ type FindVCSProviderRow struct { Token pgtype.Text `json:"token"` CreatedAt pgtype.Timestamptz `json:"created_at"` Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` + VCSKind pgtype.Text `json:"vcs_kind"` OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *GithubApps `json:"github_app"` + GithubAppInstall *GithubAppInstalls `json:"github_app_install"` } // FindVCSProvider implements Querier.FindVCSProvider. @@ -202,9 +343,17 @@ func (q *DBQuerier) FindVCSProvider(ctx context.Context, vcsProviderID pgtype.Te ctx = context.WithValue(ctx, "pggen_query_name", "FindVCSProvider") row := q.conn.QueryRow(ctx, findVCSProviderSQL, vcsProviderID) var item FindVCSProviderRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return item, fmt.Errorf("query FindVCSProvider: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return item, fmt.Errorf("assign FindVCSProvider row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return item, fmt.Errorf("assign FindVCSProvider row: %w", err) + } return item, nil } @@ -217,16 +366,28 @@ func (q *DBQuerier) FindVCSProviderBatch(batch genericBatch, vcsProviderID pgtyp func (q *DBQuerier) FindVCSProviderScan(results pgx.BatchResults) (FindVCSProviderRow, error) { row := results.QueryRow() var item FindVCSProviderRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return item, fmt.Errorf("scan FindVCSProviderBatch row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return item, fmt.Errorf("assign FindVCSProvider row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return item, fmt.Errorf("assign FindVCSProvider row: %w", err) + } return item, nil } -const findVCSProviderForUpdateSQL = `SELECT * -FROM vcs_providers -WHERE vcs_provider_id = $1 -FOR UPDATE +const findVCSProviderForUpdateSQL = `SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.vcs_provider_id = $1 +FOR UPDATE OF v ;` type FindVCSProviderForUpdateRow struct { @@ -234,8 +395,11 @@ type FindVCSProviderForUpdateRow struct { Token pgtype.Text `json:"token"` CreatedAt pgtype.Timestamptz `json:"created_at"` Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` + VCSKind pgtype.Text `json:"vcs_kind"` OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *GithubApps `json:"github_app"` + GithubAppInstall *GithubAppInstalls `json:"github_app_install"` } // FindVCSProviderForUpdate implements Querier.FindVCSProviderForUpdate. @@ -243,9 +407,17 @@ func (q *DBQuerier) FindVCSProviderForUpdate(ctx context.Context, vcsProviderID ctx = context.WithValue(ctx, "pggen_query_name", "FindVCSProviderForUpdate") row := q.conn.QueryRow(ctx, findVCSProviderForUpdateSQL, vcsProviderID) var item FindVCSProviderForUpdateRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return item, fmt.Errorf("query FindVCSProviderForUpdate: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return item, fmt.Errorf("assign FindVCSProviderForUpdate row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return item, fmt.Errorf("assign FindVCSProviderForUpdate row: %w", err) + } return item, nil } @@ -258,9 +430,17 @@ func (q *DBQuerier) FindVCSProviderForUpdateBatch(batch genericBatch, vcsProvide func (q *DBQuerier) FindVCSProviderForUpdateScan(results pgx.BatchResults) (FindVCSProviderForUpdateRow, error) { row := results.QueryRow() var item FindVCSProviderForUpdateRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + githubAppRow := q.types.newGithubApps() + githubAppInstallRow := q.types.newGithubAppInstalls() + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID, githubAppRow, githubAppInstallRow); err != nil { return item, fmt.Errorf("scan FindVCSProviderForUpdateBatch row: %w", err) } + if err := githubAppRow.AssignTo(&item.GithubApp); err != nil { + return item, fmt.Errorf("assign FindVCSProviderForUpdate row: %w", err) + } + if err := githubAppInstallRow.AssignTo(&item.GithubAppInstall); err != nil { + return item, fmt.Errorf("assign FindVCSProviderForUpdate row: %w", err) + } return item, nil } @@ -281,8 +461,9 @@ type UpdateVCSProviderRow struct { Token pgtype.Text `json:"token"` CreatedAt pgtype.Timestamptz `json:"created_at"` Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` + VCSKind pgtype.Text `json:"vcs_kind"` OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` } // UpdateVCSProvider implements Querier.UpdateVCSProvider. @@ -290,7 +471,7 @@ func (q *DBQuerier) UpdateVCSProvider(ctx context.Context, params UpdateVCSProvi ctx = context.WithValue(ctx, "pggen_query_name", "UpdateVCSProvider") row := q.conn.QueryRow(ctx, updateVCSProviderSQL, params.Name, params.Token, params.VCSProviderID) var item UpdateVCSProviderRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID); err != nil { return item, fmt.Errorf("query UpdateVCSProvider: %w", err) } return item, nil @@ -305,7 +486,7 @@ func (q *DBQuerier) UpdateVCSProviderBatch(batch genericBatch, params UpdateVCSP func (q *DBQuerier) UpdateVCSProviderScan(results pgx.BatchResults) (UpdateVCSProviderRow, error) { row := results.QueryRow() var item UpdateVCSProviderRow - if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.Cloud, &item.OrganizationName); err != nil { + if err := row.Scan(&item.VCSProviderID, &item.Token, &item.CreatedAt, &item.Name, &item.VCSKind, &item.OrganizationName, &item.GithubAppID); err != nil { return item, fmt.Errorf("scan UpdateVCSProviderBatch row: %w", err) } return item, nil diff --git a/internal/sql/pggen/webhook.sql.go b/internal/sql/pggen/webhook.sql.go deleted file mode 100644 index 43ef4e55d..000000000 --- a/internal/sql/pggen/webhook.sql.go +++ /dev/null @@ -1,411 +0,0 @@ -// Code generated by pggen. DO NOT EDIT. - -package pggen - -import ( - "context" - "fmt" - - "github.com/jackc/pgtype" - "github.com/jackc/pgx/v4" -) - -const insertWebhookSQL = `WITH inserted AS ( - INSERT INTO webhooks ( - webhook_id, - vcs_id, - vcs_provider_id, - secret, - identifier - ) VALUES ( - $1, - $2, - $3, - $4, - $5 - ) - RETURNING * -) -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM inserted w -JOIN vcs_providers v USING (vcs_provider_id);` - -type InsertWebhookParams struct { - WebhookID pgtype.UUID - VCSID pgtype.Text - VCSProviderID pgtype.Text - Secret pgtype.Text - Identifier pgtype.Text -} - -type InsertWebhookRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` -} - -// InsertWebhook implements Querier.InsertWebhook. -func (q *DBQuerier) InsertWebhook(ctx context.Context, params InsertWebhookParams) (InsertWebhookRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "InsertWebhook") - row := q.conn.QueryRow(ctx, insertWebhookSQL, params.WebhookID, params.VCSID, params.VCSProviderID, params.Secret, params.Identifier) - var item InsertWebhookRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return item, fmt.Errorf("query InsertWebhook: %w", err) - } - return item, nil -} - -// InsertWebhookBatch implements Querier.InsertWebhookBatch. -func (q *DBQuerier) InsertWebhookBatch(batch genericBatch, params InsertWebhookParams) { - batch.Queue(insertWebhookSQL, params.WebhookID, params.VCSID, params.VCSProviderID, params.Secret, params.Identifier) -} - -// InsertWebhookScan implements Querier.InsertWebhookScan. -func (q *DBQuerier) InsertWebhookScan(results pgx.BatchResults) (InsertWebhookRow, error) { - row := results.QueryRow() - var item InsertWebhookRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return item, fmt.Errorf("scan InsertWebhookBatch row: %w", err) - } - return item, nil -} - -const updateWebhookVCSIDSQL = `UPDATE webhooks -SET vcs_id = $1 -WHERE webhook_id = $2 -RETURNING *;` - -type UpdateWebhookVCSIDRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` -} - -// UpdateWebhookVCSID implements Querier.UpdateWebhookVCSID. -func (q *DBQuerier) UpdateWebhookVCSID(ctx context.Context, vcsID pgtype.Text, webhookID pgtype.UUID) (UpdateWebhookVCSIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "UpdateWebhookVCSID") - row := q.conn.QueryRow(ctx, updateWebhookVCSIDSQL, vcsID, webhookID) - var item UpdateWebhookVCSIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.Secret, &item.Identifier, &item.VCSProviderID); err != nil { - return item, fmt.Errorf("query UpdateWebhookVCSID: %w", err) - } - return item, nil -} - -// UpdateWebhookVCSIDBatch implements Querier.UpdateWebhookVCSIDBatch. -func (q *DBQuerier) UpdateWebhookVCSIDBatch(batch genericBatch, vcsID pgtype.Text, webhookID pgtype.UUID) { - batch.Queue(updateWebhookVCSIDSQL, vcsID, webhookID) -} - -// UpdateWebhookVCSIDScan implements Querier.UpdateWebhookVCSIDScan. -func (q *DBQuerier) UpdateWebhookVCSIDScan(results pgx.BatchResults) (UpdateWebhookVCSIDRow, error) { - row := results.QueryRow() - var item UpdateWebhookVCSIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.Secret, &item.Identifier, &item.VCSProviderID); err != nil { - return item, fmt.Errorf("scan UpdateWebhookVCSIDBatch row: %w", err) - } - return item, nil -} - -const findWebhooksSQL = `SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id);` - -type FindWebhooksRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` -} - -// FindWebhooks implements Querier.FindWebhooks. -func (q *DBQuerier) FindWebhooks(ctx context.Context) ([]FindWebhooksRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindWebhooks") - rows, err := q.conn.Query(ctx, findWebhooksSQL) - if err != nil { - return nil, fmt.Errorf("query FindWebhooks: %w", err) - } - defer rows.Close() - items := []FindWebhooksRow{} - for rows.Next() { - var item FindWebhooksRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindWebhooks row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWebhooks rows: %w", err) - } - return items, err -} - -// FindWebhooksBatch implements Querier.FindWebhooksBatch. -func (q *DBQuerier) FindWebhooksBatch(batch genericBatch) { - batch.Queue(findWebhooksSQL) -} - -// FindWebhooksScan implements Querier.FindWebhooksScan. -func (q *DBQuerier) FindWebhooksScan(results pgx.BatchResults) ([]FindWebhooksRow, error) { - rows, err := results.Query() - if err != nil { - return nil, fmt.Errorf("query FindWebhooksBatch: %w", err) - } - defer rows.Close() - items := []FindWebhooksRow{} - for rows.Next() { - var item FindWebhooksRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindWebhooksBatch row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWebhooksBatch rows: %w", err) - } - return items, err -} - -const findWebhookByIDSQL = `SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE w.webhook_id = $1;` - -type FindWebhookByIDRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` -} - -// FindWebhookByID implements Querier.FindWebhookByID. -func (q *DBQuerier) FindWebhookByID(ctx context.Context, webhookID pgtype.UUID) (FindWebhookByIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindWebhookByID") - row := q.conn.QueryRow(ctx, findWebhookByIDSQL, webhookID) - var item FindWebhookByIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return item, fmt.Errorf("query FindWebhookByID: %w", err) - } - return item, nil -} - -// FindWebhookByIDBatch implements Querier.FindWebhookByIDBatch. -func (q *DBQuerier) FindWebhookByIDBatch(batch genericBatch, webhookID pgtype.UUID) { - batch.Queue(findWebhookByIDSQL, webhookID) -} - -// FindWebhookByIDScan implements Querier.FindWebhookByIDScan. -func (q *DBQuerier) FindWebhookByIDScan(results pgx.BatchResults) (FindWebhookByIDRow, error) { - row := results.QueryRow() - var item FindWebhookByIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return item, fmt.Errorf("scan FindWebhookByIDBatch row: %w", err) - } - return item, nil -} - -const findWebhookByRepoAndProviderSQL = `SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE identifier = $1 -AND vcs_provider_id = $2;` - -type FindWebhookByRepoAndProviderRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` -} - -// FindWebhookByRepoAndProvider implements Querier.FindWebhookByRepoAndProvider. -func (q *DBQuerier) FindWebhookByRepoAndProvider(ctx context.Context, identifier pgtype.Text, vcsProviderID pgtype.Text) ([]FindWebhookByRepoAndProviderRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindWebhookByRepoAndProvider") - rows, err := q.conn.Query(ctx, findWebhookByRepoAndProviderSQL, identifier, vcsProviderID) - if err != nil { - return nil, fmt.Errorf("query FindWebhookByRepoAndProvider: %w", err) - } - defer rows.Close() - items := []FindWebhookByRepoAndProviderRow{} - for rows.Next() { - var item FindWebhookByRepoAndProviderRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindWebhookByRepoAndProvider row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWebhookByRepoAndProvider rows: %w", err) - } - return items, err -} - -// FindWebhookByRepoAndProviderBatch implements Querier.FindWebhookByRepoAndProviderBatch. -func (q *DBQuerier) FindWebhookByRepoAndProviderBatch(batch genericBatch, identifier pgtype.Text, vcsProviderID pgtype.Text) { - batch.Queue(findWebhookByRepoAndProviderSQL, identifier, vcsProviderID) -} - -// FindWebhookByRepoAndProviderScan implements Querier.FindWebhookByRepoAndProviderScan. -func (q *DBQuerier) FindWebhookByRepoAndProviderScan(results pgx.BatchResults) ([]FindWebhookByRepoAndProviderRow, error) { - rows, err := results.Query() - if err != nil { - return nil, fmt.Errorf("query FindWebhookByRepoAndProviderBatch: %w", err) - } - defer rows.Close() - items := []FindWebhookByRepoAndProviderRow{} - for rows.Next() { - var item FindWebhookByRepoAndProviderRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindWebhookByRepoAndProviderBatch row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWebhookByRepoAndProviderBatch rows: %w", err) - } - return items, err -} - -const findUnreferencedWebhooksSQL = `SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE NOT EXISTS ( - SELECT FROM repo_connections c - WHERE c.webhook_id = w.webhook_id -);` - -type FindUnreferencedWebhooksRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - Cloud pgtype.Text `json:"cloud"` -} - -// FindUnreferencedWebhooks implements Querier.FindUnreferencedWebhooks. -func (q *DBQuerier) FindUnreferencedWebhooks(ctx context.Context) ([]FindUnreferencedWebhooksRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindUnreferencedWebhooks") - rows, err := q.conn.Query(ctx, findUnreferencedWebhooksSQL) - if err != nil { - return nil, fmt.Errorf("query FindUnreferencedWebhooks: %w", err) - } - defer rows.Close() - items := []FindUnreferencedWebhooksRow{} - for rows.Next() { - var item FindUnreferencedWebhooksRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindUnreferencedWebhooks row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindUnreferencedWebhooks rows: %w", err) - } - return items, err -} - -// FindUnreferencedWebhooksBatch implements Querier.FindUnreferencedWebhooksBatch. -func (q *DBQuerier) FindUnreferencedWebhooksBatch(batch genericBatch) { - batch.Queue(findUnreferencedWebhooksSQL) -} - -// FindUnreferencedWebhooksScan implements Querier.FindUnreferencedWebhooksScan. -func (q *DBQuerier) FindUnreferencedWebhooksScan(results pgx.BatchResults) ([]FindUnreferencedWebhooksRow, error) { - rows, err := results.Query() - if err != nil { - return nil, fmt.Errorf("query FindUnreferencedWebhooksBatch: %w", err) - } - defer rows.Close() - items := []FindUnreferencedWebhooksRow{} - for rows.Next() { - var item FindUnreferencedWebhooksRow - if err := rows.Scan(&item.WebhookID, &item.VCSID, &item.VCSProviderID, &item.Secret, &item.Identifier, &item.Cloud); err != nil { - return nil, fmt.Errorf("scan FindUnreferencedWebhooksBatch row: %w", err) - } - items = append(items, item) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindUnreferencedWebhooksBatch rows: %w", err) - } - return items, err -} - -const deleteWebhookByIDSQL = `DELETE -FROM webhooks -WHERE webhook_id = $1 -RETURNING *;` - -type DeleteWebhookByIDRow struct { - WebhookID pgtype.UUID `json:"webhook_id"` - VCSID pgtype.Text `json:"vcs_id"` - Secret pgtype.Text `json:"secret"` - Identifier pgtype.Text `json:"identifier"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` -} - -// DeleteWebhookByID implements Querier.DeleteWebhookByID. -func (q *DBQuerier) DeleteWebhookByID(ctx context.Context, webhookID pgtype.UUID) (DeleteWebhookByIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "DeleteWebhookByID") - row := q.conn.QueryRow(ctx, deleteWebhookByIDSQL, webhookID) - var item DeleteWebhookByIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.Secret, &item.Identifier, &item.VCSProviderID); err != nil { - return item, fmt.Errorf("query DeleteWebhookByID: %w", err) - } - return item, nil -} - -// DeleteWebhookByIDBatch implements Querier.DeleteWebhookByIDBatch. -func (q *DBQuerier) DeleteWebhookByIDBatch(batch genericBatch, webhookID pgtype.UUID) { - batch.Queue(deleteWebhookByIDSQL, webhookID) -} - -// DeleteWebhookByIDScan implements Querier.DeleteWebhookByIDScan. -func (q *DBQuerier) DeleteWebhookByIDScan(results pgx.BatchResults) (DeleteWebhookByIDRow, error) { - row := results.QueryRow() - var item DeleteWebhookByIDRow - if err := row.Scan(&item.WebhookID, &item.VCSID, &item.Secret, &item.Identifier, &item.VCSProviderID); err != nil { - return item, fmt.Errorf("scan DeleteWebhookByIDBatch row: %w", err) - } - return item, nil -} diff --git a/internal/sql/pggen/workspace.sql.go b/internal/sql/pggen/workspace.sql.go index 8982bbdc4..d18afc056 100644 --- a/internal/sql/pggen/workspace.sql.go +++ b/internal/sql/pggen/workspace.sql.go @@ -140,13 +140,7 @@ const findWorkspacesSQL = `SELECT SELECT (rc.*)::"repo_connections" FROM repo_connections rc WHERE rc.workspace_id = w.workspace_id - ) AS workspace_connection, - ( - SELECT (wh.*)::"webhooks" - FROM webhooks wh - JOIN repo_connections rc USING (webhook_id) - WHERE rc.workspace_id = w.workspace_id - ) AS webhook + ) AS workspace_connection FROM workspaces w LEFT JOIN runs r ON w.latest_run_id = r.run_id LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id @@ -202,7 +196,6 @@ type FindWorkspacesRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } // FindWorkspaces implements Querier.FindWorkspaces. @@ -217,10 +210,9 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return nil, fmt.Errorf("scan FindWorkspaces row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -232,9 +224,6 @@ func (q *DBQuerier) FindWorkspaces(ctx context.Context, params FindWorkspacesPar if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return nil, fmt.Errorf("assign FindWorkspaces row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspaces row: %w", err) - } items = append(items, item) } if err := rows.Err(); err != nil { @@ -259,10 +248,9 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -274,9 +262,6 @@ func (q *DBQuerier) FindWorkspacesScan(results pgx.BatchResults) ([]FindWorkspac if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return nil, fmt.Errorf("assign FindWorkspaces row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspaces row: %w", err) - } items = append(items, item) } if err := rows.Err(); err != nil { @@ -331,7 +316,7 @@ func (q *DBQuerier) CountWorkspacesScan(results pgx.BatchResults) (pgtype.Int8, return item, nil } -const findWorkspacesByWebhookIDSQL = `SELECT +const findWorkspacesByConnectionSQL = `SELECT w.*, ( SELECT array_agg(name) @@ -342,17 +327,17 @@ const findWorkspacesByWebhookIDSQL = `SELECT r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id -WHERE h.webhook_id = $1 +JOIN repo_connections rc ON w.workspace_id = rc.workspace_id +WHERE rc.vcs_provider_id = $1 +AND rc.repo_path = $2 ;` -type FindWorkspacesByWebhookIDRow struct { +type FindWorkspacesByConnectionRow struct { WorkspaceID pgtype.Text `json:"workspace_id"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` @@ -387,85 +372,76 @@ type FindWorkspacesByWebhookIDRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } -// FindWorkspacesByWebhookID implements Querier.FindWorkspacesByWebhookID. -func (q *DBQuerier) FindWorkspacesByWebhookID(ctx context.Context, webhookID pgtype.UUID) ([]FindWorkspacesByWebhookIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacesByWebhookID") - rows, err := q.conn.Query(ctx, findWorkspacesByWebhookIDSQL, webhookID) +// FindWorkspacesByConnection implements Querier.FindWorkspacesByConnection. +func (q *DBQuerier) FindWorkspacesByConnection(ctx context.Context, vcsProviderID pgtype.Text, repoPath pgtype.Text) ([]FindWorkspacesByConnectionRow, error) { + ctx = context.WithValue(ctx, "pggen_query_name", "FindWorkspacesByConnection") + rows, err := q.conn.Query(ctx, findWorkspacesByConnectionSQL, vcsProviderID, repoPath) if err != nil { - return nil, fmt.Errorf("query FindWorkspacesByWebhookID: %w", err) + return nil, fmt.Errorf("query FindWorkspacesByConnection: %w", err) } defer rows.Close() - items := []FindWorkspacesByWebhookIDRow{} + items := []FindWorkspacesByConnectionRow{} userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { - var item FindWorkspacesByWebhookIDRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { - return nil, fmt.Errorf("scan FindWorkspacesByWebhookID row: %w", err) + var item FindWorkspacesByConnectionRow + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { + return nil, fmt.Errorf("scan FindWorkspacesByConnection row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } if err := runLockRow.AssignTo(&item.RunLock); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) - } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } items = append(items, item) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWorkspacesByWebhookID rows: %w", err) + return nil, fmt.Errorf("close FindWorkspacesByConnection rows: %w", err) } return items, err } -// FindWorkspacesByWebhookIDBatch implements Querier.FindWorkspacesByWebhookIDBatch. -func (q *DBQuerier) FindWorkspacesByWebhookIDBatch(batch genericBatch, webhookID pgtype.UUID) { - batch.Queue(findWorkspacesByWebhookIDSQL, webhookID) +// FindWorkspacesByConnectionBatch implements Querier.FindWorkspacesByConnectionBatch. +func (q *DBQuerier) FindWorkspacesByConnectionBatch(batch genericBatch, vcsProviderID pgtype.Text, repoPath pgtype.Text) { + batch.Queue(findWorkspacesByConnectionSQL, vcsProviderID, repoPath) } -// FindWorkspacesByWebhookIDScan implements Querier.FindWorkspacesByWebhookIDScan. -func (q *DBQuerier) FindWorkspacesByWebhookIDScan(results pgx.BatchResults) ([]FindWorkspacesByWebhookIDRow, error) { +// FindWorkspacesByConnectionScan implements Querier.FindWorkspacesByConnectionScan. +func (q *DBQuerier) FindWorkspacesByConnectionScan(results pgx.BatchResults) ([]FindWorkspacesByConnectionRow, error) { rows, err := results.Query() if err != nil { - return nil, fmt.Errorf("query FindWorkspacesByWebhookIDBatch: %w", err) + return nil, fmt.Errorf("query FindWorkspacesByConnectionBatch: %w", err) } defer rows.Close() - items := []FindWorkspacesByWebhookIDRow{} + items := []FindWorkspacesByConnectionRow{} userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { - var item FindWorkspacesByWebhookIDRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { - return nil, fmt.Errorf("scan FindWorkspacesByWebhookIDBatch row: %w", err) + var item FindWorkspacesByConnectionRow + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { + return nil, fmt.Errorf("scan FindWorkspacesByConnectionBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } if err := runLockRow.AssignTo(&item.RunLock); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) - } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByWebhookID row: %w", err) + return nil, fmt.Errorf("assign FindWorkspacesByConnection row: %w", err) } items = append(items, item) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("close FindWorkspacesByWebhookIDBatch rows: %w", err) + return nil, fmt.Errorf("close FindWorkspacesByConnectionBatch rows: %w", err) } return items, err } @@ -481,14 +457,13 @@ const findWorkspacesByUsernameSQL = `SELECT r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w JOIN workspace_permissions p USING (workspace_id) LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id JOIN teams t USING (team_id) JOIN team_memberships tm USING (team_id) JOIN users u ON tm.username = u.username @@ -541,7 +516,6 @@ type FindWorkspacesByUsernameRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } // FindWorkspacesByUsername implements Querier.FindWorkspacesByUsername. @@ -556,10 +530,9 @@ func (q *DBQuerier) FindWorkspacesByUsername(ctx context.Context, params FindWor userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByUsernameRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByUsername row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -571,9 +544,6 @@ func (q *DBQuerier) FindWorkspacesByUsername(ctx context.Context, params FindWor if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return nil, fmt.Errorf("assign FindWorkspacesByUsername row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByUsername row: %w", err) - } items = append(items, item) } if err := rows.Err(); err != nil { @@ -598,10 +568,9 @@ func (q *DBQuerier) FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]Fi userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() for rows.Next() { var item FindWorkspacesByUsernameRow - if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := rows.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return nil, fmt.Errorf("scan FindWorkspacesByUsernameBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -613,9 +582,6 @@ func (q *DBQuerier) FindWorkspacesByUsernameScan(results pgx.BatchResults) ([]Fi if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return nil, fmt.Errorf("assign FindWorkspacesByUsername row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return nil, fmt.Errorf("assign FindWorkspacesByUsername row: %w", err) - } items = append(items, item) } if err := rows.Err(); err != nil { @@ -670,13 +636,12 @@ const findWorkspaceByNameSQL = `SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.name = $1 AND w.organization_name = $2 ;` @@ -716,7 +681,6 @@ type FindWorkspaceByNameRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } // FindWorkspaceByName implements Querier.FindWorkspaceByName. @@ -727,8 +691,7 @@ func (q *DBQuerier) FindWorkspaceByName(ctx context.Context, name pgtype.Text, o userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByName: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -740,9 +703,6 @@ func (q *DBQuerier) FindWorkspaceByName(ctx context.Context, name pgtype.Text, o if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByName row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByName row: %w", err) - } return item, nil } @@ -758,8 +718,7 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByNameBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -771,9 +730,6 @@ func (q *DBQuerier) FindWorkspaceByNameScan(results pgx.BatchResults) (FindWorks if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByName row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByName row: %w", err) - } return item, nil } @@ -787,13 +743,12 @@ const findWorkspaceByIDSQL = `SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.workspace_id = $1 ;` @@ -832,7 +787,6 @@ type FindWorkspaceByIDRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } // FindWorkspaceByID implements Querier.FindWorkspaceByID. @@ -843,8 +797,7 @@ func (q *DBQuerier) FindWorkspaceByID(ctx context.Context, id pgtype.Text) (Find userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByID: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -856,9 +809,6 @@ func (q *DBQuerier) FindWorkspaceByID(ctx context.Context, id pgtype.Text) (Find if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByID row: %w", err) - } return item, nil } @@ -874,8 +824,7 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByIDBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -887,9 +836,6 @@ func (q *DBQuerier) FindWorkspaceByIDScan(results pgx.BatchResults) (FindWorkspa if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByID row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByID row: %w", err) - } return item, nil } @@ -903,13 +849,12 @@ const findWorkspaceByIDForUpdateSQL = `SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.workspace_id = $1 FOR UPDATE OF w;` @@ -948,7 +893,6 @@ type FindWorkspaceByIDForUpdateRow struct { UserLock *Users `json:"user_lock"` RunLock *Runs `json:"run_lock"` WorkspaceConnection *RepoConnections `json:"workspace_connection"` - Webhook *Webhooks `json:"webhook"` } // FindWorkspaceByIDForUpdate implements Querier.FindWorkspaceByIDForUpdate. @@ -959,8 +903,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdate(ctx context.Context, id pgtype.Te userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("query FindWorkspaceByIDForUpdate: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -972,9 +915,6 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdate(ctx context.Context, id pgtype.Te if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByIDForUpdate row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByIDForUpdate row: %w", err) - } return item, nil } @@ -990,8 +930,7 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdateScan(results pgx.BatchResults) (Fi userLockRow := q.types.newUsers() runLockRow := q.types.newRuns() workspaceConnectionRow := q.types.newRepoConnections() - webhookRow := q.types.newWebhooks() - if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow, webhookRow); err != nil { + if err := row.Scan(&item.WorkspaceID, &item.CreatedAt, &item.UpdatedAt, &item.AllowDestroyPlan, &item.AutoApply, &item.CanQueueDestroyPlan, &item.Description, &item.Environment, &item.ExecutionMode, &item.GlobalRemoteState, &item.MigrationEnvironment, &item.Name, &item.QueueAllRuns, &item.SpeculativeEnabled, &item.SourceName, &item.SourceURL, &item.StructuredRunOutputEnabled, &item.TerraformVersion, &item.TriggerPrefixes, &item.WorkingDirectory, &item.LockRunID, &item.LatestRunID, &item.OrganizationName, &item.Branch, &item.LockUsername, &item.CurrentStateVersionID, &item.TriggerPatterns, &item.VCSTagsRegex, &item.AllowCLIApply, &item.Tags, &item.LatestRunStatus, userLockRow, runLockRow, workspaceConnectionRow); err != nil { return item, fmt.Errorf("scan FindWorkspaceByIDForUpdateBatch row: %w", err) } if err := userLockRow.AssignTo(&item.UserLock); err != nil { @@ -1003,9 +942,6 @@ func (q *DBQuerier) FindWorkspaceByIDForUpdateScan(results pgx.BatchResults) (Fi if err := workspaceConnectionRow.AssignTo(&item.WorkspaceConnection); err != nil { return item, fmt.Errorf("assign FindWorkspaceByIDForUpdate row: %w", err) } - if err := webhookRow.AssignTo(&item.Webhook); err != nil { - return item, fmt.Errorf("assign FindWorkspaceByIDForUpdate row: %w", err) - } return item, nil } diff --git a/internal/sql/pggen/workspace_repo.sql.go b/internal/sql/pggen/workspace_repo.sql.go deleted file mode 100644 index 2e17866e1..000000000 --- a/internal/sql/pggen/workspace_repo.sql.go +++ /dev/null @@ -1,126 +0,0 @@ -// Code generated by pggen. DO NOT EDIT. - -package pggen - -import ( - "context" - "fmt" - - "github.com/jackc/pgconn" - "github.com/jackc/pgtype" - "github.com/jackc/pgx/v4" -) - -const insertWorkspaceRepoSQL = `INSERT INTO workspace_repos ( - branch, - webhook_id, - vcs_provider_id, - workspace_id -) VALUES ( - $1, - $2, - $3, - $4 -);` - -type InsertWorkspaceRepoParams struct { - Branch pgtype.Text - WebhookID pgtype.UUID - VCSProviderID pgtype.Text - WorkspaceID pgtype.Text -} - -// InsertWorkspaceRepo implements Querier.InsertWorkspaceRepo. -func (q *DBQuerier) InsertWorkspaceRepo(ctx context.Context, params InsertWorkspaceRepoParams) (pgconn.CommandTag, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "InsertWorkspaceRepo") - cmdTag, err := q.conn.Exec(ctx, insertWorkspaceRepoSQL, params.Branch, params.WebhookID, params.VCSProviderID, params.WorkspaceID) - if err != nil { - return cmdTag, fmt.Errorf("exec query InsertWorkspaceRepo: %w", err) - } - return cmdTag, err -} - -// InsertWorkspaceRepoBatch implements Querier.InsertWorkspaceRepoBatch. -func (q *DBQuerier) InsertWorkspaceRepoBatch(batch genericBatch, params InsertWorkspaceRepoParams) { - batch.Queue(insertWorkspaceRepoSQL, params.Branch, params.WebhookID, params.VCSProviderID, params.WorkspaceID) -} - -// InsertWorkspaceRepoScan implements Querier.InsertWorkspaceRepoScan. -func (q *DBQuerier) InsertWorkspaceRepoScan(results pgx.BatchResults) (pgconn.CommandTag, error) { - cmdTag, err := results.Exec() - if err != nil { - return cmdTag, fmt.Errorf("exec InsertWorkspaceRepoBatch: %w", err) - } - return cmdTag, err -} - -const updateWorkspaceRepoByIDSQL = `UPDATE workspace_repos -SET - branch = $1 -WHERE workspace_id = $2 -RETURNING workspace_id;` - -// UpdateWorkspaceRepoByID implements Querier.UpdateWorkspaceRepoByID. -func (q *DBQuerier) UpdateWorkspaceRepoByID(ctx context.Context, branch pgtype.Text, workspaceID pgtype.Text) (pgtype.Text, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "UpdateWorkspaceRepoByID") - row := q.conn.QueryRow(ctx, updateWorkspaceRepoByIDSQL, branch, workspaceID) - var item pgtype.Text - if err := row.Scan(&item); err != nil { - return item, fmt.Errorf("query UpdateWorkspaceRepoByID: %w", err) - } - return item, nil -} - -// UpdateWorkspaceRepoByIDBatch implements Querier.UpdateWorkspaceRepoByIDBatch. -func (q *DBQuerier) UpdateWorkspaceRepoByIDBatch(batch genericBatch, branch pgtype.Text, workspaceID pgtype.Text) { - batch.Queue(updateWorkspaceRepoByIDSQL, branch, workspaceID) -} - -// UpdateWorkspaceRepoByIDScan implements Querier.UpdateWorkspaceRepoByIDScan. -func (q *DBQuerier) UpdateWorkspaceRepoByIDScan(results pgx.BatchResults) (pgtype.Text, error) { - row := results.QueryRow() - var item pgtype.Text - if err := row.Scan(&item); err != nil { - return item, fmt.Errorf("scan UpdateWorkspaceRepoByIDBatch row: %w", err) - } - return item, nil -} - -const deleteWorkspaceRepoByIDSQL = `DELETE -FROM workspace_repos -WHERE workspace_id = $1 -RETURNING * -;` - -type DeleteWorkspaceRepoByIDRow struct { - Branch pgtype.Text `json:"branch"` - WebhookID pgtype.UUID `json:"webhook_id"` - VCSProviderID pgtype.Text `json:"vcs_provider_id"` - WorkspaceID pgtype.Text `json:"workspace_id"` -} - -// DeleteWorkspaceRepoByID implements Querier.DeleteWorkspaceRepoByID. -func (q *DBQuerier) DeleteWorkspaceRepoByID(ctx context.Context, workspaceID pgtype.Text) (DeleteWorkspaceRepoByIDRow, error) { - ctx = context.WithValue(ctx, "pggen_query_name", "DeleteWorkspaceRepoByID") - row := q.conn.QueryRow(ctx, deleteWorkspaceRepoByIDSQL, workspaceID) - var item DeleteWorkspaceRepoByIDRow - if err := row.Scan(&item.Branch, &item.WebhookID, &item.VCSProviderID, &item.WorkspaceID); err != nil { - return item, fmt.Errorf("query DeleteWorkspaceRepoByID: %w", err) - } - return item, nil -} - -// DeleteWorkspaceRepoByIDBatch implements Querier.DeleteWorkspaceRepoByIDBatch. -func (q *DBQuerier) DeleteWorkspaceRepoByIDBatch(batch genericBatch, workspaceID pgtype.Text) { - batch.Queue(deleteWorkspaceRepoByIDSQL, workspaceID) -} - -// DeleteWorkspaceRepoByIDScan implements Querier.DeleteWorkspaceRepoByIDScan. -func (q *DBQuerier) DeleteWorkspaceRepoByIDScan(results pgx.BatchResults) (DeleteWorkspaceRepoByIDRow, error) { - row := results.QueryRow() - var item DeleteWorkspaceRepoByIDRow - if err := row.Scan(&item.Branch, &item.WebhookID, &item.VCSProviderID, &item.WorkspaceID); err != nil { - return item, fmt.Errorf("scan DeleteWorkspaceRepoByIDBatch row: %w", err) - } - return item, nil -} diff --git a/internal/sql/queries/github_app.sql b/internal/sql/queries/github_app.sql new file mode 100644 index 000000000..bc7f80f22 --- /dev/null +++ b/internal/sql/queries/github_app.sql @@ -0,0 +1,39 @@ +-- name: InsertGithubApp :exec +INSERT INTO github_apps ( + github_app_id, + webhook_secret, + private_key, + slug, + organization +) VALUES ( + pggen.arg('github_app_id'), + pggen.arg('webhook_secret'), + pggen.arg('private_key'), + pggen.arg('slug'), + pggen.arg('organization') +); + +-- name: FindGithubApp :one +SELECT * +FROM github_apps; + +-- name: DeleteGithubApp :one +DELETE +FROM github_apps +WHERE github_app_id = pggen.arg('github_app_id') +RETURNING *; + +-- name: InsertGithubAppInstall :exec +INSERT INTO github_app_installs ( + github_app_id, + install_id, + username, + organization, + vcs_provider_id +) VALUES ( + pggen.arg('github_app_id'), + pggen.arg('install_id'), + pggen.arg('username'), + pggen.arg('organization'), + pggen.arg('vcs_provider_id') +); diff --git a/internal/sql/queries/module.sql b/internal/sql/queries/module.sql index 8cb796c3e..43649a672 100644 --- a/internal/sql/queries/module.sql +++ b/internal/sql/queries/module.sql @@ -45,14 +45,13 @@ SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.organization_name = pggen.arg('organization_name') ; @@ -66,14 +65,13 @@ SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.organization_name = pggen.arg('organization_name') AND m.name = pggen.arg('name') AND m.provider = pggen.arg('provider') @@ -89,18 +87,17 @@ SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE m.module_id = pggen.arg('id') ; --- name: FindModuleByWebhookID :one +-- name: FindModuleByConnection :one SELECT m.module_id, m.created_at, @@ -110,15 +107,15 @@ SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v WHERE v.module_id = m.module_id ) AS versions FROM modules m -JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) -WHERE h.webhook_id = pggen.arg('webhook_id') +JOIN repo_connections r USING (module_id) +WHERE r.vcs_provider_id = pggen.arg('vcs_provider_id') +AND r.repo_path = pggen.arg('repo_path') ; -- name: FindModuleByModuleVersionID :one @@ -131,7 +128,6 @@ SELECT m.status, m.organization_name, (r.*)::"repo_connections" AS module_connection, - (h.*)::"webhooks" AS webhook, ( SELECT array_agg(v.*) AS versions FROM module_versions v @@ -139,7 +135,7 @@ SELECT ) AS versions FROM modules m JOIN module_versions mv USING (module_id) -LEFT JOIN (repo_connections r JOIN webhooks h USING (webhook_id)) USING (module_id) +LEFT JOIN repo_connections r USING (module_id) WHERE mv.module_version_id = pggen.arg('module_version_id') ; diff --git a/internal/sql/queries/repo_connections.sql b/internal/sql/queries/repo_connections.sql index ebb0f137d..73092e82f 100644 --- a/internal/sql/queries/repo_connections.sql +++ b/internal/sql/queries/repo_connections.sql @@ -1,10 +1,12 @@ -- name: InsertRepoConnection :exec INSERT INTO repo_connections ( - webhook_id, + vcs_provider_id, + repo_path, workspace_id, module_id ) VALUES ( - pggen.arg('webhook_id'), + pggen.arg('vcs_provider_id'), + pggen.arg('repo_path'), pggen.arg('workspace_id'), pggen.arg('module_id') ); @@ -13,10 +15,10 @@ INSERT INTO repo_connections ( DELETE FROM repo_connections WHERE workspace_id = pggen.arg('workspace_id') -RETURNING webhook_id; +RETURNING *; -- name: DeleteModuleConnectionByID :one DELETE FROM repo_connections WHERE module_id = pggen.arg('module_id') -RETURNING webhook_id; +RETURNING *; diff --git a/internal/sql/queries/repohook.sql b/internal/sql/queries/repohook.sql new file mode 100644 index 000000000..3f9c43402 --- /dev/null +++ b/internal/sql/queries/repohook.sql @@ -0,0 +1,90 @@ +-- name: InsertRepohook :one +WITH inserted AS ( + INSERT INTO repohooks ( + repohook_id, + vcs_id, + vcs_provider_id, + secret, + repo_path + ) VALUES ( + pggen.arg('repohook_id'), + pggen.arg('vcs_id'), + pggen.arg('vcs_provider_id'), + pggen.arg('secret'), + pggen.arg('repo_path') + ) + RETURNING * +) +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM inserted w +JOIN vcs_providers v USING (vcs_provider_id); + +-- name: UpdateRepohookVCSID :one +UPDATE repohooks +SET vcs_id = pggen.arg('vcs_id') +WHERE repohook_id = pggen.arg('repohook_id') +RETURNING *; + +-- name: FindRepohooks :many +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id); + +-- name: FindRepohookByID :one +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE w.repohook_id = pggen.arg('repohook_id'); + +-- name: FindRepohookByRepoAndProvider :many +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE repo_path = pggen.arg('repo_path') +AND vcs_provider_id = pggen.arg('vcs_provider_id'); + +-- name: FindUnreferencedRepohooks :many +SELECT + w.repohook_id, + w.vcs_id, + w.vcs_provider_id, + w.secret, + w.repo_path, + v.vcs_kind +FROM repohooks w +JOIN vcs_providers v USING (vcs_provider_id) +WHERE NOT EXISTS ( + SELECT FROM repo_connections rc + WHERE rc.vcs_provider_id = w.vcs_provider_id + AND rc.repo_path = w.repo_path +); + +-- name: DeleteRepohookByID :one +DELETE +FROM repohooks +WHERE repohook_id = pggen.arg('repohook_id') +RETURNING *; diff --git a/internal/sql/queries/vcs_provider.sql b/internal/sql/queries/vcs_provider.sql index 9e2af4651..2a2171c29 100644 --- a/internal/sql/queries/vcs_provider.sql +++ b/internal/sql/queries/vcs_provider.sql @@ -1,42 +1,70 @@ -- name: InsertVCSProvider :exec INSERT INTO vcs_providers ( vcs_provider_id, - token, created_at, name, - cloud, + vcs_kind, + token, + github_app_id, organization_name ) VALUES ( pggen.arg('vcs_provider_id'), - pggen.arg('token'), pggen.arg('created_at'), pggen.arg('name'), - pggen.arg('cloud'), + pggen.arg('vcs_kind'), + pggen.arg('token'), + pggen.arg('github_app_id'), pggen.arg('organization_name') ); -- name: FindVCSProvidersByOrganization :many -SELECT * -FROM vcs_providers -WHERE organization_name = pggen.arg('organization_name') +SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.organization_name = pggen.arg('organization_name') ; -- name: FindVCSProviders :many -SELECT * -FROM vcs_providers +SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +; + +-- name: FindVCSProvidersByGithubAppInstallID :many +SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE gi.install_id = pggen.arg('install_id') ; -- name: FindVCSProvider :one -SELECT * -FROM vcs_providers -WHERE vcs_provider_id = pggen.arg('vcs_provider_id') +SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.vcs_provider_id = pggen.arg('vcs_provider_id') ; -- name: FindVCSProviderForUpdate :one -SELECT * -FROM vcs_providers -WHERE vcs_provider_id = pggen.arg('vcs_provider_id') -FOR UPDATE +SELECT + v.*, + (ga.*)::"github_apps" AS github_app, + (gi.*)::"github_app_installs" AS github_app_install +FROM vcs_providers v +LEFT JOIN (github_app_installs gi JOIN github_apps ga USING (github_app_id)) USING (vcs_provider_id) +WHERE v.vcs_provider_id = pggen.arg('vcs_provider_id') +FOR UPDATE OF v ; -- name: UpdateVCSProvider :one diff --git a/internal/sql/queries/webhook.sql b/internal/sql/queries/webhook.sql deleted file mode 100644 index 4d7010b02..000000000 --- a/internal/sql/queries/webhook.sql +++ /dev/null @@ -1,89 +0,0 @@ --- name: InsertWebhook :one -WITH inserted AS ( - INSERT INTO webhooks ( - webhook_id, - vcs_id, - vcs_provider_id, - secret, - identifier - ) VALUES ( - pggen.arg('webhook_id'), - pggen.arg('vcs_id'), - pggen.arg('vcs_provider_id'), - pggen.arg('secret'), - pggen.arg('identifier') - ) - RETURNING * -) -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM inserted w -JOIN vcs_providers v USING (vcs_provider_id); - --- name: UpdateWebhookVCSID :one -UPDATE webhooks -SET vcs_id = pggen.arg('vcs_id') -WHERE webhook_id = pggen.arg('webhook_id') -RETURNING *; - --- name: FindWebhooks :many -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id); - --- name: FindWebhookByID :one -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE w.webhook_id = pggen.arg('webhook_id'); - --- name: FindWebhookByRepoAndProvider :many -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE identifier = pggen.arg('identifier') -AND vcs_provider_id = pggen.arg('vcs_provider_id'); - --- name: FindUnreferencedWebhooks :many -SELECT - w.webhook_id, - w.vcs_id, - w.vcs_provider_id, - w.secret, - w.identifier, - v.cloud -FROM webhooks w -JOIN vcs_providers v USING (vcs_provider_id) -WHERE NOT EXISTS ( - SELECT FROM repo_connections c - WHERE c.webhook_id = w.webhook_id -); - --- name: DeleteWebhookByID :one -DELETE -FROM webhooks -WHERE webhook_id = pggen.arg('webhook_id') -RETURNING *; diff --git a/internal/sql/queries/workspace.sql b/internal/sql/queries/workspace.sql index 631ec2767..28d5fe07d 100644 --- a/internal/sql/queries/workspace.sql +++ b/internal/sql/queries/workspace.sql @@ -77,13 +77,7 @@ SELECT SELECT (rc.*)::"repo_connections" FROM repo_connections rc WHERE rc.workspace_id = w.workspace_id - ) AS workspace_connection, - ( - SELECT (wh.*)::"webhooks" - FROM webhooks wh - JOIN repo_connections rc USING (webhook_id) - WHERE rc.workspace_id = w.workspace_id - ) AS webhook + ) AS workspace_connection FROM workspaces w LEFT JOIN runs r ON w.latest_run_id = r.run_id LEFT JOIN (workspace_tags wt JOIN tags t USING (tag_id)) ON wt.workspace_id = w.workspace_id @@ -111,7 +105,7 @@ SELECT count(*) FROM workspaces ; --- name: FindWorkspacesByWebhookID :many +-- name: FindWorkspacesByConnection :many SELECT w.*, ( @@ -123,14 +117,14 @@ SELECT r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id -WHERE h.webhook_id = pggen.arg('webhook_id') +JOIN repo_connections rc ON w.workspace_id = rc.workspace_id +WHERE rc.vcs_provider_id = pggen.arg('vcs_provider_id') +AND rc.repo_path = pggen.arg('repo_path') ; -- name: FindWorkspacesByUsername :many @@ -145,14 +139,13 @@ SELECT r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w JOIN workspace_permissions p USING (workspace_id) LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id JOIN teams t USING (team_id) JOIN team_memberships tm USING (team_id) JOIN users u ON tm.username = u.username @@ -185,13 +178,12 @@ SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.name = pggen.arg('name') AND w.organization_name = pggen.arg('organization_name') ; @@ -207,13 +199,12 @@ SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.workspace_id = pggen.arg('id') ; @@ -228,13 +219,12 @@ SELECT w.*, r.status AS latest_run_status, (ul.*)::"users" AS user_lock, (rl.*)::"runs" AS run_lock, - (vr.*)::"repo_connections" AS workspace_connection, - (h.*)::"webhooks" AS webhook + (rc.*)::"repo_connections" AS workspace_connection FROM workspaces w LEFT JOIN users ul ON w.lock_username = ul.username LEFT JOIN runs rl ON w.lock_run_id = rl.run_id LEFT JOIN runs r ON w.latest_run_id = r.run_id -LEFT JOIN (repo_connections vr JOIN webhooks h USING (webhook_id)) ON w.workspace_id = vr.workspace_id +LEFT JOIN repo_connections rc ON w.workspace_id = rc.workspace_id WHERE w.workspace_id = pggen.arg('id') FOR UPDATE OF w; diff --git a/internal/testutils/html.go b/internal/testutils/html.go index a2893851c..43baa10fa 100644 --- a/internal/testutils/html.go +++ b/internal/testutils/html.go @@ -4,7 +4,9 @@ import ( "net/http/httptest" "testing" + otfhtml "github.com/leg100/otf/internal/http/html" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "golang.org/x/net/html" ) @@ -22,3 +24,9 @@ func AssertRedirect(t *testing.T, w *httptest.ResponseRecorder, path string) { assert.Equal(t, path, redirect.Path) } } + +func NewRenderer(t *testing.T) otfhtml.Renderer { + renderer, err := otfhtml.NewRenderer(false) + require.NoError(t, err) + return renderer +} diff --git a/internal/repo/broker.go b/internal/vcs/broker.go similarity index 50% rename from internal/repo/broker.go rename to internal/vcs/broker.go index 4fa6a7775..d1489b64b 100644 --- a/internal/repo/broker.go +++ b/internal/vcs/broker.go @@ -1,31 +1,35 @@ -package repo +package vcs import ( "sync" - - "github.com/leg100/otf/internal/cloud" ) type ( - broker struct { - subscribers []func(event cloud.VCSEvent) + // Broker is a brokerage for publishers and subscribers of VCS events. + Broker struct { + subscribers []func(event Event) mu sync.RWMutex } - Callback func(event cloud.VCSEvent) + + Callback func(event Event) Subscriber interface { Subscribe(cb Callback) } + + Publisher interface { + Publish(Event) + } ) -func (b *broker) Subscribe(cb Callback) { +func (b *Broker) Subscribe(cb Callback) { b.mu.Lock() defer b.mu.Unlock() b.subscribers = append(b.subscribers, cb) } -func (b *broker) publish(event cloud.VCSEvent) { +func (b *Broker) Publish(event Event) { b.mu.RLock() defer b.mu.RUnlock() diff --git a/internal/cloud/client.go b/internal/vcs/client.go similarity index 91% rename from internal/cloud/client.go rename to internal/vcs/client.go index f66a0e3fc..2bd6c3d65 100644 --- a/internal/cloud/client.go +++ b/internal/vcs/client.go @@ -1,4 +1,4 @@ -package cloud +package vcs import ( "context" @@ -6,8 +6,6 @@ import ( type ( Client interface { - // GetCurrentUser retrieves the current user - GetCurrentUser(ctx context.Context) (User, error) // ListRepositories lists repositories accessible to the current user. ListRepositories(ctx context.Context, opts ListRepositoriesOptions) ([]string, error) GetRepository(ctx context.Context, identifier string) (Repository, error) @@ -29,11 +27,12 @@ type ( GetCommit(ctx context.Context, repo, ref string) (Commit, error) } - // ClientOptions are options for constructing a cloud client - ClientOptions struct { + // NewTokenClientOptions are options for creating a client using a personal + // access token (PAT). + NewTokenClientOptions struct { + Token string Hostname string SkipTLSVerification bool - Credentials } GetRepoTarballOptions struct { @@ -55,7 +54,7 @@ type ( Webhook struct { ID string // cloud's webhook ID Repo string // identifier is / - Events []VCSEventType + Events []EventType Endpoint string // the OTF URL that receives events } @@ -63,7 +62,7 @@ type ( Repo string // repo identifier, / Secret string // secret string for generating signature Endpoint string // otf's external-facing host[:port] - Events []VCSEventType + Events []EventType } UpdateWebhookOptions CreateWebhookOptions @@ -85,7 +84,7 @@ type ( Workspace string // workspace name Repo string // / Ref string // git ref - Status VCSStatus + Status Status TargetURL string Description string } diff --git a/internal/vcs/event.go b/internal/vcs/event.go new file mode 100644 index 000000000..2893e0af2 --- /dev/null +++ b/internal/vcs/event.go @@ -0,0 +1,79 @@ +package vcs + +import "errors" + +const ( + EventTypePull EventType = iota + 1 + EventTypePush + EventTypeTag + EventTypeInstallation // github-app installation + + ActionCreated Action = iota + 1 + ActionDeleted + ActionMerged + ActionUpdated +) + +type ( + // Event is a VCS event received from a cloud, e.g. a commit event from + // github + Event struct { + EventHeader + EventPayload + } + + EventHeader struct { + VCSProviderID string + } + + EventPayload struct { + RepoPath string + + VCSKind Kind + + Type EventType + Action Action + Tag string + CommitSHA string + CommitURL string + Branch string // head branch + DefaultBranch string + + PullRequestNumber int + PullRequestURL string + PullRequestTitle string + + SenderUsername string + SenderAvatarURL string + SenderHTMLURL string + + // Paths of files that have been added/modified/removed. Only applicable + // to Push and Tag events types. + Paths []string + + // Only set if event is from a github app + GithubAppInstallID *int64 + } + + EventType int + Action int +) + +func (e EventPayload) Validate() error { + if e.VCSKind == "" { + return errors.New("event missing vcs kind") + } + if e.Type == 0 { + return errors.New("event missing event type") + } + if e.Action == 0 { + return errors.New("event missing event action") + } + switch e.Type { + case EventTypePush, EventTypePull, EventTypeTag: + if e.RepoPath == "" { + return errors.New("event missing repo path") + } + } + return nil +} diff --git a/internal/vcs/kind.go b/internal/vcs/kind.go new file mode 100644 index 000000000..249b341fe --- /dev/null +++ b/internal/vcs/kind.go @@ -0,0 +1,11 @@ +package vcs + +const ( + GithubKind Kind = "github" + GitlabKind Kind = "gitlab" +) + +// Kind of vcs hosting provider +type Kind string + +func KindPtr(k Kind) *Kind { return &k } diff --git a/internal/vcs/status.go b/internal/vcs/status.go new file mode 100644 index 000000000..3b326ded8 --- /dev/null +++ b/internal/vcs/status.go @@ -0,0 +1,11 @@ +package vcs + +type Status string + +const ( + PendingStatus Status = "pending" + RunningStatus Status = "running" + SuccessStatus Status = "success" + ErrorStatus Status = "error" + FailureStatus Status = "failure" +) diff --git a/internal/cloud/test_helpers.go b/internal/vcs/test_helpers.go similarity index 96% rename from internal/cloud/test_helpers.go rename to internal/vcs/test_helpers.go index 35b120bf8..8bad37c06 100644 --- a/internal/cloud/test_helpers.go +++ b/internal/vcs/test_helpers.go @@ -1,4 +1,4 @@ -package cloud +package vcs import ( "fmt" diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 000000000..b9240c2f6 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,2 @@ +// Package vcs handles version control system stuff. +package vcs diff --git a/internal/vcsprovider/db.go b/internal/vcsprovider/db.go index 7d31a6f0b..e42f68d72 100644 --- a/internal/vcsprovider/db.go +++ b/internal/vcsprovider/db.go @@ -4,34 +4,34 @@ import ( "context" "github.com/jackc/pgtype" - "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/sql/pggen" + "github.com/leg100/otf/internal/vcs" ) type ( // pgdb is a VCS provider database on postgres pgdb struct { - *sql.DB // provides access to generated SQL queries - CloudService + // provides access to generated SQL queries + *sql.DB + *factory } - // pgRow represents a database row for a vcs provider - pgRow struct { - VCSProviderID pgtype.Text `json:"id"` - Token pgtype.Text `json:"token"` - CreatedAt pgtype.Timestamptz `json:"created_at"` - Name pgtype.Text `json:"name"` - Cloud pgtype.Text `json:"cloud"` - OrganizationName pgtype.Text `json:"organization_name"` + // pgrow represents a database row for a vcs provider + pgrow struct { + VCSProviderID pgtype.Text `json:"vcs_provider_id"` + Token pgtype.Text `json:"token"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Name pgtype.Text `json:"name"` + VCSKind pgtype.Text `json:"vcs_kind"` + OrganizationName pgtype.Text `json:"organization_name"` + GithubAppID pgtype.Int8 `json:"github_app_id"` + GithubApp *pggen.GithubApps `json:"github_app"` + GithubAppInstall *pggen.GithubAppInstalls `json:"github_app_install"` } ) -func newDB(db *sql.DB, cloudService cloud.Service) *pgdb { - return &pgdb{db, cloudService} -} - // GetByID implements pubsub.Getter func (db *pgdb) GetByID(ctx context.Context, providerID string, action pubsub.DBAction) (any, error) { if action == pubsub.DeleteDBAction { @@ -41,13 +41,37 @@ func (db *pgdb) GetByID(ctx context.Context, providerID string, action pubsub.DB } func (db *pgdb) create(ctx context.Context, provider *VCSProvider) error { - _, err := db.Conn(ctx).InsertVCSProvider(ctx, pggen.InsertVCSProviderParams{ - VCSProviderID: sql.String(provider.ID), - Token: sql.String(provider.Token), - Name: sql.String(provider.Name), - Cloud: sql.String(provider.CloudConfig.Name), - OrganizationName: sql.String(provider.Organization), - CreatedAt: sql.Timestamptz(provider.CreatedAt), + err := db.Tx(ctx, func(ctx context.Context, q pggen.Querier) error { + params := pggen.InsertVCSProviderParams{ + VCSProviderID: sql.String(provider.ID), + Name: sql.String(provider.Name), + VCSKind: sql.String(string(provider.Kind)), + OrganizationName: sql.String(provider.Organization), + CreatedAt: sql.Timestamptz(provider.CreatedAt), + Token: sql.StringPtr(provider.Token), + } + if provider.GithubApp != nil { + params.GithubAppID = pgtype.Int8{Int: provider.GithubApp.AppCredentials.ID, Status: pgtype.Present} + } else { + params.GithubAppID = pgtype.Int8{Status: pgtype.Null} + } + _, err := db.Conn(ctx).InsertVCSProvider(ctx, params) + if err != nil { + return err + } + if provider.GithubApp != nil { + _, err := db.Conn(ctx).InsertGithubAppInstall(ctx, pggen.InsertGithubAppInstallParams{ + GithubAppID: pgtype.Int8{Int: provider.GithubApp.AppCredentials.ID, Status: pgtype.Present}, + InstallID: pgtype.Int8{Int: provider.GithubApp.ID, Status: pgtype.Present}, + Username: sql.StringPtr(provider.GithubApp.User), + Organization: sql.StringPtr(provider.GithubApp.Organization), + VCSProviderID: sql.String(provider.ID), + }) + if err != nil { + return err + } + } + return nil }) return err } @@ -59,7 +83,7 @@ func (db *pgdb) update(ctx context.Context, id string, fn func(*VCSProvider) err if err != nil { return sql.Error(err) } - provider, err = db.unmarshal(pgRow(row)) + provider, err = db.toProvider(ctx, pgrow(row)) if err != nil { return err } @@ -68,7 +92,7 @@ func (db *pgdb) update(ctx context.Context, id string, fn func(*VCSProvider) err } _, err = q.UpdateVCSProvider(ctx, pggen.UpdateVCSProviderParams{ VCSProviderID: sql.String(id), - Token: sql.String(provider.Token), + Token: sql.StringPtr(provider.Token), Name: sql.String(provider.Name), }) if err != nil { @@ -84,7 +108,7 @@ func (db *pgdb) get(ctx context.Context, id string) (*VCSProvider, error) { if err != nil { return nil, sql.Error(err) } - return db.unmarshal(pgRow(row)) + return db.toProvider(ctx, pgrow(row)) } func (db *pgdb) list(ctx context.Context) ([]*VCSProvider, error) { @@ -94,7 +118,7 @@ func (db *pgdb) list(ctx context.Context) ([]*VCSProvider, error) { } var providers []*VCSProvider for _, r := range rows { - provider, err := db.unmarshal(pgRow(r)) + provider, err := db.toProvider(ctx, pgrow(r)) if err != nil { return nil, err } @@ -110,7 +134,25 @@ func (db *pgdb) listByOrganization(ctx context.Context, organization string) ([] } var providers []*VCSProvider for _, r := range rows { - provider, err := db.unmarshal(pgRow(r)) + provider, err := db.toProvider(ctx, pgrow(r)) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +func (db *pgdb) listByGithubAppInstall(ctx context.Context, installID int64) ([]*VCSProvider, error) { + rows, err := db.Conn(ctx).FindVCSProvidersByGithubAppInstallID(ctx, + pgtype.Int8{Int: installID, Status: pgtype.Present}, + ) + if err != nil { + return nil, sql.Error(err) + } + var providers []*VCSProvider + for _, r := range rows { + provider, err := db.toProvider(ctx, pgrow(r)) if err != nil { return nil, err } @@ -128,14 +170,32 @@ func (db *pgdb) delete(ctx context.Context, id string) error { } // unmarshal a vcs provider row from the database. -func (db *pgdb) unmarshal(row pgRow) (*VCSProvider, error) { +func (db *pgdb) toProvider(ctx context.Context, row pgrow) (*VCSProvider, error) { opts := CreateOptions{ - ID: &row.VCSProviderID.String, - CreatedAt: internal.Time(row.CreatedAt.Time.UTC()), Organization: row.OrganizationName.String, - Token: row.Token.String, - Cloud: row.Cloud.String, Name: row.Name.String, + // GithubAppService: db.Git + } + if row.Token.Status == pgtype.Present { + opts.Token = &row.Token.String + kind := vcs.Kind(row.VCSKind.String) + opts.Kind = &kind + } + var creds *github.InstallCredentials + if row.GithubApp != nil { + creds = &github.InstallCredentials{ + ID: row.GithubAppInstall.InstallID.Int, + AppCredentials: github.AppCredentials{ + ID: row.GithubApp.GithubAppID.Int, + PrivateKey: row.GithubApp.PrivateKey.String, + }, + } + if row.GithubAppInstall.Username.Status == pgtype.Present { + creds.User = &row.GithubAppInstall.Username.String + } + if row.GithubAppInstall.Organization.Status == pgtype.Present { + creds.Organization = &row.GithubAppInstall.Organization.String + } } - return newProvider(db.CloudService, opts) + return db.fromDB(ctx, opts, creds, row.VCSProviderID.String, row.CreatedAt.Time.UTC()) } diff --git a/internal/vcsprovider/service.go b/internal/vcsprovider/service.go index 698a72313..35938cc20 100644 --- a/internal/vcsprovider/service.go +++ b/internal/vcsprovider/service.go @@ -6,19 +6,19 @@ import ( "github.com/go-logr/logr" "github.com/gorilla/mux" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/hooks" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/rbac" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/tfeapi" + "github.com/leg100/otf/internal/vcs" ) type ( // Alias services so they don't conflict when nested together in struct VCSProviderService Service - CloudService cloud.Service Service interface { CreateVCSProvider(ctx context.Context, opts CreateOptions) (*VCSProvider, error) @@ -26,21 +26,23 @@ type ( GetVCSProvider(ctx context.Context, id string) (*VCSProvider, error) ListVCSProviders(ctx context.Context, organization string) ([]*VCSProvider, error) ListAllVCSProviders(ctx context.Context) ([]*VCSProvider, error) + // ListVCSProvidersByGithubAppInstall lists VCS providers using the + // credentials of a particular github app installation. + ListVCSProvidersByGithubAppInstall(ctx context.Context, installID int64) ([]*VCSProvider, error) DeleteVCSProvider(ctx context.Context, id string) (*VCSProvider, error) // GetVCSClient combines retrieving a vcs provider and construct a cloud // client from that provider. // - // TODO: rename vcs provider to cloud client; the central purpose of the vcs - // provider is, after all, to construct a cloud client. - GetVCSClient(ctx context.Context, providerID string) (cloud.Client, error) + // TODO: rename vcs provider to vcs client; the central purpose of the vcs + // provider is, after all, to construct a vcs client. + GetVCSClient(ctx context.Context, providerID string) (vcs.Client, error) BeforeDeleteVCSProvider(l hooks.Listener[*VCSProvider]) } service struct { logr.Logger - CloudService site internal.Authorizer organization internal.Authorizer @@ -48,37 +50,80 @@ type ( web *webHandlers api *tfe deleteHook *hooks.Hook[*VCSProvider] + + internal.HostnameService + github.GithubAppService + *factory } Options struct { - CloudService + internal.HostnameService *sql.DB *tfeapi.Responder html.Renderer logr.Logger + github.GithubAppService + vcs.Subscriber + + GithubHostname string + GitlabHostname string + SkipTLSVerification bool } ) func NewService(opts Options) *service { + factory := factory{ + GithubAppService: opts.GithubAppService, + githubHostname: opts.GithubHostname, + gitlabHostname: opts.GitlabHostname, + skipTLSVerification: opts.SkipTLSVerification, + } svc := service{ - Logger: opts.Logger, - site: &internal.SiteAuthorizer{Logger: opts.Logger}, - organization: &organization.Authorizer{Logger: opts.Logger}, - db: newDB(opts.DB, opts.CloudService), - CloudService: opts.CloudService, - deleteHook: hooks.NewHook[*VCSProvider](opts.DB), + Logger: opts.Logger, + HostnameService: opts.HostnameService, + GithubAppService: opts.GithubAppService, + site: &internal.SiteAuthorizer{Logger: opts.Logger}, + organization: &organization.Authorizer{Logger: opts.Logger}, + factory: &factory, + db: &pgdb{ + DB: opts.DB, + factory: &factory, + }, + deleteHook: hooks.NewHook[*VCSProvider](opts.DB), } - svc.web = &webHandlers{ - CloudService: opts.CloudService, - Renderer: opts.Renderer, - svc: &svc, + Renderer: opts.Renderer, + HostnameService: opts.HostnameService, + GithubAppService: opts.GithubAppService, + GithubHostname: opts.GithubHostname, + GitlabHostname: opts.GitlabHostname, + svc: &svc, } svc.api = &tfe{ Service: &svc, Responder: opts.Responder, } - + // delete vcs providers when a github app is uninstalled + opts.Subscribe(func(event vcs.Event) { + // ignore events other than uninstallation events + if event.Type != vcs.EventTypeInstallation || event.Action != vcs.ActionDeleted { + return + } + // create user with unlimited permissions + user := &internal.Superuser{Username: "vcs-provider-service"} + ctx := internal.AddSubjectToContext(context.Background(), user) + // list all vcsproviders using the app install + providers, err := svc.ListVCSProvidersByGithubAppInstall(ctx, *event.GithubAppInstallID) + if err != nil { + return + } + // and delete them + for _, prov := range providers { + if _, err = svc.DeleteVCSProvider(ctx, prov.ID); err != nil { + return + } + } + }) return &svc } @@ -97,7 +142,7 @@ func (a *service) CreateVCSProvider(ctx context.Context, opts CreateOptions) (*V return nil, err } - provider, err := newProvider(a.CloudService, opts) + provider, err := a.newProvider(ctx, opts) if err != nil { return nil, err } @@ -167,6 +212,22 @@ func (a *service) ListAllVCSProviders(ctx context.Context) ([]*VCSProvider, erro return providers, nil } +// ListVCSProvidersByGithubAppInstall is unauthenticated: only for internal use. +func (a *service) ListVCSProvidersByGithubAppInstall(ctx context.Context, installID int64) ([]*VCSProvider, error) { + subject, err := internal.SubjectFromContext(ctx) + if err != nil { + return nil, err + } + + providers, err := a.db.listByGithubAppInstall(ctx, installID) + if err != nil { + a.Error(err, "listing github app installation vcs providers", "subject", subject, "install", installID) + return nil, err + } + a.V(9).Info("listed github app installation vcs providers", "count", len(providers), "subject", subject, "install", installID) + return providers, nil +} + func (a *service) GetVCSProvider(ctx context.Context, id string) (*VCSProvider, error) { // Parameters only include VCS Provider ID, so we can only determine // authorization _after_ retrieving the provider @@ -185,12 +246,12 @@ func (a *service) GetVCSProvider(ctx context.Context, id string) (*VCSProvider, return provider, nil } -func (a *service) GetVCSClient(ctx context.Context, providerID string) (cloud.Client, error) { +func (a *service) GetVCSClient(ctx context.Context, providerID string) (vcs.Client, error) { provider, err := a.GetVCSProvider(ctx, providerID) if err != nil { return nil, err } - return provider.NewClient(ctx) + return provider.NewClient() } func (a *service) DeleteVCSProvider(ctx context.Context, id string) (*VCSProvider, error) { diff --git a/internal/vcsprovider/test_helpers.go b/internal/vcsprovider/test_helpers.go deleted file mode 100644 index 521fe1552..000000000 --- a/internal/vcsprovider/test_helpers.go +++ /dev/null @@ -1,43 +0,0 @@ -package vcsprovider - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/leg100/otf/internal/inmem" - "github.com/leg100/otf/internal/organization" - "github.com/stretchr/testify/require" -) - -func newTestVCSProvider(t *testing.T, org *organization.Organization) *VCSProvider { - cloudService := inmem.NewCloudServiceWithDefaults() - provider, err := newProvider(cloudService, CreateOptions{ - Organization: org.Name, - // unit tests require a legitimate cloud name to avoid invalid foreign - // key error upon insert/update - Cloud: "github", - Name: uuid.NewString(), - Token: uuid.NewString(), - }) - require.NoError(t, err) - return provider -} - -type fakeService struct { - provider *VCSProvider - - Service -} - -func (f *fakeService) CreateVCSProvider(ctx context.Context, opts CreateOptions) (*VCSProvider, error) { - return f.provider, nil -} - -func (f *fakeService) ListVCSProviders(context.Context, string) ([]*VCSProvider, error) { - return []*VCSProvider{f.provider}, nil -} - -func (f *fakeService) DeleteVCSProvider(context.Context, string) (*VCSProvider, error) { - return f.provider, nil -} diff --git a/internal/vcsprovider/tfe.go b/internal/vcsprovider/tfe.go index 13c2bad02..f2befdf69 100644 --- a/internal/vcsprovider/tfe.go +++ b/internal/vcsprovider/tfe.go @@ -12,6 +12,7 @@ import ( "github.com/leg100/otf/internal/http/decode" "github.com/leg100/otf/internal/tfeapi" "github.com/leg100/otf/internal/tfeapi/types" + "github.com/leg100/otf/internal/vcs" ) const ( @@ -98,8 +99,8 @@ func (a *tfe) createOAuthClient(w http.ResponseWriter, r *http.Request) { oauthClient, err := a.CreateVCSProvider(r.Context(), CreateOptions{ Name: *params.Name, Organization: org, - Token: *params.OAuthToken, - Cloud: "github", + Token: params.OAuthToken, + Kind: vcs.KindPtr(vcs.GithubKind), }) if err != nil { tfeapi.Error(w, err) diff --git a/internal/vcsprovider/vcs_provider.go b/internal/vcsprovider/vcs_provider.go index a0eaa47c3..aa5db1038 100644 --- a/internal/vcsprovider/vcs_provider.go +++ b/internal/vcsprovider/vcs_provider.go @@ -3,34 +3,52 @@ package vcsprovider import ( "context" + "errors" "fmt" "time" "log/slog" "github.com/leg100/otf/internal" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/gitlab" + "github.com/leg100/otf/internal/vcs" ) type ( - // VCSProvider provides authenticated access to a VCS. Equivalent to an OAuthClient in - // TFE. + // VCSProvider provides authenticated access to a VCS. VCSProvider struct { ID string - CreatedAt time.Time - CloudConfig cloud.Config // cloud config for creating client - Token string // credential for creating client - Organization string // vcs provider belongs to an organization Name string + CreatedAt time.Time + Organization string // name of OTF organization + Hostname string // hostname of github/gitlab etc + + Kind vcs.Kind // github/gitlab etc. Not necessary if GithubApp is non-nil. + Token *string // personal access token. + + GithubApp *github.InstallCredentials // mutually exclusive with Token. + + skipTLSVerification bool // toggle skipping verification of VCS host's TLS cert. + } + + // factory produces VCS providers + factory struct { + github.GithubAppService + + githubHostname string + gitlabHostname string + skipTLSVerification bool // toggle skipping verification of VCS host's TLS cert. } CreateOptions struct { Organization string - Token string - Cloud string - ID *string Name string - CreatedAt *time.Time + Kind *vcs.Kind + + // Specify either token or github app install ID + Token *string + GithubAppInstallID *int64 } UpdateOptions struct { @@ -39,44 +57,105 @@ type ( } ) -func newProvider(cloudService CloudService, opts CreateOptions) (*VCSProvider, error) { - cloudConfig, err := cloudService.GetCloudConfig(opts.Cloud) - if err != nil { - return nil, err +func (f *factory) newProvider(ctx context.Context, opts CreateOptions) (*VCSProvider, error) { + var ( + creds *github.InstallCredentials + err error + ) + if opts.GithubAppInstallID != nil { + creds, err = f.GetInstallCredentials(ctx, *opts.GithubAppInstallID) + if err != nil { + return nil, err + } } + return f.newWithGithubCredentials(ctx, opts, creds) +} +func (f *factory) newWithGithubCredentials(ctx context.Context, opts CreateOptions, creds *github.InstallCredentials) (*VCSProvider, error) { provider := &VCSProvider{ - ID: internal.NewID("vcs"), - CreatedAt: internal.CurrentTimestamp(), - Organization: opts.Organization, - CloudConfig: cloudConfig, - Name: opts.Name, - } - if err := provider.setToken(opts.Token); err != nil { - return nil, err + ID: internal.NewID("vcs"), + Name: opts.Name, + CreatedAt: internal.CurrentTimestamp(), + Organization: opts.Organization, + skipTLSVerification: f.skipTLSVerification, } - if opts.ID != nil { - provider.ID = *opts.ID + if opts.Token != nil { + if opts.Kind == nil { + return nil, errors.New("must specify both token and kind") + } + provider.Kind = *opts.Kind + switch provider.Kind { + case vcs.GithubKind: + provider.Hostname = f.githubHostname + case vcs.GitlabKind: + provider.Hostname = f.gitlabHostname + default: + return nil, errors.New("no hostname found for vcs kind") + } + if err := provider.setToken(*opts.Token); err != nil { + return nil, err + } + } else if creds != nil { + provider.GithubApp = creds + provider.Kind = vcs.GithubKind + provider.Hostname = f.githubHostname + } else { + return nil, errors.New("must specify either token or github app installation ID") } - if opts.CreatedAt != nil { - provider.CreatedAt = *opts.CreatedAt + return provider, nil +} + +func (f *factory) fromDB(ctx context.Context, opts CreateOptions, creds *github.InstallCredentials, id string, createdAt time.Time) (*VCSProvider, error) { + provider, err := f.newWithGithubCredentials(ctx, opts, creds) + if err != nil { + return nil, err } + provider.ID = id + provider.CreatedAt = createdAt return provider, nil } // String provides a human meaningful description of the vcs provider, using the -// name if set, otherwise using the name of the underlying cloud provider. +// name if set; otherwise a name is constructed using both the underlying cloud +// kind and the auth kind. func (t *VCSProvider) String() string { if t.Name != "" { return t.Name } - return t.CloudConfig.Name + s := string(t.Kind) + if t.Token != nil { + s += " (token)" + } + if t.GithubApp != nil { + s += " (app)" + } + return s } -func (t *VCSProvider) NewClient(ctx context.Context) (cloud.Client, error) { - return t.CloudConfig.NewClient(ctx, cloud.Credentials{ - PersonalToken: &t.Token, - }) +func (t *VCSProvider) NewClient() (vcs.Client, error) { + if t.GithubApp != nil { + return github.NewClient(github.ClientOptions{ + Hostname: t.Hostname, + InstallCredentials: t.GithubApp, + SkipTLSVerification: t.skipTLSVerification, + }) + } else if t.Token != nil { + opts := vcs.NewTokenClientOptions{ + Hostname: t.Hostname, + Token: *t.Token, + SkipTLSVerification: t.skipTLSVerification, + } + switch t.Kind { + case vcs.GithubKind: + return github.NewTokenClient(opts) + case vcs.GitlabKind: + return gitlab.NewTokenClient(opts) + default: + return nil, fmt.Errorf("unknown kind: %s", t.Kind) + } + } else { + return nil, fmt.Errorf("missing credentials") + } } func (t *VCSProvider) Update(opts UpdateOptions) error { @@ -91,18 +170,25 @@ func (t *VCSProvider) Update(opts UpdateOptions) error { // LogValue implements slog.LogValuer. func (t *VCSProvider) LogValue() slog.Value { - return slog.GroupValue( + attrs := []slog.Attr{ slog.String("id", t.ID), slog.String("organization", t.Organization), slog.String("name", t.String()), - slog.String("cloud", t.CloudConfig.Name), - ) + slog.String("kind", string(t.Kind)), + } + if t.GithubApp != nil { + attrs = append(attrs, slog.Int64("github_install_id", t.GithubApp.ID)) + } + if t.Token != nil { + attrs = append(attrs, slog.String("token", "****")) + } + return slog.GroupValue(attrs...) } func (t *VCSProvider) setToken(token string) error { if token == "" { return fmt.Errorf("token: %w", internal.ErrEmptyValue) } - t.Token = token + t.Token = &token return nil } diff --git a/internal/vcsprovider/web.go b/internal/vcsprovider/web.go index d7499cbdc..3724e2495 100644 --- a/internal/vcsprovider/web.go +++ b/internal/vcsprovider/web.go @@ -1,76 +1,131 @@ package vcsprovider import ( - "fmt" "net/http" "github.com/gorilla/mux" - "github.com/leg100/otf/internal/cloud" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/github" "github.com/leg100/otf/internal/http/decode" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/organization" + "github.com/leg100/otf/internal/vcs" ) type webHandlers struct { html.Renderer - CloudService + internal.HostnameService + github.GithubAppService - svc Service + svc Service + GithubHostname string + GitlabHostname string } func (h *webHandlers) addHandlers(r *mux.Router) { r = html.UIRouter(r) r.HandleFunc("/organizations/{organization_name}/vcs-providers", h.list).Methods("GET") - r.HandleFunc("/organizations/{organization_name}/vcs-providers/new", h.new).Methods("GET") + r.HandleFunc("/organizations/{organization_name}/vcs-providers/new", h.newPersonalToken).Methods("GET") + r.HandleFunc("/organizations/{organization_name}/vcs-providers/new-github-app", h.newGithubApp).Methods("GET") r.HandleFunc("/organizations/{organization_name}/vcs-providers/create", h.create).Methods("POST") r.HandleFunc("/vcs-providers/{vcs_provider_id}/edit", h.edit).Methods("GET") r.HandleFunc("/vcs-providers/{vcs_provider_id}/update", h.update).Methods("POST") r.HandleFunc("/vcs-providers/{vcs_provider_id}/delete", h.delete).Methods("POST") + r.HandleFunc("/vcs-providers/{vcs_provider_id}", h.get).Methods("GET") } -func (h *webHandlers) new(w http.ResponseWriter, r *http.Request) { +func (h *webHandlers) newPersonalToken(w http.ResponseWriter, r *http.Request) { var params struct { - Organization string `schema:"organization_name,required"` - Cloud string `schema:"cloud,required"` + Organization string `schema:"organization_name,required"` + Kind vcs.Kind `schema:"kind,required"` } if err := decode.All(¶ms, r); err != nil { h.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - tmpl := fmt.Sprintf("vcs_provider_%s_new.tmpl", params.Cloud) - h.Render(tmpl, w, struct { + response := struct { organization.OrganizationPage VCSProvider *VCSProvider FormAction string EditMode bool + TokensURL string + Scope string + Kind string }{ OrganizationPage: organization.NewPage(r, "new vcs provider", params.Organization), - VCSProvider: &VCSProvider{CloudConfig: cloud.Config{Name: params.Cloud}}, + VCSProvider: &VCSProvider{Kind: params.Kind}, FormAction: paths.CreateVCSProvider(params.Organization), EditMode: false, + } + switch params.Kind { + case vcs.GithubKind: + response.Kind = string(vcs.GithubKind) + response.Scope = "repo" + response.TokensURL = "https://" + h.GithubHostname + "/settings/tokens" + case vcs.GitlabKind: + response.Kind = string(vcs.GitlabKind) + response.Scope = "api" + response.TokensURL = "https://" + h.GitlabHostname + "/-/profile/personal_access_tokens" + } + h.Render("vcs_provider_pat_new.tmpl", w, response) +} + +func (h *webHandlers) newGithubApp(w http.ResponseWriter, r *http.Request) { + var params struct { + Organization string `schema:"organization_name,required"` + } + if err := decode.All(¶ms, r); err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + app, err := h.GetGithubApp(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + installs, err := h.ListInstallations(r.Context()) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h.Render("vcs_provider_github_app_new.tmpl", w, struct { + organization.OrganizationPage + App *github.App + Installations []*github.Installation + Kind vcs.Kind + GithubHostname string + }{ + OrganizationPage: organization.NewPage(r, "new vcs provider", params.Organization), + App: app, + Installations: installs, + Kind: vcs.GithubKind, + GithubHostname: h.GithubHostname, }) } func (h *webHandlers) create(w http.ResponseWriter, r *http.Request) { var params struct { - OrganizationName string `schema:"organization_name,required"` - Token string `schema:"token,required"` - Name string `schema:"name"` - Cloud string `schema:"cloud,required"` + OrganizationName string `schema:"organization_name,required"` + Token *string `schema:"token"` + GithubAppInstallID *int64 `schema:"install_id"` + Name string `schema:"name"` + Kind *vcs.Kind `schema:"kind"` } if err := decode.All(¶ms, r); err != nil { h.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - provider, err := h.svc.CreateVCSProvider(r.Context(), CreateOptions{ - Organization: params.OrganizationName, - Token: params.Token, - Cloud: params.Cloud, - Name: params.Name, + Organization: params.OrganizationName, + Token: params.Token, + GithubAppInstallID: params.GithubAppInstallID, + Name: params.Name, + Kind: params.Kind, }) if err != nil { h.Error(w, err.Error(), http.StatusInternalServerError) @@ -139,21 +194,43 @@ func (h *webHandlers) list(w http.ResponseWriter, r *http.Request) { h.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - + app, err := h.GetGithubApp(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } providers, err := h.svc.ListVCSProviders(r.Context(), org) if err != nil { h.Error(w, err.Error(), http.StatusInternalServerError) return } - h.Render("vcs_provider_list.tmpl", w, struct { organization.OrganizationPage - Items []*VCSProvider - CloudConfigs []cloud.Config + Items []*VCSProvider + GithubApp *github.App }{ OrganizationPage: organization.NewPage(r, "vcs providers", org), Items: providers, - CloudConfigs: h.ListCloudConfigs(), + GithubApp: app, + }) +} + +func (h *webHandlers) get(w http.ResponseWriter, r *http.Request) { + id, err := decode.Param("vcs_provider_id", r) + if err != nil { + h.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + + provider, err := h.svc.GetVCSProvider(r.Context(), id) + if err != nil { + h.Error(w, err.Error(), http.StatusInternalServerError) + return + } + h.Render("vcs_provider_get.tmpl", w, struct { + VCSProvider *VCSProvider + }{ + VCSProvider: provider, }) } diff --git a/internal/vcsprovider/web_test.go b/internal/vcsprovider/web_test.go index 568d0eab3..d9cef1e7b 100644 --- a/internal/vcsprovider/web_test.go +++ b/internal/vcsprovider/web_test.go @@ -1,94 +1,131 @@ package vcsprovider import ( - "fmt" + "context" "net/http/httptest" "net/url" "strings" "testing" - "github.com/leg100/otf/internal/http/html" - "github.com/leg100/otf/internal/inmem" - "github.com/leg100/otf/internal/organization" + gogithub "github.com/google/go-github/v55/github" + "github.com/leg100/otf/internal" + "github.com/leg100/otf/internal/github" + "github.com/leg100/otf/internal/testutils" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestVCSProvider_NewHandler(t *testing.T) { - org := organization.NewTestOrganization(t) - svc := fakeWebServices(t, newTestVCSProvider(t, org)) +func TestVCSProvider_newPersonalToken(t *testing.T) { + svc := &webHandlers{ + Renderer: testutils.NewRenderer(t), + } - for _, cloud := range []string{"github", "gitlab"} { - t.Run(cloud, func(t *testing.T) { - q := "/?organization_name=acme-corp&cloud=" + cloud + for _, kind := range []string{"github", "gitlab"} { + t.Run(kind, func(t *testing.T) { + q := "/?organization_name=acme-corp&kind=" + kind r := httptest.NewRequest("GET", q, nil) w := httptest.NewRecorder() - svc.new(w, r) + svc.newPersonalToken(w, r) assert.Equal(t, 200, w.Code, w.Body.String()) }) } } +func TestVCSProvider_newGithubApp(t *testing.T) { + svc := &webHandlers{ + Renderer: testutils.NewRenderer(t), + GithubAppService: &fakeGithubAppService{ + app: &github.App{}, + installs: []*github.Installation{{ + Installation: &gogithub.Installation{ID: internal.Int64(123)}, + }}, + }, + } + + q := "/?organization_name=acme-corp&" + r := httptest.NewRequest("GET", q, nil) + w := httptest.NewRecorder() + svc.newGithubApp(w, r) + assert.Equal(t, 200, w.Code, w.Body.String()) +} + func TestCreateVCSProviderHandler(t *testing.T) { - org := organization.NewTestOrganization(t) - svc := fakeWebServices(t, newTestVCSProvider(t, org)) + svc := &webHandlers{ + Renderer: testutils.NewRenderer(t), + GithubAppService: &fakeGithubAppService{}, + svc: &fakeService{provider: &VCSProvider{Organization: "acme-corp"}}, + } - form := strings.NewReader(url.Values{ + r := httptest.NewRequest("POST", "/organization/acme-corp/vcs-providers/create", strings.NewReader(url.Values{ "organization_name": {"acme-corp"}, "token": {"secret-token"}, "name": {"my-new-vcs-provider"}, - "cloud": {"fake-cloud"}, - }.Encode()) - - r := httptest.NewRequest("POST", "/organization/acme-corp/vcs-providers/create", form) + "kind": {"fake-cloud"}, + }.Encode())) r.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() + svc.create(w, r) - if assert.Equal(t, 302, w.Code) { - redirect, err := w.Result().Location() - require.NoError(t, err) - assert.Equal(t, fmt.Sprintf("/app/organizations/%s/vcs-providers", org.Name), redirect.Path) - } else { - t.Log(w.Body.String()) - } + testutils.AssertRedirect(t, w, "/app/organizations/acme-corp/vcs-providers") } func TestListVCSProvidersHandler(t *testing.T) { - org := organization.NewTestOrganization(t) - app := fakeWebServices(t, newTestVCSProvider(t, org)) + svc := &webHandlers{ + Renderer: testutils.NewRenderer(t), + GithubAppService: &fakeGithubAppService{}, + svc: &fakeService{provider: &VCSProvider{Organization: "acme-corp"}}, + } r := httptest.NewRequest("GET", "/?organization_name=acme-corp", nil) w := httptest.NewRecorder() - app.list(w, r) + svc.list(w, r) - assert.Equal(t, 200, w.Code) + assert.Equal(t, 200, w.Code, w.Body.String()) } func TestDeleteVCSProvidersHandler(t *testing.T) { - org := organization.NewTestOrganization(t) - app := fakeWebServices(t, newTestVCSProvider(t, org)) + svc := &webHandlers{ + svc: &fakeService{provider: &VCSProvider{Organization: "acme"}}, + } - form := strings.NewReader(url.Values{ + r := httptest.NewRequest("POST", "/?", strings.NewReader(url.Values{ "vcs_provider_id": {"fake-id"}, - }.Encode()) - - r := httptest.NewRequest("POST", "/?", form) + }.Encode())) r.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - app.delete(w, r) + svc.delete(w, r) - assert.Equal(t, 302, w.Code) + testutils.AssertRedirect(t, w, "/app/organizations/acme/vcs-providers") } -func fakeWebServices(t *testing.T, provider *VCSProvider) *webHandlers { - renderer, err := html.NewRenderer(false) - require.NoError(t, err) - return &webHandlers{ - Renderer: renderer, - svc: &fakeService{provider: provider}, - CloudService: inmem.NewCloudServiceWithDefaults(), - } +type fakeService struct { + provider *VCSProvider + + Service +} + +func (f *fakeService) CreateVCSProvider(ctx context.Context, opts CreateOptions) (*VCSProvider, error) { + return f.provider, nil +} + +func (f *fakeService) ListVCSProviders(context.Context, string) ([]*VCSProvider, error) { + return []*VCSProvider{f.provider}, nil +} + +func (f *fakeService) DeleteVCSProvider(context.Context, string) (*VCSProvider, error) { + return f.provider, nil +} + +type fakeGithubAppService struct { + app *github.App + installs []*github.Installation + github.GithubAppService +} + +func (f *fakeGithubAppService) GetGithubApp(context.Context) (*github.App, error) { + return f.app, nil +} + +func (f *fakeGithubAppService) ListInstallations(context.Context) ([]*github.Installation, error) { + return f.installs, nil } diff --git a/internal/workspace/db.go b/internal/workspace/db.go index ebc4f7c02..be05adab6 100644 --- a/internal/workspace/db.go +++ b/internal/workspace/db.go @@ -3,7 +3,6 @@ package workspace import ( "context" - "github.com/google/uuid" "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/leg100/otf/internal" @@ -54,7 +53,6 @@ type ( UserLock *pggen.Users `json:"user_lock"` RunLock *pggen.Runs `json:"run_lock"` WorkspaceConnection *pggen.RepoConnections `json:"workspace_connection"` - Webhook *pggen.Webhooks `json:"webhook"` } ) @@ -88,8 +86,8 @@ func (r pgresult) toWorkspace() (*Workspace, error) { if r.WorkspaceConnection != nil { ws.Connection = &Connection{ AllowCLIApply: r.AllowCLIApply, - VCSProviderID: r.Webhook.VCSProviderID.String, - Repo: r.Webhook.Identifier.String, + VCSProviderID: r.WorkspaceConnection.VCSProviderID.String, + Repo: r.WorkspaceConnection.RepoPath.String, Branch: r.Branch.String, } if r.VCSTagsRegex.Status == pgtype.Present { @@ -265,9 +263,9 @@ func (db *pgdb) list(ctx context.Context, opts ListOptions) (*resource.Page[*Wor return resource.NewPage(items, opts.PageOptions, internal.Int64(count.Int)), nil } -func (db *pgdb) listByWebhookID(ctx context.Context, id uuid.UUID) ([]*Workspace, error) { +func (db *pgdb) listByConnection(ctx context.Context, vcsProviderID, repoPath string) ([]*Workspace, error) { q := db.Conn(ctx) - rows, err := q.FindWorkspacesByWebhookID(ctx, sql.UUID(id)) + rows, err := q.FindWorkspacesByConnection(ctx, sql.String(vcsProviderID), sql.String(repoPath)) if err != nil { return nil, err } diff --git a/internal/workspace/service.go b/internal/workspace/service.go index 51235ca6d..25e5fdfdb 100644 --- a/internal/workspace/service.go +++ b/internal/workspace/service.go @@ -4,16 +4,15 @@ import ( "context" "github.com/go-logr/logr" - "github.com/google/uuid" "github.com/gorilla/mux" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" + "github.com/leg100/otf/internal/connections" "github.com/leg100/otf/internal/hooks" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/pubsub" "github.com/leg100/otf/internal/rbac" - "github.com/leg100/otf/internal/repo" "github.com/leg100/otf/internal/resource" "github.com/leg100/otf/internal/sql" "github.com/leg100/otf/internal/sql/pggen" @@ -32,10 +31,7 @@ type ( GetWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) GetWorkspaceByName(ctx context.Context, organization, workspace string) (*Workspace, error) ListWorkspaces(ctx context.Context, opts ListOptions) (*resource.Page[*Workspace], error) - // ListWorkspacesByWebhookID retrieves workspaces by webhook ID. - // - // TODO: rename to ListConnectedWorkspaces - ListWorkspacesByRepoID(ctx context.Context, repoID uuid.UUID) ([]*Workspace, error) + ListConnectedWorkspaces(ctx context.Context, vcsProviderID, repoPath string) ([]*Workspace, error) DeleteWorkspace(ctx context.Context, workspaceID string) (*Workspace, error) SetCurrentRun(ctx context.Context, workspaceID, runID string) (*Workspace, error) @@ -50,15 +46,15 @@ type ( service struct { logr.Logger pubsub.Publisher + connections.ConnectionService site internal.Authorizer organization internal.Authorizer internal.Authorizer // workspace authorizer - db *pgdb - repo repo.RepoService - web *webHandlers - api *tfe + db *pgdb + web *webHandlers + api *tfe createHook *hooks.Hook[*Workspace] } @@ -70,7 +66,7 @@ type ( html.Renderer organization.OrganizationService vcsprovider.VCSProviderService - repo.RepoService + connections.ConnectionService auth.TeamService logr.Logger } @@ -85,11 +81,11 @@ func NewService(opts Options) *service { Logger: opts.Logger, db: db, }, - db: db, - repo: opts.RepoService, - organization: &organization.Authorizer{Logger: opts.Logger}, - site: &internal.SiteAuthorizer{Logger: opts.Logger}, - createHook: hooks.NewHook[*Workspace](opts.DB), + db: db, + ConnectionService: opts.ConnectionService, + organization: &organization.Authorizer{Logger: opts.Logger}, + site: &internal.SiteAuthorizer{Logger: opts.Logger}, + createHook: hooks.NewHook[*Workspace](opts.DB), } svc.web = &webHandlers{ Renderer: opts.Renderer, @@ -234,8 +230,8 @@ func (s *service) ListWorkspaces(ctx context.Context, opts ListOptions) (*resour return s.db.list(ctx, opts) } -func (s *service) ListWorkspacesByRepoID(ctx context.Context, repoID uuid.UUID) ([]*Workspace, error) { - return s.db.listByWebhookID(ctx, repoID) +func (s *service) ListConnectedWorkspaces(ctx context.Context, vcsProviderID, repoPath string) ([]*Workspace, error) { + return s.db.listByConnection(ctx, vcsProviderID, repoPath) } func (s *service) UpdateWorkspace(ctx context.Context, workspaceID string, opts UpdateOptions) (*Workspace, error) { @@ -313,8 +309,8 @@ func (s *service) connect(ctx context.Context, workspaceID string, connection *C return err } - _, err = s.repo.Connect(ctx, repo.ConnectOptions{ - ConnectionType: repo.WorkspaceConnection, + _, err = s.Connect(ctx, connections.ConnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: workspaceID, VCSProviderID: connection.VCSProviderID, RepoPath: connection.Repo, @@ -334,8 +330,8 @@ func (s *service) disconnect(ctx context.Context, workspaceID string) error { return err } - err = s.repo.Disconnect(ctx, repo.DisconnectOptions{ - ConnectionType: repo.WorkspaceConnection, + err = s.Disconnect(ctx, connections.DisconnectOptions{ + ConnectionType: connections.WorkspaceConnection, ResourceID: workspaceID, }) if err != nil { diff --git a/internal/workspace/test_helpers.go b/internal/workspace/test_helpers.go index eeb65cbd7..b071e17fe 100644 --- a/internal/workspace/test_helpers.go +++ b/internal/workspace/test_helpers.go @@ -6,9 +6,9 @@ import ( "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/resource" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/stretchr/testify/require" ) @@ -97,7 +97,7 @@ func (f *fakeWebService) ListTeams(context.Context, string) ([]*auth.Team, error return f.teams, nil } -func (f *fakeWebService) GetVCSClient(ctx context.Context, providerID string) (cloud.Client, error) { +func (f *fakeWebService) GetVCSClient(ctx context.Context, providerID string) (vcs.Client, error) { return &fakeWebCloudClient{repos: f.repos}, nil } @@ -140,9 +140,9 @@ func (f *fakeWebService) ListTags(context.Context, string, ListTagsOptions) (*re type fakeWebCloudClient struct { repos []string - cloud.Client + vcs.Client } -func (f *fakeWebCloudClient) ListRepositories(ctx context.Context, opts cloud.ListRepositoriesOptions) ([]string, error) { +func (f *fakeWebCloudClient) ListRepositories(ctx context.Context, opts vcs.ListRepositoriesOptions) ([]string, error) { return f.repos, nil } diff --git a/internal/workspace/web.go b/internal/workspace/web.go index 1168506d3..f69ba1150 100644 --- a/internal/workspace/web.go +++ b/internal/workspace/web.go @@ -7,13 +7,13 @@ import ( "github.com/gorilla/mux" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/decode" "github.com/leg100/otf/internal/http/html" "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/organization" "github.com/leg100/otf/internal/rbac" "github.com/leg100/otf/internal/resource" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" ) @@ -592,7 +592,7 @@ func (h *webHandlers) listWorkspaceVCSRepos(w http.ResponseWriter, r *http.Reque h.Error(w, err.Error(), http.StatusInternalServerError) return } - repos, err := client.ListRepositories(r.Context(), cloud.ListRepositoriesOptions{ + repos, err := client.ListRepositories(r.Context(), vcs.ListRepositoriesOptions{ PageSize: html.PageSize, }) if err != nil { diff --git a/internal/workspace/web_test.go b/internal/workspace/web_test.go index 82a0919c6..23ef210aa 100644 --- a/internal/workspace/web_test.go +++ b/internal/workspace/web_test.go @@ -11,10 +11,10 @@ import ( "github.com/antchfx/htmlquery" "github.com/leg100/otf/internal" "github.com/leg100/otf/internal/auth" - "github.com/leg100/otf/internal/cloud" "github.com/leg100/otf/internal/http/html/paths" "github.com/leg100/otf/internal/rbac" "github.com/leg100/otf/internal/testutils" + "github.com/leg100/otf/internal/vcs" "github.com/leg100/otf/internal/vcsprovider" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -320,11 +320,11 @@ func TestListWorkspaceReposHandler(t *testing.T) { ws := &Workspace{ID: "ws-123", Organization: "acme-corp"} app := fakeWebHandlers(t, withWorkspaces(ws), withVCSProviders(&vcsprovider.VCSProvider{}), withRepos( - cloud.NewTestRepo(), - cloud.NewTestRepo(), - cloud.NewTestRepo(), - cloud.NewTestRepo(), - cloud.NewTestRepo(), + vcs.NewTestRepo(), + vcs.NewTestRepo(), + vcs.NewTestRepo(), + vcs.NewTestRepo(), + vcs.NewTestRepo(), )) q := "/?workspace_id=ws-123&vcs_provider_id=fake-provider" diff --git a/mkdocs.yml b/mkdocs.yml index 4157861be..dadc692ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,12 +52,13 @@ nav: - auth/providers/gitlab.md - auth/providers/oidc.md - auth/providers/iap.md - - auth/site_admin.md + - auth/site_admins.md - auth/user_token.md - auth/org_token.md - Topics: - rbac.md - vcs_providers.md + - github_app.md - agents.md - registry.md - cli.md