apply.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. package cmd
  2. import (
  3. "context"
  4. "fmt"
  5. "io/ioutil"
  6. "os"
  7. "path/filepath"
  8. "strconv"
  9. "strings"
  10. "github.com/cli/cli/git"
  11. "github.com/fatih/color"
  12. "github.com/mitchellh/mapstructure"
  13. api "github.com/porter-dev/porter/api/client"
  14. "github.com/porter-dev/porter/api/types"
  15. "github.com/porter-dev/porter/cli/cmd/deploy"
  16. "github.com/porter-dev/porter/internal/templater/utils"
  17. "github.com/porter-dev/switchboard/pkg/drivers"
  18. "github.com/porter-dev/switchboard/pkg/models"
  19. "github.com/porter-dev/switchboard/pkg/parser"
  20. switchboardTypes "github.com/porter-dev/switchboard/pkg/types"
  21. "github.com/porter-dev/switchboard/pkg/worker"
  22. "github.com/rs/zerolog"
  23. "github.com/spf13/cobra"
  24. )
  25. // applyCmd represents the "porter apply" base command when called
  26. // with a porter.yaml file as an argument
  27. var applyCmd = &cobra.Command{
  28. Use: "apply",
  29. Short: "Applies a configuration to an application",
  30. Long: fmt.Sprintf(`
  31. %s
  32. Applies a configuration to an application by either creating a new one or updating an existing
  33. one. For example:
  34. %s
  35. This command will apply the configuration contained in porter.yaml to the requested project and
  36. cluster either provided inside the porter.yaml file or through environment variables. Note that
  37. environment variables will always take precendence over values specified in the porter.yaml file.
  38. By default, this command expects to be run from a local git repository.
  39. The following are the environment variables that can be used to set certain values while
  40. applying a configuration:
  41. PORTER_CLUSTER Cluster ID that contains the project
  42. PORTER_PROJECT Project ID that contains the application
  43. PORTER_NAMESPACE The Kubernetes namespace that the application belongs to
  44. PORTER_SOURCE_NAME Name of the source Helm chart
  45. PORTER_SOURCE_REPO The URL of the Helm charts registry
  46. PORTER_SOURCE_VERSION The version of the Helm chart to use
  47. PORTER_TAG The Docker image tag to use (like the git commit hash)
  48. `,
  49. color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter apply\":"),
  50. color.New(color.FgGreen, color.Bold).Sprintf("porter apply -f porter.yaml"),
  51. ),
  52. Run: func(cmd *cobra.Command, args []string) {
  53. err := checkLoginAndRun(args, apply)
  54. if err != nil {
  55. os.Exit(1)
  56. }
  57. },
  58. }
  59. var porterYAML string
  60. func init() {
  61. rootCmd.AddCommand(applyCmd)
  62. applyCmd.Flags().StringVarP(&porterYAML, "file", "f", "", "path to porter.yaml")
  63. applyCmd.MarkFlagRequired("file")
  64. }
  65. func apply(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
  66. fileBytes, err := ioutil.ReadFile(porterYAML)
  67. if err != nil {
  68. return err
  69. }
  70. resGroup, err := parser.ParseRawBytes(fileBytes)
  71. if err != nil {
  72. return err
  73. }
  74. basePath, err := os.Getwd()
  75. if err != nil {
  76. return err
  77. }
  78. worker := worker.NewWorker()
  79. worker.RegisterDriver("porter.deploy", NewPorterDriver)
  80. worker.SetDefaultDriver("porter.deploy")
  81. deplNamespace := os.Getenv("PORTER_NAMESPACE")
  82. if deplNamespace == "" {
  83. return fmt.Errorf("namespace must be set by PORTER_NAMESPACE")
  84. }
  85. deploymentHook, err := NewDeploymentHook(client, resGroup, deplNamespace)
  86. if err != nil {
  87. return err
  88. }
  89. worker.RegisterHook("deployment", deploymentHook)
  90. return worker.Apply(resGroup, &switchboardTypes.ApplyOpts{
  91. BasePath: basePath,
  92. })
  93. }
  94. type Source struct {
  95. Name string
  96. Repo string
  97. Version string
  98. }
  99. type Target struct {
  100. Project uint
  101. Cluster uint
  102. Namespace string
  103. }
  104. type Config struct {
  105. Build struct {
  106. Method string
  107. Context string
  108. Dockerfile string
  109. }
  110. Values map[string]interface{}
  111. }
  112. type Driver struct {
  113. source *Source
  114. target *Target
  115. config *Config
  116. sourceDefaultValues map[string]interface{}
  117. output map[string]interface{}
  118. lookupTable *map[string]drivers.Driver
  119. logger *zerolog.Logger
  120. shouldApply bool
  121. }
  122. func NewPorterDriver(resource *models.Resource, opts *drivers.SharedDriverOpts) (drivers.Driver, error) {
  123. driver := &Driver{
  124. lookupTable: opts.DriverLookupTable,
  125. logger: opts.Logger,
  126. output: make(map[string]interface{}),
  127. shouldApply: true,
  128. }
  129. err := driver.getSource(resource.Source)
  130. if err != nil {
  131. return nil, err
  132. }
  133. if driver.source.Repo == "https://chart-addons.getporter.dev" {
  134. driver.shouldApply = false
  135. }
  136. err = driver.getTarget(resource.Target)
  137. if err != nil {
  138. return nil, err
  139. }
  140. resourceConfig, err := driver.getConfig(resource.Config)
  141. if err != nil {
  142. return nil, err
  143. }
  144. driver.config = resourceConfig
  145. return driver, nil
  146. }
  147. func (d *Driver) ShouldApply(resource *models.Resource) bool {
  148. return d.shouldApply
  149. }
  150. func (d *Driver) Apply(resource *models.Resource) (*models.Resource, error) {
  151. // TODO: call driver ConstructConfig
  152. client := GetAPIClient(config)
  153. if resource.Name == "" {
  154. return nil, fmt.Errorf("empty app name")
  155. }
  156. resource.Name = fmt.Sprintf("preview-%s", resource.Name)
  157. namespace := d.target.Namespace
  158. existingNamespaces, err := client.GetK8sNamespaces(context.Background(), d.target.Project, d.target.Cluster)
  159. if err != nil {
  160. return nil, err
  161. }
  162. namespaceFound := false
  163. for _, ns := range existingNamespaces.Items {
  164. if namespace == ns.Name {
  165. namespaceFound = true
  166. break
  167. }
  168. }
  169. if !namespaceFound {
  170. _, err := client.CreateNewK8sNamespace(
  171. context.Background(), d.target.Project, d.target.Cluster, namespace)
  172. if err != nil {
  173. return nil, err
  174. }
  175. }
  176. method := d.config.Build.Method
  177. if method != "pack" && method != "docker" {
  178. return nil, fmt.Errorf("method should either be \"docker\" or \"pack\"")
  179. }
  180. fullPath, err := filepath.Abs(d.config.Build.Context)
  181. if err != nil {
  182. return nil, err
  183. }
  184. tag := os.Getenv("PORTER_TAG")
  185. if tag == "" {
  186. commit, err := git.LastCommit()
  187. if err != nil {
  188. return nil, err
  189. }
  190. tag = commit.Sha[:7]
  191. }
  192. if tag == "" {
  193. return nil, fmt.Errorf("could not find commit SHA to tag the image")
  194. }
  195. _, err = client.GetRelease(context.Background(), d.target.Project,
  196. d.target.Cluster, d.target.Namespace, resource.Name)
  197. if err != nil {
  198. // create new release
  199. color.New(color.FgGreen).Printf("Creating %s release: %s\n", d.source.Name, resource.Name)
  200. regList, err := client.ListRegistries(context.Background(), d.target.Project)
  201. if err != nil {
  202. return nil, err
  203. }
  204. var registryURL string
  205. if len(*regList) == 0 {
  206. return nil, fmt.Errorf("no registry found")
  207. } else {
  208. registryURL = (*regList)[0].URL
  209. }
  210. createAgent := &deploy.CreateAgent{
  211. Client: client,
  212. CreateOpts: &deploy.CreateOpts{
  213. SharedOpts: &deploy.SharedOpts{
  214. ProjectID: d.target.Project,
  215. ClusterID: d.target.Cluster,
  216. Namespace: namespace,
  217. LocalPath: fullPath,
  218. LocalDockerfile: d.config.Build.Dockerfile,
  219. Method: deploy.DeployBuildType(method),
  220. },
  221. Kind: d.source.Name,
  222. ReleaseName: resource.Name,
  223. RegistryURL: registryURL,
  224. },
  225. }
  226. subdomain, err := createAgent.CreateFromDocker(d.config.Values, tag)
  227. return resource, handleSubdomainCreate(subdomain, err)
  228. }
  229. // update an existing release
  230. color.New(color.FgGreen).Println("Deploying app:", resource.Name)
  231. updateAgent, err := deploy.NewDeployAgent(client, resource.Name, &deploy.DeployOpts{
  232. SharedOpts: &deploy.SharedOpts{
  233. ProjectID: d.target.Project,
  234. ClusterID: d.target.Cluster,
  235. Namespace: namespace,
  236. LocalPath: fullPath,
  237. LocalDockerfile: d.config.Build.Dockerfile,
  238. OverrideTag: tag,
  239. Method: deploy.DeployBuildType(method),
  240. },
  241. Local: true,
  242. })
  243. if err != nil {
  244. return nil, err
  245. }
  246. buildEnv, err := updateAgent.GetBuildEnv()
  247. if err != nil {
  248. return nil, err
  249. }
  250. err = updateAgent.SetBuildEnv(buildEnv)
  251. if err != nil {
  252. return nil, err
  253. }
  254. err = updateAgent.Build()
  255. if err != nil {
  256. return nil, err
  257. }
  258. err = updateAgent.Push()
  259. if err != nil {
  260. return nil, err
  261. }
  262. err = updateAgent.UpdateImageAndValues(d.config.Values)
  263. if err != nil {
  264. return nil, err
  265. }
  266. d.output[resource.Name] = utils.CoalesceValues(d.sourceDefaultValues, d.config.Values)
  267. return resource, nil
  268. }
  269. func (d *Driver) Output() (map[string]interface{}, error) {
  270. return d.output, nil
  271. }
  272. func (d *Driver) getSource(genericSource map[string]interface{}) error {
  273. d.source = &Source{}
  274. // first read from env vars
  275. d.source.Name = os.Getenv("PORTER_SOURCE_NAME")
  276. d.source.Repo = os.Getenv("PORTER_SOURCE_REPO")
  277. d.source.Version = os.Getenv("PORTER_SOURCE_VERSION")
  278. // next, check for values in the YAML file
  279. if d.source.Name == "" {
  280. if name, ok := genericSource["name"]; ok {
  281. nameVal, ok := name.(string)
  282. if !ok {
  283. return fmt.Errorf("invalid name provided")
  284. }
  285. d.source.Name = nameVal
  286. }
  287. }
  288. if d.source.Name == "" {
  289. return fmt.Errorf("source name required")
  290. }
  291. if d.source.Repo == "" {
  292. if repo, ok := genericSource["repo"]; ok {
  293. repoVal, ok := repo.(string)
  294. if !ok {
  295. return fmt.Errorf("invalid repo provided")
  296. }
  297. d.source.Repo = repoVal
  298. }
  299. }
  300. if d.source.Version == "" {
  301. if version, ok := genericSource["version"]; ok {
  302. versionVal, ok := version.(string)
  303. if !ok {
  304. return fmt.Errorf("invalid version provided")
  305. }
  306. d.source.Version = versionVal
  307. }
  308. }
  309. // lastly, just put in the defaults
  310. if d.source.Version == "" {
  311. d.source.Version = "latest"
  312. }
  313. if d.source.Repo == "" {
  314. d.source.Repo = "https://charts.getporter.dev"
  315. values, err := existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
  316. if err == nil {
  317. // found in "https://charts.getporter.dev"
  318. d.sourceDefaultValues = values
  319. return nil
  320. }
  321. d.source.Repo = "https://chart-addons.getporter.dev"
  322. values, err = existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
  323. if err == nil {
  324. // found in https://chart-addons.getporter.dev
  325. d.sourceDefaultValues = values
  326. return nil
  327. }
  328. return fmt.Errorf("source does not exist in any repo")
  329. }
  330. values, err := existsInRepo(d.source.Name, d.source.Version, d.source.Repo)
  331. if err == nil {
  332. d.sourceDefaultValues = values
  333. return nil
  334. }
  335. return fmt.Errorf("source '%s' does not exist in repo '%s'", d.source.Name, d.source.Repo)
  336. }
  337. func (d *Driver) getTarget(genericTarget map[string]interface{}) error {
  338. d.target = &Target{}
  339. // first read from env vars
  340. if projectEnv := os.Getenv("PORTER_PROJECT"); projectEnv != "" {
  341. project, err := strconv.Atoi(projectEnv)
  342. if err != nil {
  343. return err
  344. }
  345. d.target.Project = uint(project)
  346. }
  347. if clusterEnv := os.Getenv("PORTER_CLUSTER"); clusterEnv != "" {
  348. cluster, err := strconv.Atoi(clusterEnv)
  349. if err != nil {
  350. return err
  351. }
  352. d.target.Cluster = uint(cluster)
  353. }
  354. d.target.Namespace = os.Getenv("PORTER_NAMESPACE")
  355. // next, check for values in the YAML file
  356. if d.target.Project == 0 {
  357. if project, ok := genericTarget["project"]; ok {
  358. projectVal, ok := project.(uint)
  359. if !ok {
  360. return fmt.Errorf("project value must be an integer")
  361. }
  362. d.target.Project = projectVal
  363. }
  364. }
  365. if d.target.Cluster == 0 {
  366. if cluster, ok := genericTarget["cluster"]; ok {
  367. clusterVal, ok := cluster.(uint)
  368. if !ok {
  369. return fmt.Errorf("cluster value must be an integer")
  370. }
  371. d.target.Cluster = clusterVal
  372. }
  373. }
  374. if d.target.Namespace == "" {
  375. if namespace, ok := genericTarget["namespace"]; ok {
  376. namespaceVal, ok := namespace.(string)
  377. if !ok {
  378. return fmt.Errorf("invalid namespace provided")
  379. }
  380. d.target.Namespace = namespaceVal
  381. }
  382. }
  383. // lastly, just put in the defaults
  384. if d.target.Project == 0 {
  385. d.target.Project = config.Project
  386. }
  387. if d.target.Cluster == 0 {
  388. d.target.Cluster = config.Cluster
  389. }
  390. if d.target.Namespace == "" {
  391. d.target.Namespace = "default"
  392. }
  393. return nil
  394. }
  395. func (d *Driver) getConfig(genericConfig map[string]interface{}) (*Config, error) {
  396. config := &Config{}
  397. err := mapstructure.Decode(genericConfig, config)
  398. if err != nil {
  399. return nil, err
  400. }
  401. return config, nil
  402. }
  403. func existsInRepo(name, version, url string) (map[string]interface{}, error) {
  404. chart, err := GetAPIClient(config).GetTemplate(
  405. context.Background(),
  406. name, version,
  407. &types.GetTemplateRequest{
  408. TemplateGetBaseRequest: types.TemplateGetBaseRequest{
  409. RepoURL: url,
  410. },
  411. },
  412. )
  413. if err != nil {
  414. return nil, err
  415. }
  416. return chart.Values, nil
  417. }
  418. type DeploymentHook struct {
  419. client *api.Client
  420. resourceGroup *switchboardTypes.ResourceGroup
  421. gitInstallationID, projectID, clusterID, prID uint
  422. namespace string
  423. }
  424. func NewDeploymentHook(client *api.Client, resourceGroup *switchboardTypes.ResourceGroup, namespace string) (*DeploymentHook, error) {
  425. res := &DeploymentHook{
  426. client: client,
  427. resourceGroup: resourceGroup,
  428. namespace: namespace,
  429. }
  430. if ghIDStr := os.Getenv("PORTER_GIT_INSTALLATION_ID"); ghIDStr != "" {
  431. ghID, err := strconv.Atoi(ghIDStr)
  432. if err != nil {
  433. return nil, err
  434. }
  435. res.gitInstallationID = uint(ghID)
  436. } else if ghIDStr == "" {
  437. return nil, fmt.Errorf("Git installation ID must be defined, set by PORTER_GIT_INSTALLATION_ID")
  438. }
  439. if prIDStr := os.Getenv("PORTER_PULL_REQUEST_ID"); prIDStr != "" {
  440. prID, err := strconv.Atoi(prIDStr)
  441. if err != nil {
  442. return nil, err
  443. }
  444. res.prID = uint(prID)
  445. } else if prIDStr == "" {
  446. return nil, fmt.Errorf("Pull request ID must be defined, set by PORTER_PULL_REQUEST_ID")
  447. }
  448. res.projectID = config.Project
  449. if res.projectID == 0 {
  450. return nil, fmt.Errorf("project id must be set")
  451. }
  452. res.clusterID = config.Cluster
  453. if res.clusterID == 0 {
  454. return nil, fmt.Errorf("cluster id must be set")
  455. }
  456. return res, nil
  457. }
  458. func (t *DeploymentHook) PreApply() error {
  459. // attempt to read the deployment -- if it doesn't exist, create it
  460. _, err := t.client.GetDeployment(
  461. context.Background(),
  462. t.projectID, t.gitInstallationID, t.clusterID,
  463. &types.GetDeploymentRequest{
  464. Namespace: t.namespace,
  465. },
  466. )
  467. // TODO: case this on the response status code rather than text
  468. if err != nil && strings.Contains(err.Error(), "deployment not found") {
  469. // in this case, create the deployment
  470. _, err = t.client.CreateDeployment(
  471. context.Background(),
  472. t.projectID, t.gitInstallationID, t.clusterID,
  473. &types.CreateDeploymentRequest{
  474. Namespace: t.namespace,
  475. PullRequestID: t.prID,
  476. },
  477. )
  478. return err
  479. }
  480. return err
  481. }
  482. func (t *DeploymentHook) DataQueries() map[string]interface{} {
  483. // TODO: use the resource group to find all web applications that can have an exposed subdomain
  484. return map[string]interface{}{
  485. "first": "{ .test-deployment.spec.replicas }",
  486. }
  487. }
  488. func (t *DeploymentHook) PostApply(populatedData map[string]interface{}) error {
  489. // finalize the deployment
  490. _, err := t.client.FinalizeDeployment(
  491. context.Background(),
  492. t.projectID, t.gitInstallationID, t.clusterID,
  493. &types.FinalizeDeploymentRequest{
  494. Namespace: t.namespace,
  495. // TODO: populate the subdomain based on the query
  496. Subdomain: "google.com",
  497. },
  498. )
  499. return err
  500. }