actions.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  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. }
  193. type GithubActionYAMLJob struct {
  194. RunsOn string `yaml:"runs-on,omitempty"`
  195. Steps []GithubActionYAMLStep `yaml:"steps,omitempty"`
  196. Concurrency map[string]string `yaml:"concurrency,omitempty"`
  197. }
  198. type GithubActionYAML struct {
  199. On interface{} `yaml:"on,omitempty"`
  200. Name string `yaml:"name,omitempty"`
  201. Jobs map[string]GithubActionYAMLJob `yaml:"jobs,omitempty"`
  202. }
  203. func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
  204. gaSteps := []GithubActionYAMLStep{
  205. getCheckoutCodeStep(),
  206. getSetTagStep(),
  207. getUpdateAppStep(g.ServerURL, g.getPorterTokenSecretName(), g.ProjectID, g.ClusterID, g.ReleaseName, g.ReleaseNamespace, g.Version),
  208. }
  209. branch := g.GitBranch
  210. if branch == "" {
  211. branch = g.defaultBranch
  212. }
  213. actionYAML := GithubActionYAML{
  214. On: GithubActionYAMLOnPush{
  215. Push: GithubActionYAMLOnPushBranches{
  216. Branches: []string{
  217. branch,
  218. },
  219. },
  220. },
  221. Name: "Deploy to Porter",
  222. Jobs: map[string]GithubActionYAMLJob{
  223. "porter-deploy": {
  224. RunsOn: "ubuntu-latest",
  225. Steps: gaSteps,
  226. },
  227. },
  228. }
  229. return yaml.Marshal(actionYAML)
  230. }
  231. func (g *GithubActions) getClient() (*github.Client, error) {
  232. // in the case that this still uses the oauth integration
  233. if g.GithubOAuthIntegration != nil {
  234. // get the oauth integration
  235. oauthInt, err := g.Repo.OAuthIntegration().ReadOAuthIntegration(g.ProjectID, g.GithubOAuthIntegration.OAuthIntegrationID)
  236. if err != nil {
  237. return nil, err
  238. }
  239. _, _, err = oauth.GetAccessToken(oauthInt.SharedOAuthModel, g.GithubConf, oauth.MakeUpdateOAuthIntegrationTokenFunction(oauthInt, g.Repo))
  240. if err != nil {
  241. return nil, err
  242. }
  243. client := github.NewClient(g.GithubConf.Client(oauth2.NoContext, &oauth2.Token{
  244. AccessToken: string(oauthInt.AccessToken),
  245. RefreshToken: string(oauthInt.RefreshToken),
  246. Expiry: oauthInt.Expiry,
  247. TokenType: "Bearer",
  248. }))
  249. return client, nil
  250. }
  251. // authenticate as github app installation
  252. itr, err := ghinstallation.NewKeyFromFile(
  253. http.DefaultTransport,
  254. g.GithubAppID,
  255. int64(g.GithubInstallationID),
  256. g.GithubAppSecretPath)
  257. if err != nil {
  258. return nil, err
  259. }
  260. return github.NewClient(&http.Client{Transport: itr}), nil
  261. }
  262. func CreateGithubSecret(
  263. client *github.Client,
  264. secretName,
  265. secretValue,
  266. gitRepoOwner,
  267. gitRepoName string,
  268. ) error {
  269. // get the public key for the repo
  270. key, _, err := client.Actions.GetRepoPublicKey(context.TODO(), gitRepoOwner, gitRepoName)
  271. if err != nil {
  272. return err
  273. }
  274. // encrypt the secret with the public key
  275. keyBytes := [32]byte{}
  276. keyDecoded, err := base64.StdEncoding.DecodeString(*key.Key)
  277. if err != nil {
  278. return err
  279. }
  280. copy(keyBytes[:], keyDecoded[:])
  281. secretEncoded, err := box.SealAnonymous(nil, []byte(secretValue), &keyBytes, nil)
  282. if err != nil {
  283. return err
  284. }
  285. encrypted := base64.StdEncoding.EncodeToString(secretEncoded)
  286. encryptedSecret := &github.EncryptedSecret{
  287. Name: secretName,
  288. KeyID: *key.KeyID,
  289. EncryptedValue: encrypted,
  290. }
  291. // write the secret to the repo
  292. _, err = client.Actions.CreateOrUpdateRepoSecret(context.TODO(), gitRepoOwner, gitRepoName, encryptedSecret)
  293. return err
  294. }
  295. func (g *GithubActions) deleteGithubSecret(
  296. client *github.Client,
  297. secretName string,
  298. ) error {
  299. // delete the secret from the repo
  300. _, err := client.Actions.DeleteRepoSecret(
  301. context.TODO(),
  302. g.GitRepoOwner,
  303. g.GitRepoName,
  304. secretName,
  305. )
  306. return err
  307. }
  308. func (g *GithubActions) CreateEnvSecret() error {
  309. client, err := g.getClient()
  310. if err != nil {
  311. return err
  312. }
  313. return g.createEnvSecret(client)
  314. }
  315. func (g *GithubActions) createEnvSecret(client *github.Client) error {
  316. // convert the env object to a string
  317. lines := make([]string, 0)
  318. for key, val := range g.BuildEnv {
  319. lines = append(lines, fmt.Sprintf(`%s=%s`, key, val))
  320. }
  321. secretName := g.getBuildEnvSecretName()
  322. return CreateGithubSecret(client, secretName, strings.Join(lines, "\n"), g.GitRepoOwner, g.GitRepoName)
  323. }
  324. func (g *GithubActions) getWebhookSecretName() string {
  325. return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
  326. strings.ToUpper(g.ReleaseName), "-", "_", -1),
  327. )
  328. }
  329. func (g *GithubActions) getBuildEnvSecretName() string {
  330. return fmt.Sprintf("ENV_%s", strings.Replace(
  331. strings.ToUpper(g.ReleaseName), "-", "_", -1),
  332. )
  333. }
  334. func (g *GithubActions) getPorterYMLFileName() string {
  335. if g.InstanceName != "" {
  336. return fmt.Sprintf("porter_%s_%s.yml", strings.Replace(
  337. strings.ToLower(g.ReleaseName), "-", "_", -1),
  338. strings.ToLower(g.InstanceName),
  339. )
  340. }
  341. return fmt.Sprintf("porter_%s.yml", strings.Replace(
  342. strings.ToLower(g.ReleaseName), "-", "_", -1),
  343. )
  344. }
  345. func (g *GithubActions) getPorterTokenSecretName() string {
  346. if g.InstanceName != "" {
  347. return fmt.Sprintf("PORTER_TOKEN_%s_%d", strings.ToUpper(g.InstanceName), g.ProjectID)
  348. }
  349. return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
  350. }
  351. func getPorterTokenSecretName(projectID uint) string {
  352. return fmt.Sprintf("PORTER_TOKEN_%d", projectID)
  353. }
  354. func commitWorkflowFile(
  355. client *github.Client,
  356. filename string,
  357. contents []byte,
  358. gitRepoOwner, gitRepoName, branch string,
  359. isOAuth bool,
  360. ) (string, error) {
  361. filepath := ".github/workflows/" + filename
  362. sha := ""
  363. // get contents of a file if it exists
  364. fileData, _, _, _ := client.Repositories.GetContents(
  365. context.TODO(),
  366. gitRepoOwner,
  367. gitRepoName,
  368. filepath,
  369. &github.RepositoryContentGetOptions{
  370. Ref: branch,
  371. },
  372. )
  373. if fileData != nil {
  374. sha = *fileData.SHA
  375. }
  376. opts := &github.RepositoryContentFileOptions{
  377. Message: github.String(fmt.Sprintf("Create %s file", filename)),
  378. Content: contents,
  379. Branch: github.String(branch),
  380. SHA: &sha,
  381. }
  382. if isOAuth {
  383. opts.Committer = &github.CommitAuthor{
  384. Name: github.String("Porter Bot"),
  385. Email: github.String("contact@getporter.dev"),
  386. }
  387. }
  388. resp, _, err := client.Repositories.UpdateFile(
  389. context.TODO(),
  390. gitRepoOwner,
  391. gitRepoName,
  392. filepath,
  393. opts,
  394. )
  395. if err != nil {
  396. return "", err
  397. }
  398. return *resp.Commit.SHA, nil
  399. }
  400. func deleteGithubFile(
  401. client *github.Client,
  402. filename, gitRepoOwner, gitRepoName, branch string,
  403. isOAuth bool,
  404. ) error {
  405. filepath := ".github/workflows/" + filename
  406. // get contents of a file if it exists
  407. fileData, _, _, _ := client.Repositories.GetContents(
  408. context.TODO(),
  409. gitRepoOwner,
  410. gitRepoName,
  411. filepath,
  412. &github.RepositoryContentGetOptions{
  413. Ref: branch,
  414. },
  415. )
  416. sha := ""
  417. if fileData != nil {
  418. sha = *fileData.SHA
  419. }
  420. opts := &github.RepositoryContentFileOptions{
  421. Message: github.String(fmt.Sprintf("Delete %s file", filename)),
  422. Branch: &branch,
  423. SHA: &sha,
  424. }
  425. if isOAuth {
  426. opts.Committer = &github.CommitAuthor{
  427. Name: github.String("Porter Bot"),
  428. Email: github.String("contact@getporter.dev"),
  429. }
  430. }
  431. _, response, err := client.Repositories.DeleteFile(
  432. context.TODO(),
  433. gitRepoOwner,
  434. gitRepoName,
  435. filepath,
  436. opts,
  437. )
  438. if response != nil && response.StatusCode == 404 {
  439. return nil
  440. }
  441. if err != nil {
  442. return err
  443. }
  444. return nil
  445. }