Skip to content

Commit

Permalink
Add console observer webhook
Browse files Browse the repository at this point in the history
This changeset adds a webhook that will attempt to observe console
attach events. This is achieved by watching the pod/attach events via
a validating webhook.

By Ignoring the failure case of this webhook we can ensure that we are
optimistically observing attach events to pods, whilst removing the
possibility of blocking console creation. This is useful when there is
an incident to ensure engineers continue to have access.

Co-authored-by: saadazizgc <saadazizgc@users.noreply.github.com>
  • Loading branch information
jackatbancast and saadazizgc committed Nov 25, 2021
1 parent 55555c5 commit 8ce4bef
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 2 deletions.
132 changes: 132 additions & 0 deletions apis/workloads/v1alpha1/console_attach_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package v1alpha1

import (
"context"
"fmt"
"net/http"
"time"

"github.com/go-logr/logr"
"github.com/gocardless/theatre/v3/pkg/logging"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/record"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

// +kubebuilder:object:generate=false
type ConsoleAttachObserverWebhook struct {
client client.Client
recorder record.EventRecorder
logger logr.Logger
decoder *admission.Decoder
requestTimeout time.Duration
}

func NewConsoleAttachObserverWebhook(c client.Client, recorder record.EventRecorder, logger logr.Logger, requestTimeout time.Duration) *ConsoleAttachObserverWebhook {
return &ConsoleAttachObserverWebhook{
client: c,
recorder: recorder,
logger: logger,
requestTimeout: requestTimeout,
}
}

func (c *ConsoleAttachObserverWebhook) InjectDecoder(d *admission.Decoder) error {
c.decoder = d
return nil
}

func (c *ConsoleAttachObserverWebhook) Handle(ctx context.Context, req admission.Request) admission.Response {
logger := c.logger.WithValues(
"uuid", string(req.UID),
"pod", req.Name,
"namespace", req.Namespace,
"user", req.UserInfo.Username,
)
logger.Info("starting request", "event", "request.start")
defer func(start time.Time) {
logging.WithNoRecord(logger).Info("completed request", "event", "request.end", "duration", time.Now().Sub(start).Seconds())
}(time.Now())

attachOptions := &corev1.PodAttachOptions{}
if err := c.decoder.Decode(req, attachOptions); err != nil {
logger.Error(err, "failed to decode attach options")
return admission.Errored(http.StatusBadRequest, err)
}

rctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()

// Get the associated pod. This will have the same name, and
// exist in the same namespace as the pod attach options we
// receive in the request.
pod := &corev1.Pod{}
if err := c.client.Get(rctx, client.ObjectKey{
Namespace: req.Namespace,
Name: req.Name,
}, pod); err != nil {
logger.Error(err, "failed to get pod")
return admission.Errored(http.StatusBadRequest, err)
}

// Skip the rest of our lookups if we're not in a console. We
// can determine this by looking for a "console-name" in the
// pod labels.
if _, ok := pod.Labels["console-name"]; !ok {
return admission.Allowed("not a console; skipping observation")
}

rctx, cancel = context.WithTimeout(ctx, c.requestTimeout)
defer cancel()

// Get the console associated to publish events
csl := &Console{}
if err := c.client.Get(rctx, client.ObjectKey{
Namespace: req.Namespace,
Name: pod.Labels["console-name"],
}, csl); err != nil {
logger.Error(
err, "failed to get console",
"console", pod.Labels["console-name"],
)
return admission.Errored(http.StatusInternalServerError, err)
}

logger.WithValues(
"pod", pod.Name,
"namespace", pod.Namespace,
"user", req.UserInfo.Username,
"console", csl.Name,
"event", "console.attach",
)

// If performing a dry-run we only want to log the attachment.
if *req.DryRun {
// Log an event observing the attachment
logger.Info(
fmt.Sprintf(
"observed dry-run attach for pod %s/%s by user %s",
pod.Namespace, pod.Name, req.UserInfo.Username,
),
"console", csl.Name,
"dry-run", true,
)
return admission.Allowed("dry-run set; skipping attachment observation")
}

// Attach an event recorder to the logger, based on the
// associated pod
logger = logging.WithEventRecorder(logger, c.recorder, pod)

// Log an event observing the attachment
logger.Info(
fmt.Sprintf(
"observed attach to pod %s/%s by user %s",
pod.Namespace, pod.Name, req.UserInfo.Username,
),
"event", "ConsoleAttach",
)

return admission.Allowed("attachment observed")
}
41 changes: 39 additions & 2 deletions cmd/workloads-manager/acceptance/acceptance.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
rbacv1 "k8s.io/api/rbac/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
Expand Down Expand Up @@ -310,6 +311,7 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) {
})
Expect(err).NotTo(HaveOccurred(), "could not authorise console")

