parse.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. package stacks
  2. import (
  3. "fmt"
  4. "strings"
  5. "github.com/porter-dev/porter/api/server/shared/config"
  6. "github.com/porter-dev/porter/api/types"
  7. "github.com/porter-dev/porter/internal/helm/loader"
  8. "github.com/porter-dev/porter/internal/integrations/powerdns"
  9. "github.com/porter-dev/porter/internal/kubernetes"
  10. "github.com/porter-dev/porter/internal/kubernetes/domain"
  11. "github.com/porter-dev/porter/internal/repository"
  12. "github.com/porter-dev/porter/internal/templater/utils"
  13. "github.com/stefanmcshane/helm/pkg/chart"
  14. "gopkg.in/yaml.v2"
  15. )
  16. type PorterStackYAML struct {
  17. Version *string `yaml:"version"`
  18. Build *Build `yaml:"build"`
  19. Env map[string]string `yaml:"env"`
  20. Apps map[string]*App `yaml:"apps"`
  21. Release *string `yaml:"release"`
  22. }
  23. type Build struct {
  24. Context *string `yaml:"context" validate:"dir"`
  25. Method *string `yaml:"method" validate:"required,oneof=pack docker registry"`
  26. Builder *string `yaml:"builder" validate:"required_if=Method pack"`
  27. Buildpacks []*string `yaml:"buildpacks"`
  28. Dockerfile *string `yaml:"dockerfile" validate:"required_if=Method docker"`
  29. Image *string `yaml:"image" validate:"required_if=Method registry"`
  30. }
  31. type App struct {
  32. Run *string `yaml:"run" validate:"required"`
  33. Config map[string]interface{} `yaml:"config"`
  34. Type *string `yaml:"type" validate:"required, oneof=web worker job"`
  35. }
  36. type SubdomainCreateOpts struct {
  37. k8sAgent *kubernetes.Agent
  38. dnsRepo repository.DNSRecordRepository
  39. powerDnsClient *powerdns.Client
  40. appRootDomain string
  41. stackName string
  42. }
  43. func parse(
  44. porterYaml []byte,
  45. imageInfo types.ImageInfo,
  46. config *config.Config,
  47. projectID uint,
  48. existingValues map[string]interface{},
  49. existingDependencies []*chart.Dependency,
  50. opts SubdomainCreateOpts,
  51. ) (*chart.Chart, map[string]interface{}, error) {
  52. parsed := &PorterStackYAML{}
  53. err := yaml.Unmarshal(porterYaml, parsed)
  54. if err != nil {
  55. return nil, nil, fmt.Errorf("%s: %w", "error parsing porter.yaml", err)
  56. }
  57. values, err := buildStackValues(parsed, imageInfo, existingValues, opts)
  58. if err != nil {
  59. return nil, nil, fmt.Errorf("%s: %w", "error building values from porter.yaml", err)
  60. }
  61. convertedValues := convertMap(values).(map[string]interface{})
  62. chart, err := buildStackChart(parsed, config, projectID, existingDependencies)
  63. if err != nil {
  64. return nil, nil, fmt.Errorf("%s: %w", "error building chart from porter.yaml", err)
  65. }
  66. return chart, convertedValues, nil
  67. }
  68. func buildStackValues(parsed *PorterStackYAML, imageInfo types.ImageInfo, existingValues map[string]interface{}, opts SubdomainCreateOpts) (map[string]interface{}, error) {
  69. values := make(map[string]interface{})
  70. if parsed.Apps == nil {
  71. if existingValues == nil {
  72. return nil, fmt.Errorf("porter.yaml must contain at least one app, or release must exist and have values")
  73. }
  74. }
  75. for name, app := range parsed.Apps {
  76. appType := getType(name, app)
  77. defaultValues := getDefaultValues(app, parsed.Env, appType)
  78. convertedConfig := convertMap(app.Config).(map[string]interface{})
  79. helm_values := utils.DeepCoalesceValues(defaultValues, convertedConfig)
  80. // required to identify the chart type because of https://github.com/helm/helm/issues/9214
  81. helmName := getHelmName(name, appType)
  82. if existingValues != nil {
  83. if existingValues[helmName] != nil {
  84. existingValuesMap := existingValues[helmName].(map[string]interface{})
  85. helm_values = utils.DeepCoalesceValues(existingValuesMap, helm_values)
  86. }
  87. }
  88. err := createSubdomainIfRequired(helm_values, opts) // modifies helm_values to add subdomains if necessary
  89. if err != nil {
  90. return nil, err
  91. }
  92. values[helmName] = helm_values
  93. }
  94. // add back in the existing values that were not overwritten
  95. for k, v := range existingValues {
  96. if values[k] == nil {
  97. values[k] = v
  98. }
  99. }
  100. if imageInfo.Repository != "" && imageInfo.Tag != "" {
  101. values["global"] = map[string]interface{}{
  102. "image": map[string]interface{}{
  103. "repository": imageInfo.Repository,
  104. "tag": imageInfo.Tag,
  105. },
  106. }
  107. }
  108. return values, nil
  109. }
  110. func getType(name string, app *App) string {
  111. if app.Type != nil {
  112. return *app.Type
  113. }
  114. if strings.Contains(name, "web") {
  115. return "web"
  116. }
  117. return "worker"
  118. }
  119. func getDefaultValues(app *App, env map[string]string, appType string) map[string]interface{} {
  120. var defaultValues map[string]interface{}
  121. var runCommand string
  122. if app.Run != nil {
  123. runCommand = *app.Run
  124. }
  125. if appType == "web" {
  126. defaultValues = map[string]interface{}{
  127. "container": map[string]interface{}{
  128. "command": runCommand,
  129. "env": map[string]interface{}{
  130. "normal": CopyEnv(env),
  131. },
  132. },
  133. }
  134. } else {
  135. defaultValues = map[string]interface{}{
  136. "container": map[string]interface{}{
  137. "command": runCommand,
  138. "env": map[string]interface{}{
  139. "normal": CopyEnv(env),
  140. },
  141. },
  142. }
  143. }
  144. return defaultValues
  145. }
  146. func buildStackChart(parsed *PorterStackYAML, config *config.Config, projectID uint, existingDependencies []*chart.Dependency) (*chart.Chart, error) {
  147. deps := make([]*chart.Dependency, 0)
  148. for alias, app := range parsed.Apps {
  149. var appType string
  150. if existingDependencies != nil {
  151. for _, dep := range existingDependencies {
  152. // this condition checks that the dependency is of the form <alias>-web or <alias>-wkr or <alias>-job, meaning it already exists in the chart
  153. if strings.HasPrefix(dep.Alias, fmt.Sprintf("%s-", alias)) && (strings.HasSuffix(dep.Alias, "-web") || strings.HasSuffix(dep.Alias, "-wkr") || strings.HasSuffix(dep.Alias, "-job")) {
  154. appType = getChartTypeFromHelmName(dep.Alias)
  155. if appType == "" {
  156. return nil, fmt.Errorf("unable to determine type of existing dependency")
  157. }
  158. }
  159. }
  160. // this is a new app, so we need to get the type from the app name or type
  161. if appType == "" {
  162. appType = getType(alias, app)
  163. }
  164. } else {
  165. appType = getType(alias, app)
  166. }
  167. selectedRepo := config.ServerConf.DefaultApplicationHelmRepoURL
  168. selectedVersion, err := getLatestTemplateVersion(appType, config, projectID)
  169. if err != nil {
  170. return nil, err
  171. }
  172. helmName := getHelmName(alias, appType)
  173. deps = append(deps, &chart.Dependency{
  174. Name: appType,
  175. Alias: helmName,
  176. Version: selectedVersion,
  177. Repository: selectedRepo,
  178. })
  179. }
  180. // add in the existing dependencies that were not overwritten
  181. for _, dep := range existingDependencies {
  182. if !dependencyExists(deps, dep) {
  183. // have to repair the dependency name because of https://github.com/helm/helm/issues/9214
  184. if strings.HasSuffix(dep.Name, "-web") || strings.HasSuffix(dep.Name, "-wkr") || strings.HasSuffix(dep.Name, "-job") {
  185. dep.Name = getChartTypeFromHelmName(dep.Name)
  186. }
  187. deps = append(deps, dep)
  188. }
  189. }
  190. chart, err := createChartFromDependencies(deps)
  191. if err != nil {
  192. return nil, err
  193. }
  194. return chart, nil
  195. }
  196. func dependencyExists(deps []*chart.Dependency, dep *chart.Dependency) bool {
  197. for _, d := range deps {
  198. if d.Alias == dep.Alias {
  199. return true
  200. }
  201. }
  202. return false
  203. }
  204. func createChartFromDependencies(deps []*chart.Dependency) (*chart.Chart, error) {
  205. metadata := &chart.Metadata{
  206. Name: "umbrella",
  207. Description: "Web application that is exposed to external traffic.",
  208. Version: "0.96.0",
  209. APIVersion: "v2",
  210. Home: "https://getporter.dev/",
  211. Icon: "https://user-images.githubusercontent.com/65516095/111255214-07d3da80-85ed-11eb-99e2-fddcbdb99bdb.png",
  212. Keywords: []string{
  213. "porter",
  214. "application",
  215. "service",
  216. "umbrella",
  217. },
  218. Type: "application",
  219. Dependencies: deps,
  220. }
  221. // create a new chart object with the metadata
  222. c := &chart.Chart{
  223. Metadata: metadata,
  224. }
  225. return c, nil
  226. }
  227. func getLatestTemplateVersion(templateName string, config *config.Config, projectID uint) (string, error) {
  228. repoIndex, err := loader.LoadRepoIndexPublic(config.ServerConf.DefaultApplicationHelmRepoURL)
  229. if err != nil {
  230. return "", fmt.Errorf("%s: %w", "unable to load porter chart repo", err)
  231. }
  232. templates := loader.RepoIndexToPorterChartList(repoIndex, config.ServerConf.DefaultApplicationHelmRepoURL)
  233. if err != nil {
  234. return "", fmt.Errorf("%s: %w", "unable to load porter chart list", err)
  235. }
  236. var version string
  237. // find the matching template name
  238. for _, template := range templates {
  239. if templateName == template.Name {
  240. version = template.Versions[0]
  241. break
  242. }
  243. }
  244. if version == "" {
  245. return "", fmt.Errorf("matching template version not found")
  246. }
  247. return version, nil
  248. }
  249. func convertMap(m interface{}) interface{} {
  250. switch m := m.(type) {
  251. case map[string]interface{}:
  252. for k, v := range m {
  253. m[k] = convertMap(v)
  254. }
  255. case map[interface{}]interface{}:
  256. result := map[string]interface{}{}
  257. for k, v := range m {
  258. result[k.(string)] = convertMap(v)
  259. }
  260. return result
  261. case []interface{}:
  262. for i, v := range m {
  263. m[i] = convertMap(v)
  264. }
  265. }
  266. return m
  267. }
  268. func CopyEnv(env map[string]string) map[string]interface{} {
  269. envCopy := make(map[string]interface{})
  270. if env == nil {
  271. return envCopy
  272. }
  273. for k, v := range env {
  274. if k == "" || v == "" {
  275. continue
  276. }
  277. envCopy[k] = v
  278. }
  279. return envCopy
  280. }
  281. func createSubdomainIfRequired(
  282. mergedValues map[string]interface{},
  283. opts SubdomainCreateOpts,
  284. ) error {
  285. // look for ingress.enabled and no custom domains set
  286. ingressMap, err := getNestedMap(mergedValues, "ingress")
  287. if err == nil {
  288. enabledVal, enabledExists := ingressMap["enabled"]
  289. if enabledExists {
  290. enabled, eOK := enabledVal.(bool)
  291. if eOK && enabled {
  292. // if custom domain, we don't need to create a subdomain
  293. customDomVal, customDomExists := ingressMap["custom_domain"]
  294. if customDomExists {
  295. customDomain, cOK := customDomVal.(bool)
  296. if cOK && customDomain {
  297. return nil
  298. }
  299. }
  300. // subdomain already exists, no need to create one
  301. if porterHosts, ok := ingressMap["porter_hosts"].([]interface{}); ok && len(porterHosts) > 0 {
  302. return nil
  303. }
  304. // in the case of ingress enabled but no custom domain, create subdomain
  305. dnsRecord, err := createDNSRecord(opts)
  306. if err != nil {
  307. return fmt.Errorf("error creating subdomain: %s", err.Error())
  308. }
  309. subdomain := dnsRecord.ExternalURL
  310. if ingressVal, ok := mergedValues["ingress"]; !ok {
  311. mergedValues["ingress"] = map[string]interface{}{
  312. "porter_hosts": []string{
  313. subdomain,
  314. },
  315. }
  316. } else {
  317. ingressValMap := ingressVal.(map[string]interface{})
  318. ingressValMap["porter_hosts"] = []string{
  319. subdomain,
  320. }
  321. }
  322. }
  323. }
  324. }
  325. return nil
  326. }
  327. func createDNSRecord(opts SubdomainCreateOpts) (*types.DNSRecord, error) {
  328. if opts.powerDnsClient == nil {
  329. return nil, fmt.Errorf("cannot create subdomain because powerdns client is nil")
  330. }
  331. endpoint, found, err := domain.GetNGINXIngressServiceIP(opts.k8sAgent.Clientset)
  332. if err != nil {
  333. return nil, err
  334. }
  335. if !found {
  336. return nil, fmt.Errorf("target cluster does not have nginx ingress")
  337. }
  338. createDomain := domain.CreateDNSRecordConfig{
  339. ReleaseName: opts.stackName,
  340. RootDomain: opts.appRootDomain,
  341. Endpoint: endpoint,
  342. }
  343. record := createDomain.NewDNSRecordForEndpoint()
  344. record, err = opts.dnsRepo.CreateDNSRecord(record)
  345. if err != nil {
  346. return nil, err
  347. }
  348. _record := domain.DNSRecord(*record)
  349. err = _record.CreateDomain(opts.powerDnsClient)
  350. if err != nil {
  351. return nil, err
  352. }
  353. return record.ToDNSRecordType(), nil
  354. }
  355. func getNestedMap(obj map[string]interface{}, fields ...string) (map[string]interface{}, error) {
  356. var res map[string]interface{}
  357. curr := obj
  358. for _, field := range fields {
  359. objField, ok := curr[field]
  360. if !ok {
  361. return nil, fmt.Errorf("%s not found", field)
  362. }
  363. res, ok = objField.(map[string]interface{})
  364. if !ok {
  365. return nil, fmt.Errorf("%s is not a nested object", field)
  366. }
  367. curr = res
  368. }
  369. return res, nil
  370. }
  371. func getHelmName(alias string, t string) string {
  372. var suffix string
  373. if t == "web" {
  374. suffix = "-web"
  375. } else if t == "worker" {
  376. suffix = "-wkr"
  377. } else if t == "job" {
  378. suffix = "-job"
  379. }
  380. return fmt.Sprintf("%s%s", alias, suffix)
  381. }
  382. func getChartTypeFromHelmName(name string) string {
  383. if strings.HasSuffix(name, "-web") {
  384. return "web"
  385. } else if strings.HasSuffix(name, "-wkr") {
  386. return "worker"
  387. } else if strings.HasSuffix(name, "-job") {
  388. return "job"
  389. }
  390. return ""
  391. }
  392. func attemptToGetImageInfoFromRelease(values map[string]interface{}) types.ImageInfo {
  393. imageInfo := types.ImageInfo{}
  394. if values == nil {
  395. return imageInfo
  396. }
  397. globalImage, err := getNestedMap(values, "global", "image")
  398. if err != nil {
  399. return imageInfo
  400. }
  401. repoVal, okRepo := globalImage["repository"]
  402. tagVal, okTag := globalImage["tag"]
  403. if okRepo && okTag {
  404. imageInfo.Repository = repoVal.(string)
  405. imageInfo.Tag = tagVal.(string)
  406. }
  407. return imageInfo
  408. }