diff --git a/core/src/actions/build.ts b/core/src/actions/build.ts index 0256210cbb..b24a7ad03a 100644 --- a/core/src/actions/build.ts +++ b/core/src/actions/build.ts @@ -36,6 +36,8 @@ import { ExecutedActionExtension, } from "./base" import { ResolvedConfigGraph } from "../graph/config-graph" +import { ActionVersion } from "../vcs/vcs" +import { Memoize } from "typescript-memoize" export interface BuildCopyFrom { build: string @@ -142,6 +144,24 @@ export class BuildAction< > extends BaseAction { kind: "Build" + /** + * Builds from module conversions inherit their version from their parent module. This is done for compatibility + * reasons, so that e.g. the module version hash that appears in `${modules.*.outputs.deployment-image-id}` in + * a runtime step in a module config is consistent with the version hash in the image tag pushed by the `container` + * build. Otherwise, this would fail, since the Build version would differ from the module version. + * + * Semantically, this should be irrelevant to the user, since build cache hits or misses should be triggered for + * similar changes to the underlying build-relevant parts of the module config, or to the included sources. + */ + @Memoize() + getFullVersion(): ActionVersion { + const actionVersion = super.getFullVersion() + if (this._moduleVersion) { + actionVersion.versionString = this.moduleVersion().versionString + } + return actionVersion + } + /** * Returns the build path for the action. The path is generally `/.garden/build/`. * If `buildAtSource: true` is set on the config, the path is the base path of the action. diff --git a/core/test/data/test-projects/helm/api copy/.helmignore b/core/test/data/test-projects/helm/api copy/.helmignore new file mode 100644 index 0000000000..50af031725 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/.helmignore @@ -0,0 +1,22 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/core/test/data/test-projects/helm/api copy/Chart.yaml b/core/test/data/test-projects/helm/api copy/Chart.yaml new file mode 100644 index 0000000000..599b48bbfc --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for Kubernetes +name: api-module +version: 0.1.0 +image: + repository: busybox + tag: latest + pullPolicy: IfNotPresent diff --git a/core/test/data/test-projects/helm/api copy/garden.yml b/core/test/data/test-projects/helm/api copy/garden.yml new file mode 100644 index 0000000000..84db4e4a67 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/garden.yml @@ -0,0 +1,19 @@ +kind: Module +description: The API backend for the voting UI +type: helm +name: api-helm-module +releaseName: api-module-release +devMode: + sync: + - target: /app + mode: two-way +serviceResource: + kind: Deployment + containerModule: api-image +values: + image: + tag: ${modules.api-image.version} + ingress: + enabled: true + paths: [/] + hosts: [api-module.local.app.garden] diff --git a/core/test/data/test-projects/helm/api copy/templates/NOTES.txt b/core/test/data/test-projects/helm/api copy/templates/NOTES.txt new file mode 100644 index 0000000000..4693278a29 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/templates/NOTES.txt @@ -0,0 +1,21 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range $.Values.ingress.paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host }}{{ . }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "api-module.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get svc -w {{ include "api-module.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "api-module.fullname" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "api-module.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl port-forward $POD_NAME 8080:80 +{{- end }} diff --git a/core/test/data/test-projects/helm/api copy/templates/_helpers.tpl b/core/test/data/test-projects/helm/api copy/templates/_helpers.tpl new file mode 100644 index 0000000000..555b782562 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "api-module.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "api-module.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "api-module.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/core/test/data/test-projects/helm/api copy/templates/deployment.yaml b/core/test/data/test-projects/helm/api copy/templates/deployment.yaml new file mode 100644 index 0000000000..b17ab74df3 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/templates/deployment.yaml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "api-module.fullname" . }} + labels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + helm.sh/chart: {{ include "api-module.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + shareProcessNamespace: {{ .Values.shareProcessNamespace }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: [python, app.py] + ports: + - name: http + containerPort: 80 + protocol: TCP + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/core/test/data/test-projects/helm/api copy/templates/ingress.yaml b/core/test/data/test-projects/helm/api copy/templates/ingress.yaml new file mode 100644 index 0000000000..87cf112021 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/templates/ingress.yaml @@ -0,0 +1,90 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "api-module.fullname" . -}} +{{- $ingressPaths := .Values.ingress.paths -}} + +{{- if .Capabilities.APIVersions.Has "networking.k8s.io/v1/Ingress" -}} + +# Use the new Ingress manifest structure +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + helm.sh/chart: {{ include "api-module.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + {{- range $ingressPaths }} + - path: {{ . }} + pathType: Prefix + backend: + service: + name: {{ $fullName }} + port: + number: 80 + {{- end }} + {{- end }} + +{{- else -}} + +# Use the old Ingress manifest structure +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + helm.sh/chart: {{ include "api-module.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: +{{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} +{{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + {{- range $ingressPaths }} + - path: {{ . }} + backend: + serviceName: {{ $fullName }} + servicePort: http + {{- end }} + {{- end }} + + +{{- end }} +{{- end }} diff --git a/core/test/data/test-projects/helm/api copy/templates/service.yaml b/core/test/data/test-projects/helm/api copy/templates/service.yaml new file mode 100644 index 0000000000..e330e644d8 --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/templates/service.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "api-module.fullname" . }} + labels: + app.kubernetes.io/name: {{ include "api-module.name" . }} + helm.sh/chart: {{ include "api-module.chart" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "api-module.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} diff --git a/core/test/data/test-projects/helm/api copy/values.yaml b/core/test/data/test-projects/helm/api copy/values.yaml new file mode 100644 index 0000000000..097dc7157d --- /dev/null +++ b/core/test/data/test-projects/helm/api copy/values.yaml @@ -0,0 +1,51 @@ +# Default values for api. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: api-image + tag: stable + pullPolicy: IfNotPresent + +nameOverride: "" +fullnameOverride: "" + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + paths: [] + hosts: + - chart-example.local + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This field is whitelisted in `runPodSpecWhitelist`, so it should be included when running tests/tasks +shareProcessNamespace: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/core/test/integ/src/plugins/kubernetes/helm/deployment.ts b/core/test/integ/src/plugins/kubernetes/helm/deployment.ts index ac02ccd8cb..731e8d6cdf 100644 --- a/core/test/integ/src/plugins/kubernetes/helm/deployment.ts +++ b/core/test/integ/src/plugins/kubernetes/helm/deployment.ts @@ -202,6 +202,48 @@ describe("helmDeploy", () => { ]) }) + it("should deploy a chart from a converted Helm module referencing a container module version in its image tag", async () => { + graph = await garden.getConfigGraph({ log: garden.log, emit: false }) + const action = await garden.resolveAction({ + action: graph.getDeploy("api-helm-module"), + log: garden.log, + graph, + }) + + const status = await helmDeploy({ + ctx, + log: garden.log, + action, + force: false, + syncMode: false, + localMode: false, + }) + + const releaseName = getReleaseName(action) + const releaseStatus = await getReleaseStatus({ + ctx, + action, + releaseName, + log: garden.log, + syncMode: false, + localMode: false, + }) + + expect(releaseStatus.state).to.equal("ready") + expect(releaseStatus.detail["values"][".garden"]).to.eql({ + moduleName: "api-helm-module", + projectName: garden.projectName, + version: action.versionString(), + }) + expect(status.detail?.namespaceStatuses).to.eql([ + { + pluginName: "local-kubernetes", + namespaceName: "helm-test-default", + state: "ready", + }, + ]) + }) + it("should deploy a chart with sync enabled", async () => { graph = await garden.getConfigGraph({ log: garden.log, emit: false }) const action = await garden.resolveAction({ diff --git a/core/test/unit/src/actions.ts b/core/test/unit/src/actions/action-configs-to-graph.ts similarity index 97% rename from core/test/unit/src/actions.ts rename to core/test/unit/src/actions/action-configs-to-graph.ts index 0881f7ead0..353a300d48 100644 --- a/core/test/unit/src/actions.ts +++ b/core/test/unit/src/actions/action-configs-to-graph.ts @@ -7,13 +7,12 @@ */ import { expect } from "chai" -import { writeFile } from "fs" import { join } from "path" -import { actionConfigsToGraph } from "../../../src/graph/actions" -import { ModuleGraph } from "../../../src/graph/modules" -import { Log } from "../../../src/logger/log-entry" -import { dumpYaml } from "../../../src/util/util" -import { expectError, makeTempGarden, TempDirectory, TestGarden } from "../../helpers" +import { actionConfigsToGraph } from "../../../../src/graph/actions" +import { ModuleGraph } from "../../../../src/graph/modules" +import { Log } from "../../../../src/logger/log-entry" +import { dumpYaml } from "../../../../src/util/util" +import { expectError, makeTempGarden, TempDirectory, TestGarden } from "../../../helpers" describe("actionConfigsToGraph", () => { let tmpDir: TempDirectory diff --git a/core/test/unit/src/actions/build.ts b/core/test/unit/src/actions/build.ts new file mode 100644 index 0000000000..e15c3b5937 --- /dev/null +++ b/core/test/unit/src/actions/build.ts @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2018-2022 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { expect } from "chai" +import { makeTestGardenA } from "../../../helpers" + +describe("BuildAction", () => { + it("When converted from a module, uses the module's version string in its full version", async () => { + const garden = await makeTestGardenA() + const log = garden.log + // test-project-a uses module configs, so they'll be run through the module conversion process to generate actions, + // which is exacly what we need here. + const graph = await garden.getConfigGraph({ log, emit: false }) + const moduleA = graph.getModule("module-a") + const buildA = graph.getBuild("module-a") + + expect(moduleA.version.versionString).to.eql(buildA.getFullVersion().versionString) + }) +})