actions.go 13 KB

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