actions.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. package actions
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "strings"
  9. "github.com/Masterminds/semver/v3"
  10. ghinstallation "github.com/bradleyfalzon/ghinstallation/v2"
  11. "github.com/google/go-github/v41/github"
  12. "github.com/porter-dev/porter/internal/models"
  13. "github.com/porter-dev/porter/internal/oauth"
  14. "github.com/porter-dev/porter/internal/repository"
  15. "golang.org/x/crypto/nacl/box"
  16. "golang.org/x/oauth2"
  17. "gopkg.in/yaml.v2"
  18. )
  19. var (
  20. ErrProtectedBranch = errors.New("protected branch")
  21. ErrCreatePRForProtectedBranch = errors.New("unable to create PR to merge workflow files into protected branch")
  22. )
  23. type GithubActions struct {
  24. ServerURL string
  25. InstanceName string
  26. GithubOAuthIntegration *models.GitRepo
  27. GitRepoName string
  28. GitRepoOwner string
  29. Repo repository.Repository
  30. GithubConf *oauth2.Config // one of these will let us authenticate
  31. GithubAppID int64
  32. GithubAppSecretPath string
  33. GithubInstallationID uint
  34. PorterToken string
  35. BuildEnv map[string]string
  36. ProjectID uint
  37. ClusterID uint
  38. ReleaseName string
  39. ReleaseNamespace string
  40. GitBranch string
  41. DockerFilePath string
  42. FolderPath string
  43. ImageRepoURL string
  44. defaultBranch string
  45. Version string
  46. DryRun bool
  47. ShouldCreateWorkflow bool
  48. }
  49. var deleteWebhookAndEnvSecretsConstraint, _ = semver.NewConstraint(" < 0.1.0")
  50. func (g *GithubActions) Setup() ([]byte, error) {
  51. client, err := g.getClient()
  52. if err != nil {
  53. return nil, err
  54. }
  55. // get the repository to find the default branch
  56. repo, _, err := client.Repositories.Get(
  57. context.TODO(),
  58. g.GitRepoOwner,
  59. g.GitRepoName,
  60. )
  61. if err != nil {
  62. return nil, err
  63. }
  64. g.defaultBranch = repo.GetDefaultBranch()
  65. if !g.DryRun {
  66. // create porter token secret
  67. if err := CreateGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken, g.GitRepoOwner, g.GitRepoName); err != nil {
  68. return nil, err
  69. }
  70. }
  71. workflowYAML, err := g.GetGithubActionYAML()
  72. if err != nil {
  73. return nil, err
  74. }
  75. if !g.DryRun && g.ShouldCreateWorkflow {
  76. branch := g.GitBranch
  77. if branch == "" {
  78. branch = g.defaultBranch
  79. }
  80. // check if the branch is protected
  81. githubBranch, _, err := client.Repositories.GetBranch(
  82. context.Background(),
  83. g.GitRepoOwner,
  84. g.GitRepoName,
  85. branch,
  86. true,
  87. )
  88. if err != nil {
  89. return nil, err
  90. }
  91. isOAuth := g.GithubOAuthIntegration != nil
  92. if githubBranch.GetProtected() {
  93. err = createNewBranch(client, g.GitRepoOwner, g.GitRepoName, branch, "porter-setup")
  94. if err != nil {
  95. return nil, fmt.Errorf(
  96. "Unable to create PR to merge workflow files into protected branch: %s.\n"+
  97. "To enable automatic deployments to Porter, please create a Github workflow "+
  98. "file in this branch with the following contents:\n"+
  99. "--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
  100. )
  101. }
  102. _, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
  103. g.GitRepoName, "porter-setup", isOAuth)
  104. if err != nil {
  105. return nil, fmt.Errorf(
  106. "Unable to create PR to merge workflow files into protected branch: %s.\n"+
  107. "To enable automatic deployments to Porter, please create a Github workflow "+
  108. "file in this branch with the following contents:\n"+
  109. "--------\n%s--------\nERROR: %w", branch, string(workflowYAML), ErrCreatePRForProtectedBranch,
  110. )
  111. }
  112. pr, _, err := client.PullRequests.Create(
  113. context.Background(), g.GitRepoOwner, g.GitRepoName, &github.NewPullRequest{
  114. Title: github.String("Enable Porter automatic deployments"),
  115. Base: github.String(branch),
  116. Head: github.String("porter-setup"),
  117. },
  118. )
  119. if err != nil {
  120. return nil, err
  121. }
  122. return nil, fmt.Errorf("Please merge %s to enable automatic deployments on Porter.\nERROR: %w",
  123. pr.GetHTMLURL(), ErrProtectedBranch)
  124. }
  125. _, err = commitWorkflowFile(client, g.getPorterYMLFileName(), workflowYAML, g.GitRepoOwner,
  126. g.GitRepoName, branch, isOAuth)
  127. if err != nil {
  128. return workflowYAML, err
  129. }
  130. }
  131. return workflowYAML, err
  132. }
  133. func (g *GithubActions) Cleanup() error {
  134. client, err := g.getClient()
  135. if err != nil {
  136. return err
  137. }
  138. // get the repository to find the default branch
  139. repo, _, err := client.Repositories.Get(
  140. context.TODO(),
  141. g.GitRepoOwner,
  142. g.GitRepoName,
  143. )
  144. if err != nil {
  145. return err
  146. }
  147. g.defaultBranch = repo.GetDefaultBranch()
  148. actionVersion, err := semver.NewVersion(g.Version)
  149. if err != nil {
  150. return err
  151. }
  152. if deleteWebhookAndEnvSecretsConstraint.Check(actionVersion) {
  153. // delete the webhook token secret
  154. if err := g.deleteGithubSecret(client, g.getWebhookSecretName()); err != nil {
  155. return err
  156. }
  157. // delete the env secret
  158. if err := g.deleteGithubSecret(client, g.getBuildEnvSecretName()); err != nil {
  159. return err
  160. }
  161. }
  162. branch := g.GitBranch
  163. if branch == "" {
  164. branch = g.defaultBranch
  165. }
  166. isOAuth := g.GithubOAuthIntegration != nil
  167. return deleteGithubFile(client, g.getPorterYMLFileName(), g.GitRepoOwner, g.GitRepoName, branch, isOAuth)
  168. }
  169. type GithubActionYAMLStep struct {
  170. Name string `yaml:"name,omitempty"`
  171. ID string `yaml:"id,omitempty"`
  172. Timeout uint64 `yaml:"timeout-minutes,omitempty"`
  173. Uses string `yaml:"uses,omitempty"`
  174. Run string `yaml:"run,omitempty"`
  175. With map[string]string `yaml:"with,omitempty"`
  176. Env map[string]string `yaml:"env,omitempty"`
  177. }
  178. type GithubActionYAMLOnPushBranches struct {
  179. Branches []string `yaml:"branches,omitempty"`
  180. }
  181. type GithubActionYAMLOnPush struct {
  182. Push GithubActionYAMLOnPushBranches `yaml:"push,omitempty"`
  183. }
  184. // GithubActionYAMLOnPullRequest is a struct that represents the "on" field of a Github Action YAML file for pull request events
  185. type GithubActionYAMLOnPullRequest struct {
  186. PullRequest GithubActionYAMLOnPullRequestTypes `yaml:"pull_request,omitempty"`
  187. }
  188. // GithubActionYAMLOnPullRequestTypes is a struct that represents the "types" field of a Github Action YAML file for pull request events
  189. type GithubActionYAMLOnPullRequestTypes struct {
  190. Branches []string `yaml:"branches,omitempty"`
  191. Types []string `yaml:"types,omitempty"`
  192. Paths []string `yaml:"paths,omitempty"`
  193. }
  194. type GithubActionYAMLJob struct {
  195. RunsOn string `yaml:"runs-on,omitempty"`
  196. Steps []GithubActionYAMLStep `yaml:"steps,omitempty"`
  197. Concurrency map[string]string `yaml:"concurrency,omitempty"`
  198. }
  199. type GithubActionYAML struct {
  200. On interface{} `yaml:"on,omitempty"`
  201. Name string `yaml:"name,omitempty"`
  202. Jobs map[string]GithubActionYAMLJob `yaml:"jobs,omitempty"`
  203. }
  204. func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
  205. gaSteps := []GithubActionYAMLStep{
  206. getCheckoutCodeStep(),
  207. getSetTagStep(),
  208. getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
  209. }
  210. branch := g.GitBranch
  211. if branch == "" {
  212. branch = g.defaultBranch
  213. }
  214. actionYAML := GithubActionYAML{
  215. On: GithubActionYAMLOnPush{
  216. Push: GithubActionYAMLOnPushBranches{
  217. Branches: []string{
  218. branch,
  219. },
  220. },
  221. },
  222. Name: "Deploy to Porter",
  223. Jobs: map[string]GithubActionYAMLJob{
  224. "porter-deploy": {
  225. RunsOn: "ubuntu-latest",
  226. Steps: gaSteps,
  227. },
  228. },
  229. }
  230. return yaml.Marshal(actionYAML)
  231. }
  232. func (g *GithubActions) getClient() (*github.Client, error) {
  233. // in the case that this still uses the oauth integration
  234. if g.GithubOAuthIntegration != nil {
  235. // get the oauth integration
  236. oauthInt, err := g.Repo.OAuthIntegration().ReadOAuthIntegration(g.ProjectID, g.GithubOAuthIntegration.OAuthIntegrationID)
  237. if err != nil {
  238. return nil, err
  239. }
  240. _, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel, g.GithubConf, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, g.Repo))
  241. if err != nil {
  242. return nil, err
  243. }
  244. client := github.NewClient(g.GithubConf.Client(oauth2.NoContext, &oauth2.Token{
  245. AccessToken: string(oauthInt.AccessToken),
  246. RefreshToken: string(oauthInt.RefreshToken),
  247. Expiry: oauthInt.Expiry,
  248. TokenType: "Bearer",
  249. }))
  250. return client, nil
  251. }
  252. // authenticate as github app installation
  253. itr, err := ghinstallation.NewKeyFromFile(
  254. http.DefaultTransport,
  255. g.GithubAppID,
  256. int64(g.GithubInstallationID),
  257. g.GithubAppSecretPath)
  258. if err != nil {
  259. return nil, err
  260. }
  261. return github.NewClient(&http.Client{Transport: itr}), nil
  262. }
  263. func CreateGithubSecret(
  264. client *github.Client,
  265. secretName,
  266. secretValue,
  267. gitRepoOwner,
  268. gitRepoName string,
  269. ) error {
  270. // get the public key for the repo
  271. key, _, err := client.Actions.GetRepoPublicKey(context.TODO(), gitRepoOwner, gitRepoName)
  272. if err != nil {
  273. return err
  274. }
  275. // encrypt the secret with the public key
  276. keyBytes := [32]byte{}
  277. keyDecoded, err := base64.StdEncoding.DecodeString(*key.Key)
  278. if err != nil {
  279. return err
  280. }
  281. copy(keyBytes[:], keyDecoded[:])
  282. secretEncoded, err := box.SealAnonymous(nil, []byte(secretValue), &keyBytes, nil)
  283. if err != nil {
  284. return err
  285. }
  286. encrypted := base64.StdEncoding.EncodeToString(secretEncoded)
  287. encryptedSecret := &github.EncryptedSecret{
  288. Name: secretName,
  289. KeyID: *key.KeyID,
  290. EncryptedValue: encrypted,
  291. }
  292. // write the secret to the repo
  293. _, err = client.Actions.CreateOrUpdateRepoSecret(context.TODO(), gitRepoOwner, gitRepoName, encryptedSecret)
  294. return err
  295. }
  296. func (g *GithubActions) deleteGithubSecret(
  297. client *github.Client,
  298. secretName string,
  299. ) error {
  300. // delete the secret from the repo
  301. _, err := client.Actions.DeleteRepoSecret(
  302. context.TODO(),
  303. g.GitRepoOwner,
  304. g.GitRepoName,
  305. secretName,
  306. )
  307. return err
  308. }
  309. func (g *GithubActions) CreateEnvSecret() error {
  310. client, err := g.getClient()
  311. if err != nil {
  312. return err
  313. }
  314. return g.createEnvSecret(client)
  315. }
  316. func (g *GithubActions) createEnvSecret(client *github.Client) error {
  317. // convert the env object to a string
  318. lines := make([]string, 0)
  319. for key, val := range g.BuildEnv {
  320. lines = append(lines, fmt.Sprintf(`%s=%s`, key, val))
  321. }
  322. secretName := g.getBuildEnvSecretName()
  323. return CreateGithubSecret(client, secretName, strings.Join(lines, "\n"), g.GitRepoOwner, g.GitRepoName)
  324. }
  325. func (g *GithubActions) getWebhookSecretName() string {
  326. return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
  327. strings.ToUpper(g.ReleaseName), "-", "_", -1),
  328. )
  329. }
  330. func (g *GithubActions) getBuildEnvSecretName() string {
  331. return fmt.Sprintf("ENV_%s", strings.Replace(
  332. strings.ToUpper(g.ReleaseName), "-", "_", -1),
  333. )
  334. }
  335. func (g *GithubActions) getPorterYMLFileName() string {
  336. if g.InstanceName != "" {
  337. return fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
  338. strings.ToLower(g.ReleaseName), "-", "_", -1),
  339. strings.ToLower(g.InstanceName),
  340. )
  341. }
  342. return fmt.Sprintf("porter_%s.yml", strings.Replace(
  343. strings.ToLower(g.ReleaseName), "-", "_", -1),
  344. )
  345. }
  346. func (g *GithubActions) getPorterTokenSecretName() string {
  347. if g.InstanceName != "" {
  348. return fmt.Sprintf("PORTER_TOKEN_%s_%d", strings.ToUpper(g.InstanceName), g.ProjectID)
  349. }
  350. return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
  351. }
  352. func getPorterTokenSecretName(projectID uint) string {
  353. return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
  354. }
  355. func commitWorkflowFile(
  356. client *github.Client,
  357. filename string,
  358. contents []byte,
  359. gitRepoOwner, gitRepoName, branch string,
  360. isOAuth bool,
  361. ) (string, error) {
  362. filepath := ".github/workflows/" + filename
  363. sha := ""
  364. // get contents of a file if it exists
  365. fileData, _, _, _ := client.Repositories.GetContents(
  366. context.TODO(),
  367. gitRepoOwner,
  368. gitRepoName,
  369. filepath,
  370. &github.RepositoryContentGetOptions{
  371. Ref: branch,
  372. },
  373. )
  374. if fileData != nil {
  375. sha = *fileData.SHA
  376. }
  377. opts := &github.RepositoryContentFileOptions{
  378. Message: github.String(fmt.Sprintf("Create %s file", filename)),
  379. Content: contents,
  380. Branch: github.String(branch),
  381. SHA: &sha,
  382. }
  383. if isOAuth {
  384. opts.Committer = &github.CommitAuthor{
  385. Name: github.String("Porter Bot"),
  386. Email: github.String("contact@getporter.dev"),
  387. }
  388. }
  389. resp, _, err := client.Repositories.UpdateFile(
  390. context.TODO(),
  391. gitRepoOwner,
  392. gitRepoName,
  393. filepath,
  394. opts,
  395. )
  396. if err != nil {
  397. return "", err
  398. }
  399. return *resp.Commit.SHA, nil
  400. }
  401. func deleteGithubFile(
  402. client *github.Client,
  403. filename, gitRepoOwner, gitRepoName, branch string,
  404. isOAuth bool,
  405. ) error {
  406. filepath := ".github/workflows/" + filename
  407. // get contents of a file if it exists
  408. fileData, _, _, _ := client.Repositories.GetContents(
  409. context.TODO(),
  410. gitRepoOwner,
  411. gitRepoName,
  412. filepath,
  413. &github.RepositoryContentGetOptions{
  414. Ref: branch,
  415. },
  416. )
  417. sha := ""
  418. if fileData != nil {
  419. sha = *fileData.SHA
  420. }
  421. opts := &github.RepositoryContentFileOptions{
  422. Message: github.String(fmt.Sprintf("Delete %s file", filename)),
  423. Branch: &branch,
  424. SHA: &sha,
  425. }
  426. if isOAuth {
  427. opts.Committer = &github.CommitAuthor{
  428. Name: github.String("Porter Bot"),
  429. Email: github.String("contact@getporter.dev"),
  430. }
  431. }
  432. _, response, err := client.Repositories.DeleteFile(
  433. context.TODO(),
  434. gitRepoOwner,
  435. gitRepoName,
  436. filepath,
  437. opts,
  438. )
  439. if response != nil && response.StatusCode == 404 {
  440. return nil
  441. }
  442. if err != nil {
  443. return err
  444. }
  445. return nil
  446. }