var pod *corev1.Pod
By("Expect a pod has been created")
Eventually(func() ([]corev1.Pod, error) {
selectorSet, err := labels.ConvertSelectorToLabelsMap("console-name=" + console.Name)
Expand All @@ -320,6 +322,11 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) {
podList := &corev1.PodList{}
kubeClient.List(context.TODO(), podList, opts)

// Save a reference to the pod for later
if len(podList.Items) == 1 {
pod = &podList.Items[0]
}

return podList.Items, err
}).Should(HaveLen(1), "expected to find a single pod")

Expand All @@ -330,15 +337,45 @@ func (r *Runner) Run(logger kitlog.Logger, config *rest.Config) {
return console.Status.Phase
}).Should(Equal(workloadsv1alpha1.ConsoleRunning))

By("Attach to the console")
go consoleRunner.Attach(context.TODO(), runner.AttachOptions{
Namespace: console.Namespace,
KubeConfig: config,
Name: console.Name,
IO: runner.IOStreams{
In: nil,
Out: nil,
ErrOut: nil,
},
Hook: nil,
})

By("Expect that the attachment is observed")
Eventually(func() []corev1.Event {
events := &corev1.EventList{}
err := kubeClient.List(
context.TODO(), events,
client.InNamespace(namespace),
client.MatchingFieldsSelector{
Selector: fields.AndSelectors(
fields.OneTermEqualSelector("reason", "ConsoleAttach"),
fields.OneTermEqualSelector("involvedObject.name", pod.Name),
fields.OneTermEqualSelector("involvedObject.uid", string(pod.UID)),
),
},
)
Expect(err).ToNot(HaveOccurred())

return events.Items
}).Should(HaveLen(1))

By("Expect the console phase eventually changes to Stopped")
Eventually(func() workloadsv1alpha1.ConsolePhase {
err = kubeClient.Get(context.TODO(), client.ObjectKey{Namespace: namespace, Name: console.Name}, console)
Expect(err).NotTo(HaveOccurred(), "could not find console")
return console.Status.Phase
}).Should(Equal(workloadsv1alpha1.ConsoleStopped))

// TODO: attach to pod

By("Expect that the console is deleted shortly after stopping, due to its TTL")
Eventually(func() error {
err = kubeClient.Get(context.TODO(), client.ObjectKey{Namespace: namespace, Name: console.Name}, console)
Expand Down
11 changes: 11 additions & 0 deletions cmd/workloads-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"

"github.com/alecthomas/kingpin"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -89,6 +90,16 @@ func main() {
),
})

// console attach webhook
mgr.GetWebhookServer().Register("/observe-console-attach", &admission.Webhook{
Handler: workloadsv1alpha1.NewConsoleAttachObserverWebhook(
mgr.GetClient(),
mgr.GetEventRecorderFor("console-attach-observer"),
logger.WithName("webhooks").WithName("console-attach-observer"),
10*time.Second,
),
})

if err := mgr.Start(ctx.Done()); err != nil {
app.Fatalf("failed to run manager: %v", err)
}
Expand Down
25 changes: 25 additions & 0 deletions config/base/webhooks/workloads.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,28 @@ webhooks:
- consoletemplates
scope: '*'
sideEffects: None
- admissionReviewVersions: ["v1beta1"] # need to upgrade out webhook to support v1
clientConfig:
caBundle: Cg==
service:
name: theatre-workloads-manager
namespace: theatre-system
path: /observe-console-attach
port: 443
name: console-attach-observer.workloads.crd.gocardless.com
namespaceSelector:
matchExpressions:
- key: control-plane
operator: DoesNotExist
rules:
- apiGroups:
- ''
apiVersions:
- v1
operations:
- CONNECT
resources:
- pods/attach
scope: '*'
sideEffects: NoneOnDryRun
failurePolicy: Ignore # Ignore failures as we want to record attachment, but not at the cost of blocking connections

0 comments on commit 8ce4bef

Please sign in to comment.