From 10f2a27d719d1f6d7370c73a65d11138ebc6f57d Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Tue, 21 Apr 2020 10:22:06 -0700 Subject: [PATCH] Implement undeploy command (#385) This is new command that can be used to delete everything running in a cluster related to an application using the labels we use. This currently uses a hardcoded set of resource types. It would be possible to make this generic, which might become needed in the future. See comments in the code. --- The overall design for how to use the command: ```sh tye undeploy ``` Will read a solution or tye.yaml to get the application name. It then enumerates and deletes all resources in the cluster with `app.kubernetes.io/part-of=application-name` (in the current namespace). ```sh tye undeploy --what-if ``` Will do the same, but without actually doing the deletion. ```sh tye undeploy -i ``` Will interactively give you a yes/no choice for whether to delete each resource. --- docs/reference/commandline/tye-deploy.md | 2 + docs/reference/commandline/tye-undeploy.md | 70 ++++++ docs/reference/commandline/tye.md | 7 +- src/Microsoft.Tye.Core/ValidateSecretStep.cs | 4 + src/tye/Program.UndeployCommand.cs | 45 ++++ src/tye/Program.cs | 1 + src/tye/UndeployHost.cs | 220 +++++++++++++++++++ 7 files changed, 346 insertions(+), 3 deletions(-) create mode 100644 docs/reference/commandline/tye-undeploy.md create mode 100644 src/tye/Program.UndeployCommand.cs create mode 100644 src/tye/UndeployHost.cs diff --git a/docs/reference/commandline/tye-deploy.md b/docs/reference/commandline/tye-deploy.md index 1d0810573..276c4ad42 100644 --- a/docs/reference/commandline/tye-deploy.md +++ b/docs/reference/commandline/tye-deploy.md @@ -19,6 +19,8 @@ The `tye deploy` command will deploy an application to Kubernetes. `tye deploy` - Generate a Kubernetes Deployment and Service for each project. - Apply the generated Deployment and Service to your current Kubernetes context. +`tye deploy` operates in the current Kubernetes namespace. Use `kubectl config view --minify --output 'jsonpath={..namespace}'` to view the current namespace. + ## Arguments `PATH` diff --git a/docs/reference/commandline/tye-undeploy.md b/docs/reference/commandline/tye-undeploy.md new file mode 100644 index 000000000..317689b32 --- /dev/null +++ b/docs/reference/commandline/tye-undeploy.md @@ -0,0 +1,70 @@ +# tye undeploy + +## Name + +`tye undeploy` - Removes a deployed application from Kubernetes. + +## Synopsis + +```text +tye undeploy [-?|-h|--help] [-i|--interactive] [-v|--verbosity ] [--what-if] [] +``` + +## Description + +The `tye undeploy` command will delete a deployed application from Kubernetes. `tye undeploy` by default will: + +- List all resources that are part of an application +- Print the list of resources (what-if) +- Offer a choice to delete each resource (interactive) +- Delete each resource (if applicable) + +`tye undeploy` operates in the current Kubernetes namespace. Use `kubectl config view --minify --output 'jsonpath={..namespace}'` to view the current namespace. + +Undeploy decides which resources to delete based on the `app.kubernetes.io/part-of=...` label. This label will be set to the application name for all resources created by Tye. `tye undeploy` does not rely on the list of services in `tye.yaml` or a solution file. + +## Arguments + +`PATH` + +The path to either a file or directory to execute `tye undeploy` on. Can either be a yaml, sln, or project file, however it is recommend to have a tye.yaml file for `tye undeploy`. + +If a directory path is specified, `tye undeploy` will default to using these files, in the following order: + +- `tye.yaml` +- `*.sln` +- `*.csproj/*.fsproj` + +## Options + +- `-i|--interactive` + + Does an interactive undeploy that will prompt for deletion of each resource. + +- `-v|--verbosity ` + + The verbosity of logs emitted by `tye undeploy`. Defaults to Info. + +- `--what-if` + + Print each resource instead of deleting. + +## Examples + +- Delete a deployed application from the current directory: + + ```text + tye undeploy + ``` + +- Delete a deployed application with interactive input: + + ```text + tye undeploy --interactive + ``` + +- Display the resources that would be deleted by an undeploy operation: + + ```text + tye undeploy --what-if + ``` diff --git a/docs/reference/commandline/tye.md b/docs/reference/commandline/tye.md index 19a6deeb5..c8c859e56 100644 --- a/docs/reference/commandline/tye.md +++ b/docs/reference/commandline/tye.md @@ -29,6 +29,7 @@ For example, [`tye run`](tye-run.md) runs an application. Each command defines i | Command | Function | | --------------------------------------------- | ------------------------------------------------------------------- | -| [tye init](tye-init.md) | Scaffolds a tye.yaml file representing the application. | -| [tye run](tye-run.md) | Runs an application. | -| [tye deploy](tye-deploy.md) | Deploys an application. | +| [tye init](tye-init.md) | Scaffolds a tye.yaml file representing the application. | +| [tye run](tye-run.md) | Runs an application. | +| [tye deploy](tye-deploy.md) | Deploys an application. | +| [tye undeploy](tye-undeploy.md) | Deletes a deployed application. | diff --git a/src/Microsoft.Tye.Core/ValidateSecretStep.cs b/src/Microsoft.Tye.Core/ValidateSecretStep.cs index c2c0399f2..c16c162ef 100644 --- a/src/Microsoft.Tye.Core/ValidateSecretStep.cs +++ b/src/Microsoft.Tye.Core/ValidateSecretStep.cs @@ -161,6 +161,10 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder secret.Metadata = new V1ObjectMeta(); secret.Metadata.Name = secretInputBinding.Name; + secret.Metadata.Labels = new Dictionary() + { + ["app.kubernetes.io/part-of"] = application.Name, + }; output.WriteDebugLine($"Creating secret '{secret.Metadata.Name}'."); diff --git a/src/tye/Program.UndeployCommand.cs b/src/tye/Program.UndeployCommand.cs new file mode 100644 index 000000000..6708ed4a3 --- /dev/null +++ b/src/tye/Program.UndeployCommand.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Tye.ConfigModel; + +namespace Microsoft.Tye +{ + static partial class Program + { + public static Command CreateUndeployCommand() + { + var command = new Command("undeploy", "delete deployed application") + { + CommonArguments.Path_Required, + StandardOptions.Interactive, + StandardOptions.Verbosity, + + new Option(new[]{ "--what-if", }, "print what would be deleted without making changes") + { + Argument = new Argument(), + }, + }; + + command.Handler = CommandHandler.Create((console, path, verbosity, interactive, whatIf) => + { + // Workaround for https://github.com/dotnet/command-line-api/issues/723#issuecomment-593062654 + if (path is null) + { + throw new CommandException("No project or solution file was found."); + } + + return UndeployHost.UndeployAsync(console, path, verbosity, interactive, whatIf); + }); + + return command; + } + } +} diff --git a/src/tye/Program.cs b/src/tye/Program.cs index 77e7d6784..13f40fd06 100644 --- a/src/tye/Program.cs +++ b/src/tye/Program.cs @@ -29,6 +29,7 @@ public static Task Main(string[] args) command.AddCommand(CreateBuildCommand()); command.AddCommand(CreatePushCommand()); command.AddCommand(CreateDeployCommand()); + command.AddCommand(CreateUndeployCommand()); // Show commandline help unless a subcommand was used. command.Handler = CommandHandler.Create(help => diff --git a/src/tye/UndeployHost.cs b/src/tye/UndeployHost.cs new file mode 100644 index 000000000..8e340c15e --- /dev/null +++ b/src/tye/UndeployHost.cs @@ -0,0 +1,220 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using k8s; +using k8s.Models; +using Microsoft.Rest; +using Microsoft.Tye.ConfigModel; +using Newtonsoft.Json; + +namespace Microsoft.Tye +{ + public static class UndeployHost + { + public static async Task UndeployAsync(IConsole console, FileInfo path, Verbosity verbosity, bool interactive, bool whatIf) + { + var output = new OutputContext(console, verbosity); + + output.WriteInfoLine("Loading Application Details..."); + + // We don't need to know anything about the services, just the application name. + var application = ConfigFactory.FromFile(path); + + await ExecuteUndeployAsync(output, application, interactive, whatIf); + } + + public static async Task ExecuteUndeployAsync(OutputContext output, ConfigApplication application, bool interactive, bool whatIf) + { + var config = KubernetesClientConfiguration.BuildDefaultConfig(); + + // Workaround for https://github.com/kubernetes-client/csharp/issues/372 + var store = await KubernetesClientConfiguration.LoadKubeConfigAsync(); + var context = store.Contexts.Where(c => c.Name == config.CurrentContext).FirstOrDefault(); + config.Namespace ??= context?.ContextDetails?.Namespace; + + var kubernetes = new Kubernetes(config); + + // Due to some limitations in the k8s SDK we currently have a hardcoded list of resource + // types that we handle deletes for. If we start adding extensibility for the *kinds* of + // k8s resources we create, or the ability to deploy additional files along with the + // resources we understand then we should revisit this. + // + // Basically the challenges are: + // + // - kubectl api-resources --all (and similar) are implemented client-side (n+1 problem) + // - the C# k8s SDK doesn't have an untyped api for operations on arbitrary resources, the + // closest thing is the custom resource APIs + // - Legacy resources without an api group don't follow the same URL scheme as more modern + // ones, and thus cannot be addressed using the custom resource APIs. + // + // So solving 'undeploy' generically would involve doing a bunch of work to query things + // generically, including going outside of what's provided by the SDK. + // + // - querying api-resources + // - querying api-groups + // - handcrafing requests to list for each resource + // - handcrafting requests to delete each resource + var resources = new List(); + + try + { + output.WriteDebugLine("Querying services"); + var response = await kubernetes.ListNamespacedServiceWithHttpMessagesAsync( + config.Namespace, + labelSelector: $"app.kubernetes.io/part-of={application.Name}"); + + foreach (var resource in response.Body.Items) + { + resource.Kind = V1Service.KubeKind; + } + + resources.AddRange(response.Body.Items.Select(item => new Resource(item, item.Metadata, DeleteService))); + output.WriteDebugLine($"Found {response.Body.Items.Count} matching services"); + } + catch (Exception ex) + { + output.WriteDebugLine("Failed to query services."); + output.WriteDebugLine(ex.ToString()); + throw new CommandException("Unable connect to kubernetes.", ex); + } + + try + { + output.WriteDebugLine("Querying deployments"); + var response = await kubernetes.ListNamespacedDeploymentWithHttpMessagesAsync( + config.Namespace, + labelSelector: $"app.kubernetes.io/part-of={application.Name}"); + + foreach (var resource in response.Body.Items) + { + resource.Kind = V1Deployment.KubeKind; + } + + resources.AddRange(response.Body.Items.Select(item => new Resource(item, item.Metadata, DeleteDeployment))); + output.WriteDebugLine($"Found {response.Body.Items.Count} matching deployments"); + } + catch (Exception ex) + { + output.WriteDebugLine("Failed to query deployments."); + output.WriteDebugLine(ex.ToString()); + throw new CommandException("Unable connect to kubernetes.", ex); + } + + try + { + output.WriteDebugLine("Querying secrets"); + var response = await kubernetes.ListNamespacedSecretWithHttpMessagesAsync( + config.Namespace, + labelSelector: $"app.kubernetes.io/part-of={application.Name}"); + + foreach (var resource in response.Body.Items) + { + resource.Kind = V1Secret.KubeKind; + } + + resources.AddRange(response.Body.Items.Select(item => new Resource(item, item.Metadata, DeleteSecret))); + output.WriteDebugLine($"Found {response.Body.Items.Count} matching secrets"); + + } + catch (Exception ex) + { + output.WriteDebugLine("Failed to query secrets."); + output.WriteDebugLine(ex.ToString()); + throw new CommandException("Unable connect to kubernetes.", ex); + } + + output.WriteInfoLine($"Found {resources.Count} resource(s)."); + + var exceptions = new List<(Resource resource, HttpOperationException exception)>(); + foreach (var resource in resources) + { + var operation = Operations.Delete; + if (interactive && !output.Confirm($"Delete {resource.Obj.Kind} '{resource.Metadata.Name}'?")) + { + operation = Operations.None; + } + + if (whatIf && operation == Operations.Delete) + { + operation = Operations.Explain; + } + + if (operation == Operations.None) + { + output.WriteAlwaysLine($"Skipping '{resource.Obj.Kind}' '{resource.Metadata.Name}' ..."); + } + else if (operation == Operations.Explain) + { + output.WriteAlwaysLine($"whatif: Deleting '{resource.Obj.Kind}' '{resource.Metadata.Name}' ..."); + } + else if (operation == Operations.Delete) + { + output.WriteAlwaysLine($"Deleting '{resource.Obj.Kind}' '{resource.Metadata.Name}' ..."); + + try + { + var response = await resource.Deleter(resource.Metadata.Name); + + output.WriteDebugLine($"Successfully deleted resource: '{resource.Obj.Kind}' '{resource.Metadata.Name}'."); + } + catch (HttpOperationException ex) + { + output.WriteDebugLine($"Failed to delete resource: '{resource.Obj.Kind}' '{resource.Metadata.Name}'."); + output.WriteDebugLine(ex.ToString()); + exceptions.Add((resource, ex)); + } + } + } + + if (exceptions.Count > 0) + { + throw new CommandException( + $"Failed to delete some resources: " + Environment.NewLine + Environment.NewLine + + string.Join(Environment.NewLine, exceptions.Select(e => $"\t'{e.resource.Obj.Kind}' '{e.resource.Metadata.Name}': {e.exception.Body}."))); + } + + Task> DeleteService(string name) + { + return kubernetes!.DeleteNamespacedServiceWithHttpMessagesAsync(name, config!.Namespace); + } + + Task> DeleteDeployment(string name) + { + return kubernetes!.DeleteNamespacedDeploymentWithHttpMessagesAsync(name, config!.Namespace); + } + + Task> DeleteSecret(string name) + { + return kubernetes!.DeleteNamespacedSecretWithHttpMessagesAsync(name, config!.Namespace); + } + } + + private enum Operations + { + None, + Delete, + Explain, + } + + private readonly struct Resource + { + public readonly IKubernetesObject Obj; + public readonly V1ObjectMeta Metadata; + public readonly Func>> Deleter; + + public Resource(IKubernetesObject obj, V1ObjectMeta metadata, Func>> deleter) + { + Obj = obj; + Metadata = metadata; + Deleter = deleter; + } + } + } +}