Skip to content

Instantly share code, notes, and snippets.

@lawrencejones
Created October 5, 2020 15:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lawrencejones/6d4e3119c56101614cc97f2e79abbecf to your computer and use it in GitHub Desktop.
Save lawrencejones/6d4e3119c56101614cc97f2e79abbecf to your computer and use it in GitHub Desktop.
Theatre consoles wrapper
package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"strings"
"text/template"
"time"
"github.com/Masterminds/sprig"
"github.com/gocardless/anu/utopia/pkg/registry"
kitlog "github.com/go-kit/kit/log"
workloadsv1alpha1 "github.com/gocardless/theatre/v2/apis/workloads/v1alpha1"
"github.com/gocardless/theatre/v2/pkg/workloads/console/runner"
"github.com/manifoldco/promptui"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
var (
consoles = app.Command("consoles", consolesUsage)
consolesService = new(RegistryOptions).Bind(consoles, "registry-", true).BindService(consoles, "")
consolesCreate = consoles.Command("create", "Create a console")
consolesCreateTemplate = consolesCreate.
Flag("template", "Optional specific template, ie. readonly, or readwrite").String()
consolesCreateTimeout = consolesCreate.
Flag("timeout", "Optional timeout for console, overriding default").Default("0").Duration()
consolesCreateReason = consolesCreate.
Flag("reason", "Reason for opening console").Required().String()
consolesCreateAttach = consolesCreate.
Flag("attach", "Attach to the console if it starts successfully").Default("true").Bool()
consolesCreateNoninteractive = consolesCreate.
Flag("noninteractive", "Do not enable TTY and STDIN on console container").Bool()
consolesCreateCommand = consolesCreate.
Arg("command", "Command to run in console").Strings()
consolesAttach = consoles.Command("attach", "Attach to an existing console")
consolesAttachName = consolesAttach.Flag("name", "Name of the console to attach to").Required().String()
consolesList = consoles.Command("list", "List currently running consoles")
consolesListMine = consolesList.Flag("mine", "Filter consoles to mine only (using --user)").Default("false").Bool()
consolesListIdentity = new(IdentityOptions).Bind(consolesList, "")
consolesAuthorise = consoles.Command("authorise", "Authorise a console which requires review")
consolesAuthoriseName = consolesAuthorise.Flag("name", "Name of console to authorise").Required().String()
consolesAuthoriseIdentity = new(IdentityOptions).Bind(consolesAuthorise, "")
)
const consolesUsage = `Manage consoles inside Kubernetes
Create and administrate consoles inside GoCardless Kubernetes clusters.
As with all utopia commands, the --service flag can be omitted when
running within the Git repository of the service.
Examples:
# Open bash in a payments-service console
utopia consoles create \
--service payments-service \
--environment live-staging \
--reason "PT-0001: Debugging a timeout" \
--template default \
-- \
bash
msg="console has been requested" console=paysvc-live-default-hsmdl namespace=staging
msg="console is ready" console=paysvc-live-default-hsmdl namespace=staging pod=paysvc-live-default-hsmdl-console-zbkz8
app@paysvc-live-default-hsmdl-console-zbkz8:~$ echo hello
hello
# Attach to a console that has already been created
# Press ctrl-p, then ctrl-q to detach
utopia consoles attach \
--service payments-service \
--environment live-staging \
--name paysvc-live-default-hsmdl
msg="attaching to pod" console=paysvc-live-default-hsmdl namespace=staging pod=paysvc-live-default-hsmdl-console-zbkz8
app@paysvc-live-default-hsmdl-console-zbkz8:~$ echo again
again
`
// kubernetesConfigFor builds Kubernetes configuration for a specific deployment target.
// If we can find a Kubernetes config that matches our target context name, then we return
// that. If we can't find one, and we're inside a Kubernetes cluster, we try
// authenticating with in-cluster credentials.
//
// If that fails, we return the original error from trying to find the specific context,
// as the caller might never have intended for us to try the in-cluster approach.
func kubernetesConfigFor(target registry.Target) (*rest.Config, error) {
config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
clientcmd.NewDefaultClientConfigLoadingRules(),
&clientcmd.ConfigOverrides{
CurrentContext: target.Spec.Context,
},
).ClientConfig()
if _, ok := os.LookupEnv("KUBERNETES_SERVICE_HOST"); ok {
if config, err := rest.InClusterConfig(); err == nil {
return config, nil
}
}
return config, err
}
func consolesRun(ctx context.Context, command string) error {
_, service, environment, target, err := consolesService.GetUniqueTarget(ctx)
if err != nil {
return err
}
// Alias target fields to make constructing the command structs a bit easier
namespace := target.Spec.Namespace
selector := fmt.Sprintf("release=%s", target.Spec.Release)
cfg, err := kubernetesConfigFor(*target)
if err != nil {
return err
}
consoleRunner, err := runner.New(cfg)
if err != nil {
return err
}
// Configurable printer, so we can log appropriate messages whenever console events
// happen
printer := LifecyclePrinter(logger, *service, *environment)
switch command {
case consolesCreate.FullCommand():
// If we specify a template, we want to append it to our selector
if *consolesCreateTemplate != "" {
selector = fmt.Sprintf("%s,template=%s", selector, *consolesCreateTemplate)
}
_, err := consoleRunner.Create(
ctx,
runner.CreateOptions{
Namespace: namespace,
Selector: selector,
KubeConfig: cfg,
Hook: printer,
Timeout: *consolesCreateTimeout,
Reason: *consolesCreateReason,
Command: *consolesCreateCommand,
Attach: *consolesCreateAttach,
Noninteractive: *consolesCreateNoninteractive,
IO: runner.IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
},
},
)
// Try harder to format errors about multiple console templates, as this is a normal
// thing to error on and might be a source of user frustration.
//
// We don't assume we're looking for the default template here: that means we'll
// automatically work for any service with just one template, but the corollary is we
// are broken without the --template flag for any service that defines many.
//
// This seems fine for now.
if err, ok := err.(runner.MultipleConsoleTemplateError); ok {
errMsg := "Found multiple ConsoleTemplates for this service"
if *consolesCreateTemplate != "" {
errMsg = fmt.Sprintf("%s with label template=%s", errMsg, *consolesCreateTemplate)
}
var identifiers []string
for _, tpl := range err.ConsoleTemplates {
identifiers = append(identifiers,
fmt.Sprintf(`%s/%s{template="%s"}`,
tpl.Namespace,
tpl.Name,
tpl.Labels["template"]))
}
errMsg = fmt.Sprintf("%s:\n - %s\n", errMsg, strings.Join(identifiers, "\n - "))
errMsg = fmt.Sprintf("%s\nUse the --template flag to choose a specific template.\n\n", errMsg)
fmt.Fprintf(os.Stderr, errMsg)
return fmt.Errorf("failed to find specific console template")
}
return err
case consolesAttach.FullCommand():
return consoleRunner.Attach(
ctx,
runner.AttachOptions{
Namespace: namespace,
KubeConfig: cfg,
Hook: printer,
Name: *consolesAttachName,
IO: runner.IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
},
},
)
case consolesList.FullCommand():
var user string
// Only set a user if we've specified --mine, otherwise we want to see everyone's
// consoles.
if *consolesListMine {
var err error
user, err = consolesListIdentity.GetUser(ctx)
if err != nil {
return err
}
}
_, err = consoleRunner.List(
ctx,
runner.ListOptions{
Namespace: namespace,
Selector: selector,
Username: user,
Output: os.Stdout,
},
)
return err
case consolesAuthorise.FullCommand():
csl, err := consoleRunner.Get(ctx, runner.GetOptions{
Namespace: namespace,
ConsoleName: *consolesAuthoriseName,
})
if err != nil {
return err
}
confirmation, err := confirmConsoleAuthorisation(csl, *service, *environment, *target)
if err != nil {
return err
}
if !confirmation {
logger.Log("msg", "declined to authorise console")
return nil
}
user, err := consolesAuthoriseIdentity.GetUser(ctx)
if err != nil {
return err
}
return consoleRunner.Authorise(
ctx,
runner.AuthoriseOptions{
Namespace: namespace,
ConsoleName: *consolesAuthoriseName,
Username: user,
},
)
}
panic("unrecognised command")
}
// LifecyclePrinter hooks into console lifecycle events, reporting on the change of
// console phases during creation or attaching
func LifecyclePrinter(logger kitlog.Logger, service registry.Service, environment registry.Environment) runner.LifecycleHook {
return runner.DefaultLifecycleHook{
AttachingToPodFunc: func(csl *workloadsv1alpha1.Console) error {
logger.Log("msg", "attaching to pod",
"console", csl.Name, "namespace", csl.Namespace, "pod", csl.Status.PodName)
return nil
},
ConsoleRequiresAuthorisationFunc: func(csl *workloadsv1alpha1.Console, rule *workloadsv1alpha1.ConsoleAuthorisationRule) error {
approverSlice := make([]string, 0, len(rule.ConsoleAuthorisers.Subjects))
for _, approver := range rule.ConsoleAuthorisers.Subjects {
approverSlice = append(approverSlice, fmt.Sprintf(" - %s (%s)", approver.Name, approver.Kind))
}
approvers := strings.Join(approverSlice, "\n")
fmt.Printf(`
This console is currently pending authorisation (rule: %s).
An authorisation is required from %d members of:
%s
Send this command to an authoriser:
utopia consoles authorise --service %s --environment %s --name %s
Waiting for authorisation...
`,
rule.Name,
rule.AuthorisationsRequired,
approvers,
service.Spec.Name,
environment.Spec.Name,
csl.Name,
)
return nil
},
ConsoleReadyFunc: func(csl *workloadsv1alpha1.Console) error {
logger.Log("msg", "console is ready",
"console", csl.Name, "namespace", csl.Namespace, "pod", csl.Status.PodName)
return nil
},
ConsoleCreatedFunc: func(csl *workloadsv1alpha1.Console) error {
logger.Log("msg", "console has been requested",
"console", csl.Name, "namespace", csl.Namespace)
return nil
},
}
}
var confirmConsoleAuthorisationTemplate = template.Must(
template.New("console-authorisation").Funcs(sprig.TxtFuncMap()).Parse(`
Console details:
User: {{ .Console.Spec.User }}
Template: {{ .Template }}
Service: {{ .Service.Spec.Name }}
Environment: {{ .Environment.Spec.Name }}
Context: {{ .Target.Spec.Context }}
Namespace: {{ .Target.Spec.Namespace }}
Repo: {{ .Service.Spec.Repository }}
Command: {{ .Command }}
Reason: {{ .Console.Spec.Reason }}
Time: {{ .Console.ObjectMeta.CreationTimestamp }} ({{ .Age }} ago)
`))
func confirmConsoleAuthorisation(csl *workloadsv1alpha1.Console,
service registry.Service, environment registry.Environment, target registry.Target,
) (bool, error) {
command, err := json.Marshal(csl.Spec.Command)
if err != nil {
return false, fmt.Errorf("failed to render console command: %w", err)
}
data := struct {
Console workloadsv1alpha1.Console
Template string
Service registry.Service
Environment registry.Environment
Target registry.Target
Command string
Age time.Duration
}{
Console: *csl,
Template: csl.ObjectMeta.Labels["template"],
Service: service,
Environment: environment,
Target: target,
Command: string(command),
Age: time.Now().Sub(csl.ObjectMeta.CreationTimestamp.UTC()),
}
var buffer bytes.Buffer
if err := confirmConsoleAuthorisationTemplate.Execute(&buffer, data); err != nil {
return false, err
}
fmt.Println(buffer.String())
prompt := promptui.Select{
Label: "Authorise Console? [yes/no]",
Items: []string{"no", "yes"},
}
_, result, err := prompt.Run()
if err != nil {
return false, fmt.Errorf("prompt failed %w", err)
}
return result == "yes", nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment