Skip to content

Commit

Permalink
Implement undeploy command (dotnet#385)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
rynowak authored and kishanAnem committed May 15, 2020
1 parent 2b515c3 commit 10f2a27
Show file tree
Hide file tree
Showing 7 changed files with 346 additions and 3 deletions.
2 changes: 2 additions & 0 deletions docs/reference/commandline/tye-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
70 changes: 70 additions & 0 deletions docs/reference/commandline/tye-undeploy.md
Original file line number Diff line number Diff line change
@@ -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 <Debug|Info|Quiet>] [--what-if] [<PATH>]
```

## 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 <Debug|Info|Quiet>`

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
```
7 changes: 4 additions & 3 deletions docs/reference/commandline/tye.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
4 changes: 4 additions & 0 deletions src/Microsoft.Tye.Core/ValidateSecretStep.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()
{
["app.kubernetes.io/part-of"] = application.Name,
};

output.WriteDebugLine($"Creating secret '{secret.Metadata.Name}'.");

Expand Down
45 changes: 45 additions & 0 deletions src/tye/Program.UndeployCommand.cs
Original file line number Diff line number Diff line change
@@ -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<bool>(),
},
};

command.Handler = CommandHandler.Create<IConsole, FileInfo, Verbosity, bool, bool>((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;
}
}
}
1 change: 1 addition & 0 deletions src/tye/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public static Task<int> 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<IHelpBuilder>(help =>
Expand Down
220 changes: 220 additions & 0 deletions src/tye/UndeployHost.cs
Original file line number Diff line number Diff line change
@@ -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<Resource>();

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<Rest.HttpOperationResponse<V1Status>> DeleteService(string name)
{
return kubernetes!.DeleteNamespacedServiceWithHttpMessagesAsync(name, config!.Namespace);
}

Task<Rest.HttpOperationResponse<V1Status>> DeleteDeployment(string name)
{
return kubernetes!.DeleteNamespacedDeploymentWithHttpMessagesAsync(name, config!.Namespace);
}

Task<Rest.HttpOperationResponse<V1Status>> 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<string, Task<Rest.HttpOperationResponse<V1Status>>> Deleter;

public Resource(IKubernetesObject obj, V1ObjectMeta metadata, Func<string, Task<Rest.HttpOperationResponse<V1Status>>> deleter)
{
Obj = obj;
Metadata = metadata;
Deleter = deleter;
}
}
}
}

0 comments on commit 10f2a27

Please sign in to comment.