actions.go 12 KB

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