diff --git a/docs/reference/module-types/container.md b/docs/reference/module-types/container.md index 59a73dd636..d9cbe1eed8 100644 --- a/docs/reference/module-types/container.md +++ b/docs/reference/module-types/container.md @@ -113,6 +113,14 @@ The names of any services that this service depends on at runtime, and the names | Type | Required | | ---- | -------- | | `array[string]` | No +### `module.services[].annotations` +[module](#module) > [services](#module.services[]) > annotations + +Annotations to attach to the service (Note: May not be applicable to all providers) + +| Type | Required | +| ---- | -------- | +| `object` | No ### `module.services[].args[]` [module](#module) > [services](#module.services[]) > args @@ -147,6 +155,14 @@ module: - path: /api port: http ``` +### `module.services[].ingresses[].annotations` +[module](#module) > [services](#module.services[]) > [ingresses](#module.services[].ingresses[]) > annotations + +Annotations to attach to the ingress (Note: May not be applicable to all providers) + +| Type | Required | +| ---- | -------- | +| `object` | No ### `module.services[].ingresses[].hostname` [module](#module) > [services](#module.services[]) > [ingresses](#module.services[].ingresses[]) > hostname @@ -273,7 +289,7 @@ The protocol of the port. ### `module.services[].ports[].containerPort` [module](#module) > [services](#module.services[]) > [ports](#module.services[].ports[]) > containerPort -The port exposed on the container by the running procces. This will also be the default value for `servicePort`. +The port exposed on the container by the running process. This will also be the default value for `servicePort`. `servicePort:80 -> containerPort:8080 -> process:8080` | Type | Required | @@ -485,10 +501,12 @@ module: services: - name: dependencies: [] + annotations: {} args: daemon: false ingresses: - - hostname: + - annotations: {} + hostname: path: / port: env: {} diff --git a/garden-service/src/plugins/container/config.ts b/garden-service/src/plugins/container/config.ts index 9db644b21f..c7c524213c 100644 --- a/garden-service/src/plugins/container/config.ts +++ b/garden-service/src/plugins/container/config.ts @@ -24,8 +24,10 @@ import { ModuleSpec, ModuleConfig } from "../../config/module" import { CommonServiceSpec, ServiceConfig, baseServiceSchema } from "../../config/service" import { baseTaskSpecSchema, BaseTaskSpec } from "../../config/task" import { baseTestSpecSchema, BaseTestSpec } from "../../config/test" +import { joiStringMap } from "../../config/common" export interface ContainerIngressSpec { + annotations: Annotations hostname?: string path: string port: string @@ -59,7 +61,12 @@ export interface ServiceHealthCheckSpec { tcpPort?: string, } +interface Annotations { + [name: string]: string +} + export interface ContainerServiceSpec extends CommonServiceSpec { + annotations: Annotations, args: string[], daemon: boolean ingresses: ContainerIngressSpec[], @@ -107,8 +114,13 @@ const hotReloadConfigSchema = Joi.object() export type ContainerServiceConfig = ServiceConfig +const annotationsSchema = joiStringMap(Joi.string()) + .default(() => ({}), "{}") + const ingressSchema = Joi.object() .keys({ + annotations: annotationsSchema + .description("Annotations to attach to the ingress (Note: May not be applicable to all providers)"), hostname: ingressHostnameSchema, path: Joi.string().uri({ relativeOnly: true }) .default("/") @@ -151,11 +163,12 @@ export const portSchema = Joi.object() .required() .example("8080") .description(deline` - The port exposed on the container by the running procces. This will also be the default value + The port exposed on the container by the running process. This will also be the default value for \`servicePort\`. \`servicePort:80 -> containerPort:8080 -> process:8080\``), - servicePort: Joi.number().default((context) => context.containerPort, "") + servicePort: Joi.number() + .default((context) => context.containerPort, "") .example("80") .description(deline`The port exposed on the service. Defaults to \`containerPort\` if not specified. @@ -183,6 +196,8 @@ const volumeSchema = Joi.object() const serviceSchema = baseServiceSchema .keys({ + annotations: annotationsSchema + .description("Annotations to attach to the service (Note: May not be applicable to all providers)"), args: Joi.array().items(Joi.string()) .description("The arguments to run the container with when starting the service."), daemon: Joi.boolean() diff --git a/garden-service/src/plugins/kubernetes/container/deployment.ts b/garden-service/src/plugins/kubernetes/container/deployment.ts index 2f095325c1..8a55e235a0 100644 --- a/garden-service/src/plugins/kubernetes/container/deployment.ts +++ b/garden-service/src/plugins/kubernetes/container/deployment.ts @@ -10,8 +10,8 @@ import { extend, keyBy, set, toPairs } from "lodash" import { DeployServiceParams, PushModuleParams, DeleteServiceParams } from "../../../types/plugin/params" import { RuntimeContext, Service, ServiceStatus } from "../../../types/service" import { ContainerModule, ContainerService } from "../../container/config" -import { createIngresses } from "./ingress" -import { createServices } from "./service" +import { createIngressResources } from "./ingress" +import { createServiceResources } from "./service" import { waitForResources } from "../status" import { applyMany, deleteObjectsByLabel } from "../kubectl" import { getAppNamespace } from "../namespace" @@ -53,9 +53,9 @@ export async function createContainerObjects( const version = service.module.version const namespace = await getAppNamespace(ctx, ctx.provider) const api = new KubeApi(ctx.provider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) const deployment = await createDeployment(ctx.provider, service, runtimeContext, namespace, enableHotReload) - const kubeservices = await createServices(service, namespace) + const kubeservices = await createServiceResources(service, namespace) const objects = [deployment, ...kubeservices, ...ingresses] diff --git a/garden-service/src/plugins/kubernetes/container/ingress.ts b/garden-service/src/plugins/kubernetes/container/ingress.ts index cbc9c10d1f..06fadb1d6d 100644 --- a/garden-service/src/plugins/kubernetes/container/ingress.ts +++ b/garden-service/src/plugins/kubernetes/container/ingress.ts @@ -8,7 +8,7 @@ import * as Bluebird from "bluebird" import { certpem } from "certpem" -import { groupBy, omit, find } from "lodash" +import { omit, find, extend } from "lodash" import { findByName } from "../../../util/util" import { ContainerService, ContainerIngressSpec } from "../../container/config" import { IngressTlsCertificate } from "../kubernetes" @@ -24,34 +24,28 @@ interface ServiceIngressWithCert extends ServiceIngress { const certificateHostnames: { [name: string]: string[] } = {} -export async function createIngresses(api: KubeApi, namespace: string, service: ContainerService) { +export async function createIngressResources(api: KubeApi, namespace: string, service: ContainerService) { if (service.spec.ingresses.length === 0) { return [] } const allIngresses = await getIngressesWithCert(service, api) - // first group ingress endpoints by certificate, so we can properly configure TLS - const groupedByCert = groupBy(allIngresses, e => e.certificate ? e.certificate.name : undefined) - - return Bluebird.map(Object.values(groupedByCert), async (certIngresses) => { - // second, group ingress endpoints by hostname - const groupedByHostname = groupBy(certIngresses, e => e.hostname) - - const rules = Object.entries(groupedByHostname).map(([host, hostnameIngresses]) => ({ - host, + return Bluebird.map(allIngresses, async (ingress) => { + const rules = [{ + host: ingress.hostname, http: { - paths: hostnameIngresses.map(ingress => ({ + paths: [{ path: ingress.path, backend: { serviceName: service.name, servicePort: findByName(service.spec.ports, ingress.spec.port)!.servicePort, }, - })), + }], }, - })) + }] - const cert = certIngresses[0].certificate + const cert = ingress.certificate const annotations = { "ingress.kubernetes.io/force-ssl-redirect": !!cert + "", @@ -61,6 +55,8 @@ export async function createIngresses(api: KubeApi, namespace: string, service: annotations["kubernetes.io/ingress.class"] = api.provider.config.ingressClass } + extend(annotations, ingress.spec.annotations) + const spec: any = { rules } if (!!cert) { diff --git a/garden-service/src/plugins/kubernetes/container/service.ts b/garden-service/src/plugins/kubernetes/container/service.ts index 8e01c75f78..47fd078015 100644 --- a/garden-service/src/plugins/kubernetes/container/service.ts +++ b/garden-service/src/plugins/kubernetes/container/service.ts @@ -8,7 +8,7 @@ import { ContainerService } from "../../container/config" -export async function createServices(service: ContainerService, namespace: string) { +export async function createServiceResources(service: ContainerService, namespace: string) { const services: any = [] const addService = (name: string, type: string, servicePorts: any[]) => { @@ -17,7 +17,7 @@ export async function createServices(service: ContainerService, namespace: strin kind: "Service", metadata: { name, - annotations: {}, + annotations: service.spec.annotations, namespace, }, spec: { diff --git a/garden-service/src/plugins/local/local-google-cloud-functions.ts b/garden-service/src/plugins/local/local-google-cloud-functions.ts index 15538f9746..951d36d501 100644 --- a/garden-service/src/plugins/local/local-google-cloud-functions.ts +++ b/garden-service/src/plugins/local/local-google-cloud-functions.ts @@ -46,10 +46,12 @@ export const gardenPlugin = (): GardenPlugin => ({ outputs: { ingress: `http://${s.name}:${emulatorPort}/local/local/${functionEntrypoint}`, }, + annotations: {}, args: ["/app/start.sh", functionEntrypoint], daemon: false, ingresses: [{ name: "default", + annotations: {}, hostname: s.spec.hostname, port: "http", path: "/", diff --git a/garden-service/test/src/plugins/container.ts b/garden-service/test/src/plugins/container.ts index f197c2cc3b..08ed618e2e 100644 --- a/garden-service/test/src/plugins/container.ts +++ b/garden-service/test/src/plugins/container.ts @@ -201,11 +201,13 @@ describe("plugins.container", () => { buildArgs: {}, services: [{ name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, ingresses: [ { + annotations: {}, path: "/", port: "http", }, @@ -264,10 +266,12 @@ describe("plugins.container", () => { services: [{ name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, ingresses: [{ + annotations: {}, path: "/", port: "http", }], @@ -304,10 +308,12 @@ describe("plugins.container", () => { spec: { name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, ingresses: [{ + annotations: {}, path: "/", port: "http", }], @@ -380,11 +386,13 @@ describe("plugins.container", () => { buildArgs: {}, services: [{ name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, ingresses: [ { + annotations: {}, path: "/", port: "bla", }, @@ -437,6 +445,7 @@ describe("plugins.container", () => { buildArgs: {}, services: [{ name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, @@ -489,6 +498,7 @@ describe("plugins.container", () => { buildArgs: {}, services: [{ name: "service-a", + annotations: {}, args: ["echo"], dependencies: [], daemon: false, diff --git a/garden-service/test/src/plugins/kubernetes/container/ingress.ts b/garden-service/test/src/plugins/kubernetes/container/ingress.ts index beba849a2d..f76cbc3772 100644 --- a/garden-service/test/src/plugins/kubernetes/container/ingress.ts +++ b/garden-service/test/src/plugins/kubernetes/container/ingress.ts @@ -8,7 +8,7 @@ import { gardenPlugin } from "../../../../../src/plugins/container/container" import { dataDir, makeTestGarden, expectError } from "../../../../helpers" import { Garden } from "../../../../../src/garden" import { moduleFromConfig } from "../../../../../src/types/module" -import { createIngresses } from "../../../../../src/plugins/kubernetes/container/ingress" +import { createIngressResources } from "../../../../../src/plugins/kubernetes/container/ingress" import { ServicePortProtocol, ContainerIngressSpec, @@ -287,7 +287,7 @@ const wildcardDomainCertSecret = { }, } -describe("createIngresses", () => { +describe("createIngressResources", () => { const projectRoot = resolve(dataDir, "test-project-container") const handler = gardenPlugin() const configure = handler.moduleActions!.container!.configure! @@ -321,6 +321,7 @@ describe("createIngresses", () => { async function getTestService(...ingresses: ContainerIngressSpec[]): Promise { const spec: ContainerServiceSpec = { name: "my-service", + annotations: {}, args: [], daemon: false, dependencies: [], @@ -395,12 +396,13 @@ describe("createIngresses", () => { it("should create an ingress for a basic container service", async () => { const service = await getTestService({ + annotations: {}, path: "/", port: "http", }) const api = getKubeApi(basicProvider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) expect(ingresses).to.eql([{ apiVersion: "extensions/v1beta1", @@ -434,20 +436,15 @@ describe("createIngresses", () => { }]) }) - it("should group ingresses by hostname", async () => { - const service = await getTestService( - { - path: "/here", - port: "http", - }, - { - path: "/there", - port: "http", - }, - ) + it("should add annotations if configured", async () => { + const service = await getTestService({ + annotations: { foo: "bar" }, + path: "/", + port: "http", + }) const api = getKubeApi(basicProvider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) expect(ingresses).to.eql([{ apiVersion: "extensions/v1beta1", @@ -457,6 +454,7 @@ describe("createIngresses", () => { annotations: { "ingress.kubernetes.io/force-ssl-redirect": "false", "kubernetes.io/ingress.class": "nginx", + "foo": "bar", }, namespace, }, @@ -467,14 +465,7 @@ describe("createIngresses", () => { http: { paths: [ { - path: "/here", - backend: { - serviceName: "my-service", - servicePort: 123, - }, - }, - { - path: "/there", + path: "/", backend: { serviceName: "my-service", servicePort: 123, @@ -488,38 +479,39 @@ describe("createIngresses", () => { }]) }) - it("should create a rule for each hostname", async () => { + it("should create multiple ingresses if specified", async () => { const service = await getTestService( { - hostname: "foo", + annotations: {}, path: "/", port: "http", }, { - hostname: "bar", - path: "/", + annotations: {}, + hostname: "bla", + path: "/foo", port: "http", }, ) const api = getKubeApi(basicProvider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) - expect(ingresses).to.eql([{ - apiVersion: "extensions/v1beta1", - kind: "Ingress", - metadata: { - name: service.name, - annotations: { - "ingress.kubernetes.io/force-ssl-redirect": "false", - "kubernetes.io/ingress.class": "nginx", + expect(ingresses).to.eql([ + { + apiVersion: "extensions/v1beta1", + kind: "Ingress", + metadata: { + name: service.name, + annotations: { + "ingress.kubernetes.io/force-ssl-redirect": "false", + "kubernetes.io/ingress.class": "nginx", + }, + namespace, }, - namespace, - }, - spec: { - rules: [ - { - host: "foo", + spec: { + rules: [{ + host: "my.domain.com", http: { paths: [ { @@ -531,13 +523,27 @@ describe("createIngresses", () => { }, ], }, + }], + }, + }, + { + apiVersion: "extensions/v1beta1", + kind: "Ingress", + metadata: { + name: service.name, + annotations: { + "ingress.kubernetes.io/force-ssl-redirect": "false", + "kubernetes.io/ingress.class": "nginx", }, - { - host: "bar", + namespace, + }, + spec: { + rules: [{ + host: "bla", http: { paths: [ { - path: "/", + path: "/foo", backend: { serviceName: "my-service", servicePort: 123, @@ -545,22 +551,23 @@ describe("createIngresses", () => { }, ], }, - }, - ], + }], + }, }, - }]) + ]) }) it("should map a configured TLS certificate to an ingress", async () => { const service = await getTestService( { + annotations: {}, path: "/", port: "http", }, ) const api = getKubeApi(singleTlsProvider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) td.verify(api.upsert("Secret", namespace, myDomainCertSecret)) @@ -599,95 +606,10 @@ describe("createIngresses", () => { }]) }) - it("should group multiple ingresses by TLS certificate", async () => { - const service = await getTestService( - { - path: "/", - port: "http", - }, - { - hostname: "other.domain.com", - path: "/", - port: "http", - }, - ) - - const api = getKubeApi(multiTlsProvider) - const ingresses = await createIngresses(api, namespace, service) - - expect(ingresses).to.eql([ - { - apiVersion: "extensions/v1beta1", - kind: "Ingress", - metadata: { - name: service.name, - annotations: { - "ingress.kubernetes.io/force-ssl-redirect": "true", - "kubernetes.io/ingress.class": "nginx", - }, - namespace, - }, - spec: { - tls: [{ - secretName: "somesecret", - }], - rules: [ - { - host: "my.domain.com", - http: { - paths: [ - { - path: "/", - backend: { - serviceName: "my-service", - servicePort: 123, - }, - }, - ], - }, - }, - ], - }, - }, - { - apiVersion: "extensions/v1beta1", - kind: "Ingress", - metadata: { - name: service.name, - annotations: { - "ingress.kubernetes.io/force-ssl-redirect": "true", - "kubernetes.io/ingress.class": "nginx", - }, - namespace, - }, - spec: { - tls: [{ - secretName: "othersecret", - }], - rules: [ - { - host: "other.domain.com", - http: { - paths: [ - { - path: "/", - backend: { - serviceName: "my-service", - servicePort: 123, - }, - }, - ], - }, - }, - ], - }, - }, - ]) - }) - it("should throw if a configured certificate doesn't exist", async () => { const service = await getTestService( { + annotations: {}, path: "/", port: "http", }, @@ -705,12 +627,13 @@ describe("createIngresses", () => { err.code = 404 td.when(api.core.readNamespacedSecret("foo", "default")).thenReject(err) - await expectError(async () => await createIngresses(api, namespace, service), "configuration") + await expectError(async () => await createIngressResources(api, namespace, service), "configuration") }) it("should throw if a secret for a configured certificate doesn't contain a certificate", async () => { const service = await getTestService( { + annotations: {}, path: "/", port: "http", }, @@ -732,12 +655,13 @@ describe("createIngresses", () => { }, }) - await expectError(async () => await createIngresses(api, namespace, service), "configuration") + await expectError(async () => await createIngressResources(api, namespace, service), "configuration") }) it("should throw if a secret for a configured certificate contains an invalid certificate", async () => { const service = await getTestService( { + annotations: {}, path: "/", port: "http", }, @@ -761,12 +685,13 @@ describe("createIngresses", () => { }, }) - await expectError(async () => await createIngresses(api, namespace, service), "configuration") + await expectError(async () => await createIngressResources(api, namespace, service), "configuration") }) it("should correctly match an ingress to a wildcard certificate", async () => { const service = await getTestService( { + annotations: {}, hostname: "something.wildcarddomain.com", path: "/", port: "http", @@ -774,7 +699,7 @@ describe("createIngresses", () => { ) const api = getKubeApi(multiTlsProvider) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) td.verify(api.upsert("Secret", namespace, wildcardDomainCertSecret)) @@ -816,6 +741,7 @@ describe("createIngresses", () => { it("should use configured hostnames for a certificate when specified", async () => { const service = await getTestService( { + annotations: {}, hostname: "madeup.domain.com", path: "/", port: "http", @@ -837,7 +763,7 @@ describe("createIngresses", () => { td.when(api.core.readNamespacedSecret("foo", "default")).thenResolve({ body: myDomainCertSecret, }) - const ingresses = await createIngresses(api, namespace, service) + const ingresses = await createIngressResources(api, namespace, service) td.verify(api.upsert("Secret", namespace, myDomainCertSecret)) diff --git a/garden-service/test/src/plugins/kubernetes/container/service.ts b/garden-service/test/src/plugins/kubernetes/container/service.ts new file mode 100644 index 0000000000..c85c65d2db --- /dev/null +++ b/garden-service/test/src/plugins/kubernetes/container/service.ts @@ -0,0 +1,85 @@ +import { createServiceResources } from "../../../../../src/plugins/kubernetes/container/service" +import { makeTestGarden, dataDir } from "../../../../helpers" +import { gardenPlugin } from "../../../../../src/plugins/container/container" +import { resolve } from "path" +import { Garden } from "../../../../../src/garden" +import { expect } from "chai" + +describe("createServiceResources", () => { + const projectRoot = resolve(dataDir, "test-project-container") + let garden: Garden + + beforeEach(async () => { + garden = await makeTestGarden(projectRoot, { container: gardenPlugin }) + }) + + it("should return service resources", async () => { + const graph = await garden.getConfigGraph() + const service = await graph.getService("service-a") + + const resources = await createServiceResources(service, "my-namespace") + + expect(resources).to.eql([ + { + apiVersion: "v1", + kind: "Service", + metadata: { + annotations: {}, + name: "service-a", + namespace: "my-namespace", + }, + spec: { + ports: [ + { + name: "http", + protocol: "TCP", + targetPort: 8080, + port: 8080, + }, + ], + selector: { + service: "service-a", + }, + type: "ClusterIP", + }, + }, + ]) + }) + + it("should add annotations if configured", async () => { + const graph = await garden.getConfigGraph() + const service = await graph.getService("service-a") + + service.spec.annotations = { my: "annotation" } + + const resources = await createServiceResources(service, "my-namespace") + + expect(resources).to.eql([ + { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "service-a", + annotations: { + my: "annotation", + }, + namespace: "my-namespace", + }, + spec: { + ports: [ + { + name: "http", + protocol: "TCP", + targetPort: 8080, + port: 8080, + }, + ], + selector: { + service: "service-a", + }, + type: "ClusterIP", + }, + }, + ]) + }) +}) diff --git a/garden-service/test/src/task-graph.ts b/garden-service/test/src/task-graph.ts index a21927f2c1..c582c95f1a 100644 --- a/garden-service/test/src/task-graph.ts +++ b/garden-service/test/src/task-graph.ts @@ -133,7 +133,7 @@ describe("task-graph", () => { ]) }) - it.only("should not emit a taskPending event when adding a task with a cached result", async () => { + it("should not emit a taskPending event when adding a task with a cached result", async () => { const now = freezeTime() const garden = await getGarden()