actions.go 12 KB

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