diff --git a/core/package.json b/core/package.json index 6562934413..413355798b 100644 --- a/core/package.json +++ b/core/package.json @@ -165,7 +165,7 @@ "devDependencies": { "@commitlint/cli": "^17.6.5", "@commitlint/config-conventional": "^17.6.5", - "@garden-io/platform-api-types": "1.455.0", + "@garden-io/platform-api-types": "1.914.0", "@google-cloud/kms": "^3.7.0", "@types/analytics-node": "^3.1.10", "@types/async": "^3.2.18", diff --git a/core/src/cloud/api.ts b/core/src/cloud/api.ts index 4b749f6fbb..5e3f6327a7 100644 --- a/core/src/cloud/api.ts +++ b/core/src/cloud/api.ts @@ -16,11 +16,14 @@ import { Cookie } from "tough-cookie" import { cloneDeep, isObject } from "lodash" import { dedent, deline } from "../util/string" import { - GetProjectResponse, - GetProfileResponse, + BaseResponse, + CreateEphemeralClusterResponse, CreateProjectsForRepoResponse, + EphemeralClusterWithRegistry, + GetKubeconfigResponse, + GetProfileResponse, + GetProjectResponse, ListProjectsResponse, - BaseResponse, } from "@garden-io/platform-api-types" import { getCloudDistributionName, getCloudLogSectionName, getPackageVersion } from "../util/util" import { CommandInfo } from "../plugin-context" @@ -36,6 +39,10 @@ const gardenClientVersion = getPackageVersion() export class CloudApiDuplicateProjectsError extends CloudApiError {} export class CloudApiTokenRefreshError extends CloudApiError {} +function extractErrorMessageBodyFromGotError(error: any): error is GotHttpError { + return error?.response?.body?.message +} + function stripLeadingSlash(str: string) { return str.replace(/^\/+/, "") } @@ -786,4 +793,28 @@ export class CloudApi { } return secrets } + + async createEphemeralCluster(): Promise { + try { + const response = await this.post(`/ephemeral-clusters/`) + return response.data + } catch (err) { + throw new CloudApiError({ + message: `${extractErrorMessageBodyFromGotError(err) ?? "Creating an ephemeral cluster failed."}`, + }) + } + } + + async getKubeConfigForCluster(clusterId: string): Promise { + try { + const response = await this.get(`/ephemeral-clusters/${clusterId}/kubeconfig`) + return response.data.kubeconfig + } catch (err) { + throw new CloudApiError({ + message: `${ + extractErrorMessageBodyFromGotError(err) ?? "Fetching the Kubeconfig for ephemeral cluster failed." + }`, + }) + } + } } diff --git a/core/src/plugins/kubernetes/container/ingress.ts b/core/src/plugins/kubernetes/container/ingress.ts index 13c5cef38f..6606634b5a 100644 --- a/core/src/plugins/kubernetes/container/ingress.ts +++ b/core/src/plugins/kubernetes/container/ingress.ts @@ -20,6 +20,7 @@ import { V1Ingress, V1Secret } from "@kubernetes/client-node" import { Log } from "../../../logger/log-entry" import chalk from "chalk" import { Resolved } from "../../../actions/types" +import { isProviderEphemeralKubernetes } from "../ephemeral/ephemeral" // Ingress API versions in descending order of preference export const supportedIngressApiVersions = ["networking.k8s.io/v1", "networking.k8s.io/v1beta1", "extensions/v1beta1"] @@ -183,8 +184,14 @@ async function getIngress( const certificate = await pickCertificate(action, api, provider, hostname) // TODO: support other protocols - const protocol: ServiceProtocol = !!certificate ? "https" : "http" - const port = !!certificate ? provider.config.ingressHttpsPort : provider.config.ingressHttpPort + let protocol: ServiceProtocol = !!certificate ? "https" : "http" + let port = !!certificate ? provider.config.ingressHttpsPort : provider.config.ingressHttpPort + + // ephemeral-kubernetes ingresses should always be https + if (isProviderEphemeralKubernetes(provider)) { + protocol = "https" + port = provider.config.ingressHttpsPort + } return { ...spec, diff --git a/core/src/plugins/kubernetes/ephemeral/config.ts b/core/src/plugins/kubernetes/ephemeral/config.ts new file mode 100644 index 0000000000..4921ef5643 --- /dev/null +++ b/core/src/plugins/kubernetes/ephemeral/config.ts @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2018-2023 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 chalk from "chalk" +import { mkdirp, writeFile } from "fs-extra" +import { load } from "js-yaml" +import { remove } from "lodash" +import moment from "moment" +import { join } from "path" +import { joi, joiProviderName } from "../../../config/common" +import { providerConfigBaseSchema } from "../../../config/provider" +import { ConfigurationError } from "../../../exceptions" +import { ConfigureProviderParams } from "../../../plugin/handlers/Provider/configureProvider" +import { dedent } from "../../../util/string" +import { KubernetesConfig, namespaceSchema } from "../config" +import { EPHEMERAL_KUBERNETES_PROVIDER_NAME } from "./ephemeral" + +export const configSchema = () => + providerConfigBaseSchema() + .keys({ + name: joiProviderName(EPHEMERAL_KUBERNETES_PROVIDER_NAME), + namespace: namespaceSchema().description( + "Specify which namespace to deploy services to (defaults to the project name). " + + "Note that the framework generates other namespaces as well with this name as a prefix." + ), + setupIngressController: joi + .string() + .allow("nginx", false, null) + .default("nginx") + .description( + dedent`Set this to null or false to skip installing/enabling the \`nginx\` ingress controller. Note: if you skip installing the \`nginx\` ingress controller for ephemeral cluster, your ingresses may not function properly.` + ), + }) + .description(`The provider configuration for the ${EPHEMERAL_KUBERNETES_PROVIDER_NAME} plugin.`) + +export async function configureProvider(params: ConfigureProviderParams) { + const { base, log, projectName, ctx, config: baseConfig } = params + if (projectName === "garden-system") { + // avoid configuring ephemeral-kubernetes provider and creating ephemeral-cluster for garden-system project + return { + config: baseConfig, + } + } + log.info(`Configuring ${EPHEMERAL_KUBERNETES_PROVIDER_NAME} provider for project ${projectName}`) + if (!ctx.cloudApi) { + throw new ConfigurationError({ + message: `You are not logged in. You must be logged into Garden Cloud in order to use ${EPHEMERAL_KUBERNETES_PROVIDER_NAME} provider.`, + }) + } + if (ctx.cloudApi && ctx.cloudApi?.domain !== "https://app.garden.io") { + throw new ConfigurationError({ + message: `${EPHEMERAL_KUBERNETES_PROVIDER_NAME} provider is currently not supported for ${ctx.cloudApi.distroName}.`, + }) + } + // creating tmp dir .garden/ephemeral-kubernetes for storing kubeconfig + const ephemeralClusterDirPath = join(ctx.gardenDirPath, "ephemeral-kubernetes") + await mkdirp(ephemeralClusterDirPath) + log.info("Creating ephemeral kubernetes cluster") + const createEphemeralClusterResponse = await ctx.cloudApi.createEphemeralCluster() + const clusterId = createEphemeralClusterResponse.instanceMetadata.instanceId + log.info(`Ephemeral kubernetes cluster created successfully`) + const deadlineDateTime = moment(createEphemeralClusterResponse.instanceMetadata.deadline) + const diffInNowAndDeadline = moment.duration(deadlineDateTime.diff(moment())).asMinutes().toFixed(1) + log.info( + chalk.white( + `Ephemeral cluster will be destroyed in ${diffInNowAndDeadline} minutes, at ${deadlineDateTime.format( + "YYYY-MM-DD HH:mm:ss" + )}` + ) + ) + log.info("Getting Kubeconfig for the cluster") + const kubeConfig = await ctx.cloudApi.getKubeConfigForCluster(clusterId) + const kubeconfigFileName = `${clusterId}-kubeconfig.yaml` + const kubeConfigPath = join(ctx.gardenDirPath, "ephemeral-kubernetes", kubeconfigFileName) + await writeFile(kubeConfigPath, kubeConfig) + log.info(`Kubeconfig for ephemeral cluster saved at path: ${chalk.underline(kubeConfigPath)}`) + + const parsedKubeConfig: any = load(kubeConfig) + const currentContext = parsedKubeConfig["current-context"] + baseConfig.context = currentContext + baseConfig.kubeconfig = kubeConfigPath + + // set deployment registry + baseConfig.deploymentRegistry = { + hostname: createEphemeralClusterResponse.registry.endpointAddress, + namespace: createEphemeralClusterResponse.registry.repository, + insecure: false, + } + // set imagePullSecrets + baseConfig.imagePullSecrets = [ + { + name: createEphemeralClusterResponse.registry.imagePullSecret.name, + namespace: createEphemeralClusterResponse.registry.imagePullSecret.namespace, + }, + ] + // set build mode to kaniko + baseConfig.buildMode = "kaniko" + // set additional kaniko flags + baseConfig.kaniko = { + extraFlags: [ + `--registry-mirror=${createEphemeralClusterResponse.registry.endpointAddress}`, + `--registry-mirror=${createEphemeralClusterResponse.registry.dockerRegistryMirror}`, + "--insecure-pull", + "--force", + ], + } + // set setupIngressController to null while initializing kubernetes plugin + // as we use it later and configure it separately for ephemeral-kubernetes + const kubernetesPluginConfig = { + ...params, + config: { + ...baseConfig, + setupIngressController: null, + }, + } + let { config: updatedConfig } = await base!(kubernetesPluginConfig) + + // setup ingress controller unless setupIngressController is set to false/null in provider config + if (baseConfig.setupIngressController) { + const _systemServices = updatedConfig._systemServices + const nginxServices = ["ingress-controller", "default-backend"] + remove(_systemServices, (s) => nginxServices.includes(s)) + _systemServices.push("nginx-ephemeral") + updatedConfig.setupIngressController = "nginx" + // set default hostname + updatedConfig.defaultHostname = createEphemeralClusterResponse.ingressesHostname + } + + return { + config: updatedConfig, + } +} diff --git a/core/src/plugins/kubernetes/ephemeral/ephemeral.ts b/core/src/plugins/kubernetes/ephemeral/ephemeral.ts new file mode 100644 index 0000000000..6b433acda4 --- /dev/null +++ b/core/src/plugins/kubernetes/ephemeral/ephemeral.ts @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2018-2023 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 { configureProvider, configSchema } from "./config" +import { createGardenPlugin } from "../../../plugin/plugin" +import { dedent } from "../../../util/string" +import { KubernetesProvider } from "../config" +import { joi, joiIdentifier } from "../../../config/common" + +const providerUrl = "./kubernetes.md" +export const EPHEMERAL_KUBERNETES_PROVIDER_NAME = "ephemeral-kubernetes" + +const outputsSchema = joi.object().keys({ + "app-namespace": joiIdentifier().required().description("The primary namespace used for resource deployments."), + "default-hostname": joi + .string() + .description( + "The dynamic hostname assigned to the ephemeral cluster automatically, when an ephemeral cluster is created." + ), +}) + +export const gardenPlugin = () => + createGardenPlugin({ + name: EPHEMERAL_KUBERNETES_PROVIDER_NAME, + base: "kubernetes", + docs: dedent` + The \`${EPHEMERAL_KUBERNETES_PROVIDER_NAME}\` provider is a specialized version of the [\`kubernetes\` provider](${providerUrl}) that allows to deploy applications to one of the ephemeral Kubernetes clusters provided by Garden. + + For information about using ephemeral Kubernetes clusters, please refer to [Ephemeral Kubernetes clusters guide](../../basics/ephemeral-clusters.md) + `, + configSchema: configSchema(), + outputsSchema, + handlers: { + configureProvider, + }, + }) + +export function isProviderEphemeralKubernetes(provider: KubernetesProvider) { + return provider?.name === EPHEMERAL_KUBERNETES_PROVIDER_NAME +} diff --git a/core/src/plugins/kubernetes/helm/deployment.ts b/core/src/plugins/kubernetes/helm/deployment.ts index 19d898b013..e295a9756b 100644 --- a/core/src/plugins/kubernetes/helm/deployment.ts +++ b/core/src/plugins/kubernetes/helm/deployment.ts @@ -167,7 +167,7 @@ export const helmDeploy: DeployActionHandler<"deploy", HelmDeployAction> = async attached = true } // Get ingresses of deployed resources - const ingresses = getK8sIngresses(manifests) + const ingresses = getK8sIngresses(manifests, provider) return { state: "ready", diff --git a/core/src/plugins/kubernetes/helm/status.ts b/core/src/plugins/kubernetes/helm/status.ts index 164ad2c4b0..e0223aecb3 100644 --- a/core/src/plugins/kubernetes/helm/status.ts +++ b/core/src/plugins/kubernetes/helm/status.ts @@ -72,7 +72,7 @@ export const getHelmDeployStatus: DeployActionHandler<"getStatus", HelmDeployAct const deployedResources = await getDeployedChartResources({ ctx: k8sCtx, action, releaseName, log }) forwardablePorts = getForwardablePorts({ resources: deployedResources, parentAction: action, mode: deployedMode }) - ingresses = getK8sIngresses(deployedResources) + ingresses = getK8sIngresses(deployedResources, provider) if (state === "ready") { // Local mode always takes precedence over sync mode diff --git a/core/src/plugins/kubernetes/init.ts b/core/src/plugins/kubernetes/init.ts index 3878516127..fa3ac7857a 100644 --- a/core/src/plugins/kubernetes/init.ts +++ b/core/src/plugins/kubernetes/init.ts @@ -35,6 +35,7 @@ import { mapValues, omit } from "lodash" import { getIngressApiVersion, supportedIngressApiVersions } from "./container/ingress" import { Log } from "../../logger/log-entry" import { DeployStatusMap } from "../../plugin/handlers/Deploy/get-status" +import { isProviderEphemeralKubernetes } from "./ephemeral/ephemeral" const dockerAuthSecretType = "kubernetes.io/dockerconfigjson" const dockerAuthDocsLink = ` @@ -230,9 +231,9 @@ export async function prepareSystem({ return {} } - // We require manual init if we're installing any system services to remote clusters, to avoid conflicts - // between users or unnecessary work. - if (!clusterInit && remoteCluster) { + // We require manual init if we're installing any system services to remote clusters unless the remote cluster + // is an ephemeral-cluster, to avoid conflicts between users or unnecessary work. + if (!clusterInit && remoteCluster && !isProviderEphemeralKubernetes(provider)) { const initCommand = chalk.white.bold(`garden --env=${ctx.environmentName} plugins kubernetes cluster-init`) if (combinedState === "ready") { diff --git a/core/src/plugins/kubernetes/kubernetes-type/handlers.ts b/core/src/plugins/kubernetes/kubernetes-type/handlers.ts index 432b1c71c2..649bb436a4 100644 --- a/core/src/plugins/kubernetes/kubernetes-type/handlers.ts +++ b/core/src/plugins/kubernetes/kubernetes-type/handlers.ts @@ -214,7 +214,7 @@ export const getKubernetesDeployStatus: DeployActionHandler<"getStatus", Kuberne version: state === "ready" ? action.versionString() : undefined, detail: { remoteResources }, mode: deployedMode, - ingresses: getK8sIngresses(remoteResources), + ingresses: getK8sIngresses(remoteResources, provider), }, // TODO-0.13.1 outputs: {}, diff --git a/core/src/plugins/kubernetes/status/ingress.ts b/core/src/plugins/kubernetes/status/ingress.ts index 0610d8693b..ed33959aa8 100644 --- a/core/src/plugins/kubernetes/status/ingress.ts +++ b/core/src/plugins/kubernetes/status/ingress.ts @@ -6,7 +6,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { ServiceIngress } from "../../../types/service" +import { ServiceIngress, ServiceProtocol } from "../../../types/service" +import { KubernetesProvider } from "../config" +import { isProviderEphemeralKubernetes } from "../ephemeral/ephemeral" import { KubernetesIngress, KubernetesResource } from "../types" /** @@ -14,7 +16,7 @@ import { KubernetesIngress, KubernetesResource } from "../types" * * Does a best-effort extraction based on known ingress resource types. */ -export function getK8sIngresses(resources: KubernetesResource[]): ServiceIngress[] { +export function getK8sIngresses(resources: KubernetesResource[], provider?: KubernetesProvider): ServiceIngress[] { const output: ServiceIngress[] = [] for (const r of resources.filter(isIngressResource)) { @@ -40,9 +42,15 @@ export function getK8sIngresses(resources: KubernetesResource[]): ServiceIngress stringPath = path } + let protocol: ServiceProtocol = tlsHosts.includes(rule.host) ? "https" : "http" + // ephemeral-kubernetes ingresses should always be https + if (provider && isProviderEphemeralKubernetes(provider)) { + protocol = "https" + } + output.push({ hostname: rule.host, - protocol: tlsHosts.includes(rule.host) ? "https" : "http", + protocol, path: stringPath, }) } diff --git a/core/src/plugins/plugins.ts b/core/src/plugins/plugins.ts index bc88f833d9..80912eefaf 100644 --- a/core/src/plugins/plugins.ts +++ b/core/src/plugins/plugins.ts @@ -13,6 +13,7 @@ export const getSupportedPlugins = () => [ { name: "hadolint", callback: () => require("./hadolint/hadolint").gardenPlugin.getSpec() }, { name: "kubernetes", callback: () => require("./kubernetes/kubernetes").gardenPlugin() }, { name: "local-kubernetes", callback: () => require("./kubernetes/local/local").gardenPlugin() }, + { name: "ephemeral-kubernetes", callback: () => require("./kubernetes/ephemeral/ephemeral").gardenPlugin() }, { name: "openshift", callback: () => require("./openshift/openshift").gardenPlugin() }, { name: "octant", callback: () => require("./octant/octant").gardenPlugin() }, { name: "otel-collector", callback: () => require("./otel-collector/otel-collector").gardenPlugin.getSpec() }, diff --git a/core/test/helpers/api.ts b/core/test/helpers/api.ts index f7f52f8041..135a8d00ae 100644 --- a/core/test/helpers/api.ts +++ b/core/test/helpers/api.ts @@ -42,6 +42,8 @@ export class FakeCloudApi extends CloudApi { cachedPermissions: {}, accessTokens: [], groups: [], + meta: {}, + singleProjectId: "", } } diff --git a/core/test/unit/src/plugins/kubernetes/ephemeral.ts b/core/test/unit/src/plugins/kubernetes/ephemeral.ts new file mode 100644 index 0000000000..2cda4cdeea --- /dev/null +++ b/core/test/unit/src/plugins/kubernetes/ephemeral.ts @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2018-2023 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 { providerFromConfig } from "../../../../../src/config/provider" +import { Garden } from "../../../../../src/garden" +import { getRootLogger } from "../../../../../src/logger/logger" +import { configureProvider } from "../../../../../src/plugins/kubernetes/ephemeral/config" +import { gardenPlugin } from "../../../../../src/plugins/kubernetes/ephemeral/ephemeral" +import { TempDirectory, expectError, makeTempDir, makeTestGardenA } from "../../../../helpers" +import { FakeCloudApi } from "../../../../helpers/api" + +describe("ephemeral-kubernetes configureProvider", () => { + const basicConfig = { + name: "ephemeral-kubernetes", + } + + let tmpDir: TempDirectory + let garden: Garden + + beforeEach(async () => { + tmpDir = await makeTempDir({ git: true }) + }) + + afterEach(async () => { + await tmpDir.cleanup() + }) + + async function configure(config) { + return configureProvider({ + ctx: await garden.getPluginContext({ + provider: providerFromConfig({ + plugin: gardenPlugin(), + config, + dependencies: {}, + moduleConfigs: [], + status: { ready: false, outputs: {} }, + }), + templateContext: undefined, + events: undefined, + }), + namespace: "default", + environmentName: "default", + projectName: garden.projectName, + projectRoot: garden.projectRoot, + config, + log: garden.log, + dependencies: {}, + configStore: garden.localConfigStore, + }) + } + + it("should throw an error in configure provider if user is not logged in", async () => { + garden = await makeTestGardenA(undefined) + await expectError( + () => + configure({ + ...basicConfig, + }), + (err) => { + expect(err.message).to.contain( + "You are not logged in. You must be logged into Garden Cloud in order to use ephemeral-kubernetes provider" + ) + } + ) + }) + + it("should throw an error for Garden Enterprise", async () => { + const cloudApi = await FakeCloudApi.factory({ log: getRootLogger().createLog() }) + garden = await makeTestGardenA(undefined, { cloudApi }) + await expectError( + () => + configure({ + ...basicConfig, + }), + (err) => { + expect(err.message).to.equal("ephemeral-kubernetes provider is currently not supported for Garden Enterprise.") + } + ) + }) +}) diff --git a/docs/README.md b/docs/README.md index 1b3d3fced1..ddbec896d4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ * [How Garden Works](./basics/how-garden-works.md) * [Quickstart Guide](./basics/quickstart.md) * [Core Concepts](./basics/core-concepts.md) +* [Start a Free Kubernetes Cluster](./basics/ephemeral-clusters.md) * [Use Cases](./basics/use-cases.md) * [Garden vs Other Tools](./basics/garden-vs-other-tools.md) @@ -100,6 +101,7 @@ * [`conftest-kubernetes`](./reference/providers/conftest-kubernetes.md) * [`conftest`](./reference/providers/conftest.md) * [`container`](./reference/providers/container.md) + * [`ephemeral-kubernetes`](./reference/providers/ephemeral-kubernetes.md) * [`exec`](./reference/providers/exec.md) * [`hadolint`](./reference/providers/hadolint.md) * [`jib`](./reference/providers/jib.md) diff --git a/docs/basics/ephemeral-clusters.md b/docs/basics/ephemeral-clusters.md new file mode 100644 index 0000000000..7989688820 --- /dev/null +++ b/docs/basics/ephemeral-clusters.md @@ -0,0 +1,95 @@ +--- +title: Start a Free Kubernetes Cluster +order: 4 +--- + +# Ephemeral Kubernetes Clusters + +{% hint style="warning" %} +This feature is still experimental. Please let us know if you have any questions or if any issues come up! +{% endhint %} + +At Garden, we're committed to reducing the friction with getting started and trialing our tooling with your projects. To make Garden adoption more accessible and convenient, we've introduced **Ephemeral Kubernetes Clusters**. We designed this feature to provide you with a hassle-free way to explore Garden's capabilities on Kubernetes without needing to configure or provision a local or remote cluster. + +The Ephemeral Kubernetes Clusters are provided for free to all users in our **Community Tier**. These clusters are meant for short-term use and to allow you to run and test your applications with Garden on a Kubernetes remote cluster. + +## Usage quota and managing clusters + +Each user is granted a maximum of **20 hours per month** of ephemeral cluster usage where each cluster has a maximum lifetime of **4 hours**. After this period, the cluster is automatically destroyed. + +If you need to destroy the cluster before its maximum lifetime of 4 hours expires, you can do so by visiting [Garden Cloud](https://app.garden.io) and selecting the option to destroy the ephemeral cluster from there. This allows you to release resources and terminate the cluster when it's no longer needed. + +## Configuring your projects to use ephemeral Kubernetes cluster + +To get started with Ephemeral Kubernetes Clusters, follow these steps: + +1. Login to Garden Cloud by running `garden login` from your project root. +2. Configure the `ephemeral-kubernetes` provider in your project's configuration file. Here's an example configuration: + +```yaml +providers: + - name: ephemeral-kubernetes + environments: [remote] + +``` +In the above configuration, we configure `ephemeral-kubernetes` for the `remote` environment. + +## Deploy your project on ephemeral cluster + +Once the provider is configured, you can deploy your project using the Garden CLI by running the following command: + +``` +garden deploy --env remote +``` + +Garden will automatically provision an Ephemeral Kubernetes Cluster for your project and deploy your application to it. + +## Ingress + +Ephemeral Kubernetes Clusters fully support ingresses and each cluster is assigned its own unique default hostname dynamically when created. This hostname and its direct subdomains are secured by TLS and require authentication. + +### Configuring ingress + +If you want to refer to the hostname that is assigned dynamically when the cluster is created, you can refer to that using the output `${providers.ephemeral-kubernetes.outputs.default-hostname}`. This can be useful if, for example, you want to expose an ingress on a subdomain of the default hostname. + +For example, if you wish to expose `api` on `api.`, you can use the following configuration for ingresses: + +```yaml +.... +ingresses: + - path: / + port: http + hostname: api.${providers.ephemeral-kubernetes.outputs.default-hostname} +``` + +### Authentication for ingress + +The ingress URLs are not publicly accessible and require authentication via GitHub. To preview an ingress URL, you need to authenticate with GitHub and authorize the "Garden Ephemeral Environment Previews" app. + +The first time you attempt to preview an ingress URL, you will be automatically redirected to GitHub for authorization of the "Garden Ephemeral Environment Previews" app. This is a one-time step, and subsequent ingress previews won't require re-authorization, ensuring a seamless experience as long as you remain logged in to the GitHub. + +{% hint style="info" %} +Ingress URLs are not shareable at the moment however it is planned to be supported in future releases. Stay tuned for further updates on this. +{% endhint %} + +## Accessing the ephemeral cluster via kubeconfig + +Once your ephemeral cluster is created, the kubeconfig file for that cluster is stored on your local machine. The path to the kubeconfig file is shown in the logs when you deploy your project using Garden and looks like following: +``` +kubeconfig for ephemeral cluster saved at path: /garden/examples/ephemeral-cluster-demo/.garden/ephemeral-kubernetes/-kubeconfig.yaml +``` + +This kubeconfig file allows you to interact with the cluster using `kubectl` or other Kubernetes tools. + +## Limitations + +As of today, the ephemeral-kubernetes provider has the following limitations: + +- Local docker builds are currently not supported. In-cluster building with Kaniko is the only supported building method and it is configured by default at the provider level. + +## Example projects using the `ephemeral-kubernetes` provider + +To demonstrate the use of the `ephemeral-kubernetes` provider, we have added an example project: [ephemeral-cluster-demo](https://github.com/garden-io/garden/tree/main/examples) under our examples collection. Check out the `ephemeral-cluster-demo` example and README at: + + + diff --git a/docs/reference/providers/ephemeral-kubernetes.md b/docs/reference/providers/ephemeral-kubernetes.md new file mode 100644 index 0000000000..9d382eb55c --- /dev/null +++ b/docs/reference/providers/ephemeral-kubernetes.md @@ -0,0 +1,193 @@ +--- +title: "`ephemeral-kubernetes` Provider" +tocTitle: "`ephemeral-kubernetes`" +--- + +# `ephemeral-kubernetes` Provider + +## Description + +The `ephemeral-kubernetes` provider is a specialized version of the [`kubernetes` provider](./kubernetes.md) that allows to deploy applications to one of the ephemeral Kubernetes clusters provided by Garden. + +For information about using ephemeral Kubernetes clusters, please refer to [Ephemeral Kubernetes clusters guide](../../basics/ephemeral-clusters.md) + +Below is the full schema reference for the provider configuration. For an introduction to configuring a Garden project with providers, please look at our [configuration guide](../../using-garden/configuration-overview.md). + +The reference is divided into two sections. The [first section](#complete-yaml-schema) contains the complete YAML schema, and the [second section](#configuration-keys) describes each schema key. + +## Complete YAML Schema + +The values in the schema below are the default values. + +```yaml +providers: + - # List other providers that should be resolved before this one. + dependencies: [] + + # If specified, this provider will only be used in the listed environments. Note that an empty array effectively + # disables the provider. To use a provider in all environments, omit this field. + environments: + + # The name of the provider plugin to use. + name: ephemeral-kubernetes + + # Specify which namespace to deploy services to (defaults to the project name). Note that the framework generates + # other namespaces as well with this name as a prefix. + namespace: + # A valid Kubernetes namespace name. Must be a valid RFC1035/RFC1123 (DNS) label (may contain lowercase letters, + # numbers and dashes, must start with a letter, and cannot end with a dash) and must not be longer than 63 + # characters. + name: + + # Map of annotations to apply to the namespace when creating it. + annotations: + + # Map of labels to apply to the namespace when creating it. + labels: + + # Set this to null or false to skip installing/enabling the `nginx` ingress controller. Note: if you skip + # installing the `nginx` ingress controller for ephemeral cluster, your ingresses may not function properly. + setupIngressController: nginx +``` +## Configuration Keys + +### `providers[]` + +| Type | Default | Required | +| --------------- | ------- | -------- | +| `array[object]` | `[]` | No | + +### `providers[].dependencies[]` + +[providers](#providers) > dependencies + +List other providers that should be resolved before this one. + +| Type | Default | Required | +| --------------- | ------- | -------- | +| `array[string]` | `[]` | No | + +Example: + +```yaml +providers: + - dependencies: + - exec +``` + +### `providers[].environments[]` + +[providers](#providers) > environments + +If specified, this provider will only be used in the listed environments. Note that an empty array effectively disables the provider. To use a provider in all environments, omit this field. + +| Type | Required | +| --------------- | -------- | +| `array[string]` | No | + +Example: + +```yaml +providers: + - environments: + - dev + - stage +``` + +### `providers[].name` + +[providers](#providers) > name + +The name of the provider plugin to use. + +| Type | Default | Required | +| -------- | ------------------------ | -------- | +| `string` | `"ephemeral-kubernetes"` | Yes | + +Example: + +```yaml +providers: + - name: "ephemeral-kubernetes" +``` + +### `providers[].namespace` + +[providers](#providers) > namespace + +Specify which namespace to deploy services to (defaults to the project name). Note that the framework generates other namespaces as well with this name as a prefix. + +| Type | Required | +| ------------------ | -------- | +| `object \| string` | No | + +### `providers[].namespace.name` + +[providers](#providers) > [namespace](#providersnamespace) > name + +A valid Kubernetes namespace name. Must be a valid RFC1035/RFC1123 (DNS) label (may contain lowercase letters, numbers and dashes, must start with a letter, and cannot end with a dash) and must not be longer than 63 characters. + +| Type | Required | +| -------- | -------- | +| `string` | No | + +### `providers[].namespace.annotations` + +[providers](#providers) > [namespace](#providersnamespace) > annotations + +Map of annotations to apply to the namespace when creating it. + +| Type | Required | +| -------- | -------- | +| `object` | No | + +Example: + +```yaml +providers: + - namespace: '' + ... + annotations: + cluster-autoscaler.kubernetes.io/safe-to-evict: 'false' +``` + +### `providers[].namespace.labels` + +[providers](#providers) > [namespace](#providersnamespace) > labels + +Map of labels to apply to the namespace when creating it. + +| Type | Required | +| -------- | -------- | +| `object` | No | + +### `providers[].setupIngressController` + +[providers](#providers) > setupIngressController + +Set this to null or false to skip installing/enabling the `nginx` ingress controller. Note: if you skip installing the `nginx` ingress controller for ephemeral cluster, your ingresses may not function properly. + +| Type | Default | Required | +| -------- | --------- | -------- | +| `string` | `"nginx"` | No | + + +## Outputs + +The following keys are available via the `${providers.}` template string key for `ephemeral-kubernetes` providers. + +### `${providers..outputs.app-namespace}` + +The primary namespace used for resource deployments. + +| Type | +| -------- | +| `string` | + +### `${providers..outputs.default-hostname}` + +The dynamic hostname assigned to the ephemeral cluster automatically, when an ephemeral cluster is created. + +| Type | +| -------- | +| `string` | diff --git a/examples/ephemeral-cluster-demo/README.md b/examples/ephemeral-cluster-demo/README.md new file mode 100644 index 0000000000..29fbca3708 --- /dev/null +++ b/examples/ephemeral-cluster-demo/README.md @@ -0,0 +1,40 @@ +# Demo project with using Ephemeral Cluster + +A basic demo showing the use of Ephemeral Clusters. + + +# Simple demo project using Ephemeral Cluster + +This example project demonstrates how to use Garden's ephemeral-kubernetes provider for deploying an application to one of the ephemeral Kubernetes clusters provided by Garden. + +For information about ephemeral Kubernetes clusters, check out the docs: + + + +## Configuring ephemeral kubernetes + +The project configuration of this application, which is specified in `garden.yml`, declares 2 environments `local` and `remote`. And for the `remote` environment `ephemeral-kubernetes` provider is configured as following: + +```yaml +... +environments: + - name: remote # <-- remote environment name + +providers: + # setting ephemeral-kubernetes provider for remote environment + - name: ephemeral-kubernetes + environments: [remote] +... +``` + +## Deploying the project + +To deploy this project to an ephemeral cluster provided by Garden, follow these steps: + +1. Login to Garden Cloud using `garden login`. +2. Run `garden deploy` to deploy the application to remote environment which would be on an ephemeral cluster. + +The ephemeral cluster will be created for you automatically during the deploy step. Once the project has been successfully deployed, the logs will display the ingress URLs for accessing the backend and the frontend. + +> [!NOTE] +> To preview an ingress URL, you'll need to authenticate with GitHub and authorize the "Garden Ephemeral Environment Previews" app. diff --git a/examples/ephemeral-cluster-demo/backend/.dockerignore b/examples/ephemeral-cluster-demo/backend/.dockerignore new file mode 100644 index 0000000000..1cd4736667 --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +Dockerfile +garden.yml +app.yaml diff --git a/examples/ephemeral-cluster-demo/backend/.gardenignore b/examples/ephemeral-cluster-demo/backend/.gardenignore new file mode 100644 index 0000000000..eb086d61c3 --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/.gardenignore @@ -0,0 +1,27 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +.vscode/settings.json +webserver/*server* diff --git a/examples/ephemeral-cluster-demo/backend/.gitignore b/examples/ephemeral-cluster-demo/backend/.gitignore new file mode 100644 index 0000000000..a365e5b54c --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/.gitignore @@ -0,0 +1,30 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +.vscode/settings.json +webserver/*server* + +# Generated files +.*.yml diff --git a/examples/ephemeral-cluster-demo/backend/Dockerfile b/examples/ephemeral-cluster-demo/backend/Dockerfile new file mode 100644 index 0000000000..e993313d66 --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.18.3-alpine3.16 + +ENV PORT=8080 +EXPOSE ${PORT} +WORKDIR /app + +COPY main.go . + +RUN go mod init main && go build -o main . + +ENTRYPOINT ["./main"] diff --git a/examples/ephemeral-cluster-demo/backend/backend.garden.yml b/examples/ephemeral-cluster-demo/backend/backend.garden.yml new file mode 100644 index 0000000000..97b1c10744 --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/backend.garden.yml @@ -0,0 +1,40 @@ +kind: Build +name: backend +description: Backend service container image +type: container + +--- + +kind: Deploy +name: backend +description: Backend service container +type: container + +build: backend + +# You can specify variables here at the action level +variables: + ingressPath: /hello-backend + +spec: + healthCheck: + httpGet: + path: /hello-backend + port: http + ports: + - name: http + containerPort: 8080 + # Maps service:80 -> container:8080 + servicePort: 80 + ingresses: + - path: ${var.ingressPath} + port: http + +--- + +kind: Run +name: backend-run-task +type: container +build: backend +spec: + command: ["sh", "-c", "echo task output"] diff --git a/examples/ephemeral-cluster-demo/backend/main.go b/examples/ephemeral-cluster-demo/backend/main.go new file mode 100644 index 0000000000..78fe7ac251 --- /dev/null +++ b/examples/ephemeral-cluster-demo/backend/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "net/http" +) + +func handler(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "Hello from Go!") +} + +func main() { + http.HandleFunc("/hello-backend", handler) + fmt.Println("Server running...") + + err := http.ListenAndServe(":8080", nil) + if err != nil { + panic(err) + } +} diff --git a/examples/ephemeral-cluster-demo/frontend/.dockerignore b/examples/ephemeral-cluster-demo/frontend/.dockerignore new file mode 100644 index 0000000000..1cd4736667 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +Dockerfile +garden.yml +app.yaml diff --git a/examples/ephemeral-cluster-demo/frontend/.gardenignore b/examples/ephemeral-cluster-demo/frontend/.gardenignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/.gardenignore @@ -0,0 +1 @@ +node_modules diff --git a/examples/ephemeral-cluster-demo/frontend/.gitignore b/examples/ephemeral-cluster-demo/frontend/.gitignore new file mode 100644 index 0000000000..8fa9d6f153 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/.gitignore @@ -0,0 +1 @@ +!*.js \ No newline at end of file diff --git a/examples/ephemeral-cluster-demo/frontend/Dockerfile b/examples/ephemeral-cluster-demo/frontend/Dockerfile new file mode 100644 index 0000000000..90d9671437 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18.16.0-alpine + +ENV PORT=8080 +EXPOSE ${PORT} +WORKDIR /app + +COPY package.json /app +RUN npm install + +COPY . /app + +CMD ["npm", "start"] diff --git a/examples/ephemeral-cluster-demo/frontend/app.js b/examples/ephemeral-cluster-demo/frontend/app.js new file mode 100644 index 0000000000..ace2544e37 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/app.js @@ -0,0 +1,27 @@ +const express = require("express") +const app = express() + +const backendServiceEndpoint = `http://backend/hello-backend` + +app.get("/", (req, res) => res.send("Hello from the frontend!")) + +app.get("/call-backend", (req, res) => { + // Query the backend and return the response + fetch(backendServiceEndpoint) + .then((response) => response.text()) + .then((message) => { + message = `Backend says: '${message}'` + res.json({ + message, + }) + }) + .catch((err) => { + res.statusCode = 500 + res.json({ + error: err, + message: "Unable to reach service at " + backendServiceEndpoint, + }) + }) +}) + +module.exports = { app } diff --git a/examples/ephemeral-cluster-demo/frontend/frontend.garden.yml b/examples/ephemeral-cluster-demo/frontend/frontend.garden.yml new file mode 100644 index 0000000000..fdabea2632 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/frontend.garden.yml @@ -0,0 +1,47 @@ +kind: Build +name: frontend +description: Frontend service container image +type: container + +--- + +kind: Deploy +name: frontend +description: Frontend service container +type: container + +build: frontend +dependencies: + - deploy.backend + +spec: + ports: + - name: http + containerPort: 8080 + ingresses: + - path: / + port: http + hostname: frontend.${providers.ephemeral-kubernetes.outputs.default-hostname} + - path: /call-backend + port: http + hostname: frontend.${providers.ephemeral-kubernetes.outputs.default-hostname} + +--- + +kind: Test +name: frontend-unit +type: container +build: frontend +spec: + args: [npm, test] + +--- + +kind: Test +name: frontend-integ +type: container +build: frontend +dependencies: + - deploy.frontend # <- we want the frontend service to be running and up-to-date for this test +spec: + args: [npm, run, integ] diff --git a/examples/ephemeral-cluster-demo/frontend/main.js b/examples/ephemeral-cluster-demo/frontend/main.js new file mode 100644 index 0000000000..ab66491126 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/main.js @@ -0,0 +1,3 @@ +const { app } = require('./app'); + +app.listen(process.env.PORT, '0.0.0.0', () => console.log('Frontend service started')); diff --git a/examples/ephemeral-cluster-demo/frontend/package.json b/examples/ephemeral-cluster-demo/frontend/package.json new file mode 100644 index 0000000000..7a9f3678c0 --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "frontend", + "version": "1.0.0", + "description": "Simple Node.js docker service", + "main": "main.js", + "scripts": { + "start": "node main.js", + "test": "echo OK", + "integ": "mocha test/integ.js" + }, + "author": "garden.io ", + "license": "ISC", + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "mocha": "^10.2.0", + "supertest": "^6.3.3" + } +} diff --git a/examples/ephemeral-cluster-demo/frontend/test/integ.js b/examples/ephemeral-cluster-demo/frontend/test/integ.js new file mode 100644 index 0000000000..ea1ccd85ef --- /dev/null +++ b/examples/ephemeral-cluster-demo/frontend/test/integ.js @@ -0,0 +1,17 @@ +const supertest = require("supertest") +const { app } = require("../app") + +describe('GET /call-backend', () => { + const agent = supertest.agent(app) + + it('should respond with a message from the backend service', (done) => { + agent + .get("/call-backend") + .expect(200, { message: "Backend says: 'Hello from Go!'" }) + .end((err) => { + if (err) return done(err) + done() + }) + }) +}) + diff --git a/examples/ephemeral-cluster-demo/garden.yml b/examples/ephemeral-cluster-demo/garden.yml new file mode 100644 index 0000000000..49196ca9cc --- /dev/null +++ b/examples/ephemeral-cluster-demo/garden.yml @@ -0,0 +1,11 @@ +apiVersion: garden.io/v1 +kind: Project +name: ephemeral-cluster-demo +environments: + - name: remote +providers: + - name: ephemeral-kubernetes + environments: [remote] +variables: + userId: ${kebabCase(local.username)} + diff --git a/static/kubernetes/system/nginx-ephemeral/garden.yml b/static/kubernetes/system/nginx-ephemeral/garden.yml new file mode 100644 index 0000000000..c272fdea8e --- /dev/null +++ b/static/kubernetes/system/nginx-ephemeral/garden.yml @@ -0,0 +1,41 @@ +kind: Deploy +description: Ingress controller for garden development +name: nginx-ephemeral +type: helm +dependencies: + - deploy.default-backend +spec: + chart: + name: ingress-nginx + repo: https://kubernetes.github.io/ingress-nginx + version: 4.0.13 + releaseName: garden-nginx + atomic: false + values: + name: ingress-controller + controller: + extraArgs: + default-backend-service: ${var.namespace}/default-backend + kind: Deployment + replicaCount: 1 + updateStrategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + minReadySeconds: 1 + tolerations: ${var.system-tolerations} + nodeSelector: ${var.system-node-selector} + admissionWebhooks: + enabled: false + ingressClassResource: + name: nginx + enabled: true + default: true + service: + annotations: + "kubernetes.namespace.so/expose": "true" + "kubernetes.namespace.so/exposed-port-80": "wildcard" + "kubernetes.namespace.so/exposed-port-443": "wildcard" + type: LoadBalancer + defaultBackend: + enabled: false diff --git a/yarn.lock b/yarn.lock index 66c40d0269..1e95d88f5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -482,10 +482,10 @@ dependencies: heap ">= 0.2.0" -"@garden-io/platform-api-types@1.455.0": - version "1.455.0" - resolved "https://registry.yarnpkg.com/@garden-io/platform-api-types/-/platform-api-types-1.455.0.tgz#68530a03fdc62122158fc7a73195c12147792d1c" - integrity sha512-VJ1qgLMswcGEVOWkjXxPJV25HL0JXAfwvhiMrKjFoipbopgo1yzoKqcy3N31WFsbrmY9gDRUQexdIy8WrLNqjg== +"@garden-io/platform-api-types@1.914.0": + version "1.914.0" + resolved "https://registry.yarnpkg.com/@garden-io/platform-api-types/-/platform-api-types-1.914.0.tgz#4ad7641151d500492464d7c7dcfe0da8aa79b1dc" + integrity sha512-/ThhZ5raa5cWjDoNP2v7QRZk7k5Qz4IbgLsf7JEmoE1cVk6l8uvLPgTZMJY+QqA+ZAkPPmF1W8N719UzHqEKxQ== "@google-cloud/kms@^3.7.0": version "3.7.0"