apply.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. package v2
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "errors"
  6. "fmt"
  7. "os"
  8. "os/signal"
  9. "path/filepath"
  10. "strconv"
  11. "syscall"
  12. "time"
  13. "github.com/fatih/color"
  14. "github.com/porter-dev/porter/api/server/handlers/porter_app"
  15. "github.com/porter-dev/porter/api/types"
  16. "github.com/porter-dev/porter/internal/models"
  17. "github.com/cli/cli/git"
  18. api "github.com/porter-dev/porter/api/client"
  19. "github.com/porter-dev/porter/cli/cmd/config"
  20. )
  21. // ApplyInput is the input for the Apply function
  22. type ApplyInput struct {
  23. // CLIConfig is the CLI configuration
  24. CLIConfig config.CLIConfig
  25. // Client is the Porter API client
  26. Client api.Client
  27. // PorterYamlPath is the path to the porter.yaml file
  28. PorterYamlPath string
  29. // AppName is the name of the app
  30. AppName string
  31. // ImageTagOverride is the image tag to use for the app
  32. ImageTagOverride string
  33. // PreviewApply is true when Apply should create a new deployment target matching current git branch and apply to that target
  34. PreviewApply bool
  35. // WaitForSuccessfulDeployment is true when Apply should wait for the update to complete before returning
  36. WaitForSuccessfulDeployment bool
  37. // PullImageBeforeBuild will attempt to pull the image before building if true
  38. PullImageBeforeBuild bool
  39. // WithPredeploy is true when Apply should run the predeploy step
  40. WithPredeploy bool
  41. // Exact is true when Apply should use the exact app config provided by the user
  42. Exact bool
  43. }
  44. // Apply implements the functionality of the `porter apply` command for validate apply v2 projects
  45. func Apply(ctx context.Context, inp ApplyInput) error {
  46. ctx, cancel := context.WithCancel(ctx)
  47. defer cancel()
  48. go func() {
  49. termChan := make(chan os.Signal, 1)
  50. signal.Notify(termChan, syscall.SIGINT, syscall.SIGTERM)
  51. select {
  52. case <-termChan:
  53. color.New(color.FgYellow).Printf("Shutdown signal received, cancelling processes\n") // nolint:errcheck,gosec
  54. cancel()
  55. case <-ctx.Done():
  56. }
  57. }()
  58. cliConf := inp.CLIConfig
  59. client := inp.Client
  60. deploymentTargetID, err := deploymentTargetFromConfig(ctx, client, cliConf.Project, cliConf.Cluster, inp.PreviewApply)
  61. if err != nil {
  62. return fmt.Errorf("error getting deployment target from config: %w", err)
  63. }
  64. var prNumber int
  65. prNumberEnv := os.Getenv("PORTER_PR_NUMBER")
  66. if prNumberEnv != "" {
  67. prNumber, err = strconv.Atoi(prNumberEnv)
  68. if err != nil {
  69. return fmt.Errorf("error parsing PORTER_PR_NUMBER to int: %w", err)
  70. }
  71. }
  72. porterYamlExists := len(inp.PorterYamlPath) != 0
  73. if porterYamlExists {
  74. _, err := os.Stat(filepath.Clean(inp.PorterYamlPath))
  75. if err != nil {
  76. if !os.IsNotExist(err) {
  77. return fmt.Errorf("error checking if porter yaml exists at path %s: %w", inp.PorterYamlPath, err)
  78. }
  79. // If a path was specified but the file does not exist, we will not immediately error out.
  80. // This supports users migrated from v1 who use a workflow file that always specifies a porter yaml path
  81. // in the apply command.
  82. porterYamlExists = false
  83. }
  84. }
  85. var b64YAML string
  86. if porterYamlExists {
  87. porterYaml, err := os.ReadFile(filepath.Clean(inp.PorterYamlPath))
  88. if err != nil {
  89. return fmt.Errorf("could not read porter yaml file: %w", err)
  90. }
  91. b64YAML = base64.StdEncoding.EncodeToString(porterYaml)
  92. color.New(color.FgGreen).Printf("Using Porter YAML at path: %s\n", inp.PorterYamlPath) // nolint:errcheck,gosec
  93. }
  94. commitSHA := commitSHAFromEnv()
  95. gitSource, err := gitSourceFromEnv()
  96. if err != nil {
  97. return fmt.Errorf("error getting git source from env: %w", err)
  98. }
  99. updateInput := api.UpdateAppInput{
  100. ProjectID: cliConf.Project,
  101. ClusterID: cliConf.Cluster,
  102. Name: inp.AppName,
  103. ImageTagOverride: inp.ImageTagOverride,
  104. GitSource: gitSource,
  105. DeploymentTargetId: deploymentTargetID,
  106. CommitSHA: commitSHA,
  107. Base64PorterYAML: b64YAML,
  108. WithPredeploy: inp.WithPredeploy,
  109. Exact: inp.Exact,
  110. }
  111. updateResp, err := client.UpdateApp(ctx, updateInput)
  112. if err != nil {
  113. return fmt.Errorf("error calling update app endpoint: %w", err)
  114. }
  115. if updateResp.AppRevisionId == "" {
  116. return errors.New("app revision id is empty")
  117. }
  118. appName := updateResp.AppName
  119. buildSettings, err := client.GetBuildFromRevision(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
  120. if err != nil {
  121. return fmt.Errorf("error getting build from revision: %w", err)
  122. }
  123. if buildSettings != nil && buildSettings.Build.Method != "" {
  124. eventID, _ := createBuildEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, commitSHA)
  125. var buildFinished bool
  126. var buildError error
  127. var buildLogs string
  128. defer func() {
  129. var status buildStatus
  130. if buildError != nil && !errors.Is(buildError, context.Canceled) {
  131. status = buildStatus_failed
  132. } else if !buildFinished {
  133. status = buildStatus_canceled
  134. }
  135. if status != "" {
  136. _ = reportUnsuccessfulBuild(ctx, reportUnsuccessfulBuildInput{
  137. client: client,
  138. appName: appName,
  139. buildStatus: status,
  140. cliConf: cliConf,
  141. deploymentTargetID: deploymentTargetID,
  142. appRevisionID: updateResp.AppRevisionId,
  143. eventID: eventID,
  144. buildError: buildError,
  145. buildLogs: buildLogs,
  146. commitSHA: commitSHA,
  147. prNumber: prNumber,
  148. })
  149. }
  150. return
  151. }()
  152. if commitSHA == "" {
  153. return errors.New("build is required but commit SHA cannot be identified. Please set the PORTER_COMMIT_SHA environment variable or run apply in git repository with access to the git CLI")
  154. }
  155. color.New(color.FgGreen).Printf("Building new image with tag %s...\n", commitSHA) // nolint:errcheck,gosec
  156. buildInput, err := buildInputFromBuildSettings(buildInputFromBuildSettingsInput{
  157. projectID: cliConf.Project,
  158. appName: appName,
  159. commitSHA: commitSHA,
  160. image: buildSettings.Image,
  161. build: buildSettings.Build,
  162. buildEnv: buildSettings.BuildEnvVariables,
  163. pullImageBeforeBuild: inp.PullImageBeforeBuild,
  164. })
  165. if err != nil {
  166. buildError = fmt.Errorf("error creating build input from build settings: %w", err)
  167. return buildError
  168. }
  169. buildOutput := build(ctx, client, buildInput)
  170. if buildOutput.Error != nil {
  171. buildError = fmt.Errorf("error building app: %w", buildOutput.Error)
  172. buildLogs = buildOutput.Logs
  173. return buildError
  174. }
  175. _, err = client.UpdateRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId, models.AppRevisionStatus_BuildSuccessful)
  176. if err != nil {
  177. buildError = fmt.Errorf("error updating revision status post build: %w", err)
  178. return buildError
  179. }
  180. color.New(color.FgGreen).Printf("Successfully built image (tag: %s)\n", commitSHA) // nolint:errcheck,gosec
  181. buildMetadata := make(map[string]interface{})
  182. buildMetadata["end_time"] = time.Now().UTC()
  183. _ = updateExistingEvent(ctx, client, appName, cliConf.Project, cliConf.Cluster, deploymentTargetID, types.PorterAppEventType_Build, eventID, types.PorterAppEventStatus_Success, buildMetadata)
  184. buildFinished = true
  185. }
  186. color.New(color.FgGreen).Printf("Deploying new revision %s for app %s...\n", updateResp.AppRevisionId, appName) // nolint:errcheck,gosec
  187. now := time.Now().UTC()
  188. for {
  189. if time.Since(now) > checkDeployTimeout {
  190. return errors.New("timed out waiting for app to deploy")
  191. }
  192. status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
  193. if err != nil {
  194. return fmt.Errorf("error getting app revision status: %w", err)
  195. }
  196. if status == nil {
  197. return errors.New("unable to determine status of app revision")
  198. }
  199. if status.AppRevisionStatus.IsInTerminalStatus {
  200. break
  201. }
  202. if status.AppRevisionStatus.PredeployStarted {
  203. color.New(color.FgGreen).Printf("Waiting for predeploy to complete...\n") // nolint:errcheck,gosec
  204. }
  205. if status.AppRevisionStatus.InstallStarted {
  206. color.New(color.FgGreen).Printf("Waiting for deploy to complete...\n") // nolint:errcheck,gosec
  207. }
  208. time.Sleep(checkDeployFrequency)
  209. }
  210. _, _ = client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
  211. ProjectID: cliConf.Project,
  212. ClusterID: cliConf.Cluster,
  213. AppName: appName,
  214. AppRevisionID: updateResp.AppRevisionId,
  215. PRNumber: prNumber,
  216. CommitSHA: commitSHA,
  217. })
  218. status, err := client.GetRevisionStatus(ctx, cliConf.Project, cliConf.Cluster, appName, updateResp.AppRevisionId)
  219. if err != nil {
  220. return fmt.Errorf("error getting app revision status: %w", err)
  221. }
  222. if status == nil {
  223. return errors.New("unable to determine status of app revision")
  224. }
  225. if status.AppRevisionStatus.InstallFailed {
  226. return errors.New("app failed to deploy")
  227. }
  228. if status.AppRevisionStatus.PredeployFailed {
  229. return errors.New("predeploy failed for new revision")
  230. }
  231. color.New(color.FgGreen).Printf("Successfully applied new revision %s\n", updateResp.AppRevisionId) // nolint:errcheck,gosec
  232. if inp.WaitForSuccessfulDeployment {
  233. return waitForAppRevisionStatus(ctx, waitForAppRevisionStatusInput{
  234. ProjectID: cliConf.Project,
  235. ClusterID: cliConf.Cluster,
  236. AppName: appName,
  237. RevisionID: updateResp.AppRevisionId,
  238. Client: client,
  239. })
  240. }
  241. return nil
  242. }
  243. func commitSHAFromEnv() string {
  244. var commitSHA string
  245. if os.Getenv("PORTER_COMMIT_SHA") != "" {
  246. commitSHA = os.Getenv("PORTER_COMMIT_SHA")
  247. } else if os.Getenv("GITHUB_SHA") != "" {
  248. commitSHA = os.Getenv("GITHUB_SHA")
  249. } else if commit, err := git.LastCommit(); err == nil && commit != nil {
  250. commitSHA = commit.Sha
  251. }
  252. return commitSHA
  253. }
  254. func deploymentTargetFromConfig(ctx context.Context, client api.Client, projectID, clusterID uint, previewApply bool) (string, error) {
  255. var deploymentTargetID string
  256. if os.Getenv("PORTER_DEPLOYMENT_TARGET_ID") != "" {
  257. deploymentTargetID = os.Getenv("PORTER_DEPLOYMENT_TARGET_ID")
  258. }
  259. if deploymentTargetID == "" {
  260. targetResp, err := client.DefaultDeploymentTarget(ctx, projectID, clusterID)
  261. if err != nil {
  262. return deploymentTargetID, fmt.Errorf("error calling default deployment target endpoint: %w", err)
  263. }
  264. deploymentTargetID = targetResp.DeploymentTargetID
  265. }
  266. if previewApply {
  267. var branchName string
  268. // branch name is set to different values in the GH env, depending on whether or not the workflow is triggered by a PR
  269. // issue is being tracked here: https://github.com/github/docs/issues/15319
  270. if os.Getenv("GITHUB_HEAD_REF") != "" {
  271. branchName = os.Getenv("GITHUB_HEAD_REF")
  272. } else if os.Getenv("GITHUB_REF_NAME") != "" {
  273. branchName = os.Getenv("GITHUB_REF_NAME")
  274. } else if branch, err := git.CurrentBranch(); err == nil {
  275. branchName = branch
  276. }
  277. if branchName == "" {
  278. return deploymentTargetID, errors.New("branch name is empty. Please run apply in a git repository with access to the git CLI")
  279. }
  280. targetResp, err := client.CreateDeploymentTarget(ctx, projectID, clusterID, branchName, true)
  281. if err != nil {
  282. return deploymentTargetID, fmt.Errorf("error calling create deployment target endpoint: %w", err)
  283. }
  284. deploymentTargetID = targetResp.DeploymentTargetID
  285. }
  286. if deploymentTargetID == "" {
  287. return deploymentTargetID, errors.New("deployment target id is empty")
  288. }
  289. return deploymentTargetID, nil
  290. }
  291. type reportUnsuccessfulBuildInput struct {
  292. client api.Client
  293. appName string
  294. buildStatus buildStatus
  295. cliConf config.CLIConfig
  296. deploymentTargetID string
  297. appRevisionID string
  298. eventID string
  299. buildError error
  300. buildLogs string
  301. commitSHA string
  302. prNumber int
  303. }
  304. type buildStatus string
  305. var (
  306. buildStatus_failed buildStatus = "failed"
  307. buildStatus_canceled buildStatus = "canceled"
  308. )
  309. func reportUnsuccessfulBuild(ctx context.Context, inp reportUnsuccessfulBuildInput) error {
  310. var appRevisionStatus models.AppRevisionStatus
  311. var porterAppEventStatus types.PorterAppEventStatus
  312. switch inp.buildStatus {
  313. case buildStatus_failed:
  314. appRevisionStatus = models.AppRevisionStatus_BuildFailed
  315. porterAppEventStatus = types.PorterAppEventStatus_Failed
  316. case buildStatus_canceled:
  317. appRevisionStatus = models.AppRevisionStatus_BuildCanceled
  318. porterAppEventStatus = types.PorterAppEventStatus_Canceled
  319. default:
  320. return errors.New("unknown build status")
  321. }
  322. _, err := inp.client.UpdateRevisionStatus(ctx, inp.cliConf.Project, inp.cliConf.Cluster, inp.appName, inp.appRevisionID, appRevisionStatus)
  323. if err != nil {
  324. return err
  325. }
  326. buildMetadata := make(map[string]interface{})
  327. buildMetadata["end_time"] = time.Now().UTC()
  328. // the below is a temporary solution until we can report build errors via telemetry from the CLI
  329. errorStringMap := make(map[string]string)
  330. if inp.buildError != nil {
  331. errorStringMap["build-error"] = fmt.Sprintf("%+v", inp.buildError)
  332. }
  333. if inp.buildLogs != "" {
  334. b64BuildLogs := base64.StdEncoding.EncodeToString([]byte(inp.buildLogs))
  335. // the key name below must be kept the same so that reportBuildStatus in the CreateOrUpdatePorterAppEvent handler reports logs correctly
  336. errorStringMap["b64-build-logs"] = b64BuildLogs
  337. }
  338. if len(errorStringMap) != 0 {
  339. buildMetadata["errors"] = errorStringMap
  340. }
  341. err = updateExistingEvent(ctx, inp.client, inp.appName, inp.cliConf.Project, inp.cliConf.Cluster, inp.deploymentTargetID, types.PorterAppEventType_Build, inp.eventID, porterAppEventStatus, buildMetadata)
  342. if err != nil {
  343. return err
  344. }
  345. _, err = inp.client.ReportRevisionStatus(ctx, api.ReportRevisionStatusInput{
  346. ProjectID: inp.cliConf.Project,
  347. ClusterID: inp.cliConf.Cluster,
  348. AppName: inp.appName,
  349. AppRevisionID: inp.appRevisionID,
  350. PRNumber: inp.prNumber,
  351. CommitSHA: inp.commitSHA,
  352. })
  353. if err != nil {
  354. return err
  355. }
  356. return nil
  357. }
  358. // checkDeployTimeout is the timeout for checking if an app has been deployed
  359. const checkDeployTimeout = 15 * time.Minute
  360. // checkDeployFrequency is the frequency for checking if an app has been deployed
  361. const checkDeployFrequency = 10 * time.Second
  362. func gitSourceFromEnv() (porter_app.GitSource, error) {
  363. var source porter_app.GitSource
  364. var repoID uint
  365. if os.Getenv("GITHUB_REPOSITORY_ID") != "" {
  366. id, err := strconv.Atoi(os.Getenv("GITHUB_REPOSITORY_ID"))
  367. if err != nil {
  368. return source, fmt.Errorf("unable to parse GITHUB_REPOSITORY_ID to int: %w", err)
  369. }
  370. repoID = uint(id)
  371. }
  372. return porter_app.GitSource{
  373. GitBranch: os.Getenv("GITHUB_REF_NAME"),
  374. GitRepoID: repoID,
  375. GitRepoName: os.Getenv("GITHUB_REPOSITORY"),
  376. }, nil
  377. }
  378. type buildInputFromBuildSettingsInput struct {
  379. projectID uint
  380. appName string
  381. commitSHA string
  382. image porter_app.Image
  383. build porter_app.BuildSettings
  384. buildEnv map[string]string
  385. pullImageBeforeBuild bool
  386. }
  387. func buildInputFromBuildSettings(inp buildInputFromBuildSettingsInput) (buildInput, error) {
  388. var buildSettings buildInput
  389. if inp.appName == "" {
  390. return buildSettings, errors.New("app name is empty")
  391. }
  392. if inp.image.Repository == "" {
  393. return buildSettings, errors.New("image repository is empty")
  394. }
  395. if inp.build.Method == "" {
  396. return buildSettings, errors.New("build method is empty")
  397. }
  398. if inp.commitSHA == "" {
  399. return buildSettings, errors.New("commit SHA is empty")
  400. }
  401. return buildInput{
  402. ProjectID: inp.projectID,
  403. AppName: inp.appName,
  404. BuildContext: inp.build.Context,
  405. Dockerfile: inp.build.Dockerfile,
  406. BuildMethod: inp.build.Method,
  407. Builder: inp.build.Builder,
  408. BuildPacks: inp.build.Buildpacks,
  409. ImageTag: inp.commitSHA,
  410. RepositoryURL: inp.image.Repository,
  411. CurrentImageTag: inp.image.Tag,
  412. Env: inp.buildEnv,
  413. PullImageBeforeBuild: inp.pullImageBeforeBuild,
  414. }, nil
  415. }