github.com/grahambrereton-form3/tilt@v0.10.18/internal/k8s/client.go (about)

     1  package k8s
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"net/url"
     8  	"regexp"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/opentracing/opentracing-go"
    13  	"github.com/pkg/browser"
    14  	"github.com/pkg/errors"
    15  	v1 "k8s.io/api/core/v1"
    16  	apierrors "k8s.io/apimachinery/pkg/api/errors"
    17  	"k8s.io/apimachinery/pkg/api/meta"
    18  	"k8s.io/apimachinery/pkg/api/validation"
    19  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    20  	"k8s.io/apimachinery/pkg/labels"
    21  	"k8s.io/apimachinery/pkg/runtime/schema"
    22  	"k8s.io/apimachinery/pkg/version"
    23  	"k8s.io/apimachinery/pkg/watch"
    24  	"k8s.io/client-go/discovery"
    25  	"k8s.io/client-go/dynamic"
    26  	"k8s.io/client-go/kubernetes"
    27  	apiv1 "k8s.io/client-go/kubernetes/typed/core/v1"
    28  	"k8s.io/client-go/rest"
    29  	"k8s.io/client-go/restmapper"
    30  	"k8s.io/client-go/tools/clientcmd"
    31  
    32  	// Client auth plugins! They will auto-init if we import them.
    33  	_ "k8s.io/client-go/plugin/pkg/client/auth"
    34  
    35  	"github.com/windmilleng/tilt/internal/container"
    36  	"github.com/windmilleng/tilt/pkg/logger"
    37  )
    38  
    39  type Namespace string
    40  type PodID string
    41  type NodeID string
    42  type ServiceName string
    43  type KubeContext string
    44  
    45  const DefaultNamespace = Namespace("default")
    46  
    47  var ForbiddenFieldsRe = regexp.MustCompile(`updates to .* are forbidden`)
    48  
    49  func (pID PodID) Empty() bool    { return pID.String() == "" }
    50  func (pID PodID) String() string { return string(pID) }
    51  
    52  func (nID NodeID) String() string { return string(nID) }
    53  
    54  func (n Namespace) Empty() bool { return n == "" }
    55  
    56  func (n Namespace) String() string {
    57  	if n == "" {
    58  		return string(DefaultNamespace)
    59  	}
    60  	return string(n)
    61  }
    62  
    63  type Client interface {
    64  	// Updates the entities, creating them if necessary.
    65  	//
    66  	// Tries to update them in-place if possible. But for certain resource types,
    67  	// we might need to fallback to deleting and re-creating them.
    68  	//
    69  	// Returns entities in the order that they were applied (which may be different
    70  	// than they were passed in) and with UUIDs from the Kube API
    71  	Upsert(ctx context.Context, entities []K8sEntity) ([]K8sEntity, error)
    72  
    73  	// Deletes all given entities.
    74  	//
    75  	// Currently ignores any "not found" errors, because that seems like the correct
    76  	// behavior for our use cases.
    77  	Delete(ctx context.Context, entities []K8sEntity) error
    78  
    79  	GetByReference(ctx context.Context, ref v1.ObjectReference) (K8sEntity, error)
    80  
    81  	PodByID(ctx context.Context, podID PodID, n Namespace) (*v1.Pod, error)
    82  
    83  	// Creates a channel where all changes to the pod are brodcast.
    84  	// Takes a pod as input, to indicate the version of the pod where we start watching.
    85  	WatchPod(ctx context.Context, pod *v1.Pod) (watch.Interface, error)
    86  
    87  	// Streams the container logs
    88  	ContainerLogs(ctx context.Context, podID PodID, cName container.Name, n Namespace, startTime time.Time) (io.ReadCloser, error)
    89  
    90  	// Opens a tunnel to the specified pod+port. Returns the tunnel's local port and a function that closes the tunnel
    91  	CreatePortForwarder(ctx context.Context, namespace Namespace, podID PodID, optionalLocalPort, remotePort int, host string) (PortForwarder, error)
    92  
    93  	WatchPods(ctx context.Context, lps labels.Selector) (<-chan *v1.Pod, error)
    94  
    95  	WatchServices(ctx context.Context, lps labels.Selector) (<-chan *v1.Service, error)
    96  
    97  	WatchEvents(ctx context.Context) (<-chan *v1.Event, error)
    98  
    99  	ConnectedToCluster(ctx context.Context) error
   100  
   101  	ContainerRuntime(ctx context.Context) container.Runtime
   102  
   103  	// Some clusters support a private image registry that we can push to.
   104  	PrivateRegistry(ctx context.Context) container.Registry
   105  
   106  	Exec(ctx context.Context, podID PodID, cName container.Name, n Namespace, cmd []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
   107  }
   108  
   109  type K8sClient struct {
   110  	env               Env
   111  	kubectlRunner     kubectlRunner
   112  	core              apiv1.CoreV1Interface
   113  	restConfig        *rest.Config
   114  	portForwardClient PortForwardClient
   115  	configNamespace   Namespace
   116  	clientset         kubernetes.Interface
   117  	dynamic           dynamic.Interface
   118  	runtimeAsync      *runtimeAsync
   119  	registryAsync     *registryAsync
   120  	drm               meta.RESTMapper
   121  }
   122  
   123  var _ Client = K8sClient{}
   124  
   125  func ProvideK8sClient(
   126  	ctx context.Context,
   127  	env Env,
   128  	maybeRESTConfig RESTConfigOrError,
   129  	maybeClientset ClientsetOrError,
   130  	pfClient PortForwardClient,
   131  	configNamespace Namespace,
   132  	runner kubectlRunner,
   133  	clientLoader clientcmd.ClientConfig) Client {
   134  	if env == EnvNone {
   135  		// No k8s, so no need to get any further configs
   136  		return &explodingClient{err: fmt.Errorf("Kubernetes context not set")}
   137  	}
   138  
   139  	restConfig, err := maybeRESTConfig.Config, maybeRESTConfig.Error
   140  	if err != nil {
   141  		return &explodingClient{err: err}
   142  	}
   143  
   144  	clientset, err := maybeClientset.Clientset, maybeClientset.Error
   145  	if err != nil {
   146  		return &explodingClient{err: err}
   147  	}
   148  
   149  	core := clientset.CoreV1()
   150  	runtimeAsync := newRuntimeAsync(core)
   151  	registryAsync := newRegistryAsync(env, core, runtimeAsync)
   152  
   153  	di, err := dynamic.NewForConfig(restConfig)
   154  	if err != nil {
   155  		return &explodingClient{err: err}
   156  	}
   157  
   158  	discoveryClient, err := discovery.NewDiscoveryClientForConfig(restConfig)
   159  	if err != nil {
   160  		return &explodingClient{fmt.Errorf("unable to create discovery client: %v", err)}
   161  	}
   162  
   163  	apiGroupResources, err := restmapper.GetAPIGroupResources(discoveryClient)
   164  	if err != nil {
   165  		return &explodingClient{fmt.Errorf("unable to fetch API Group Resources: %v", err)}
   166  	}
   167  
   168  	drm := restmapper.NewDiscoveryRESTMapper(apiGroupResources)
   169  
   170  	// TODO(nick): I'm not happy about the way that pkg/browser uses global writers.
   171  	writer := logger.Get(ctx).Writer(logger.DebugLvl)
   172  	browser.Stdout = writer
   173  	browser.Stderr = writer
   174  
   175  	return K8sClient{
   176  		env:               env,
   177  		kubectlRunner:     runner,
   178  		core:              core,
   179  		restConfig:        restConfig,
   180  		portForwardClient: pfClient,
   181  		configNamespace:   configNamespace,
   182  		clientset:         clientset,
   183  		runtimeAsync:      runtimeAsync,
   184  		registryAsync:     registryAsync,
   185  		dynamic:           di,
   186  		drm:               drm,
   187  	}
   188  }
   189  
   190  func ServiceURL(service *v1.Service, ip NodeIP) (*url.URL, error) {
   191  	status := service.Status
   192  
   193  	lbStatus := status.LoadBalancer
   194  
   195  	if len(service.Spec.Ports) == 0 {
   196  		return nil, nil
   197  	}
   198  
   199  	portSpec := service.Spec.Ports[0]
   200  	port := portSpec.Port
   201  	nodePort := portSpec.NodePort
   202  
   203  	// Documentation here is helpful:
   204  	// https://godoc.org/k8s.io/api/core/v1#LoadBalancerIngress
   205  	// GKE and OpenStack typically use IP-based load balancers.
   206  	// AWS typically uses DNS-based load balancers.
   207  	for _, ingress := range lbStatus.Ingress {
   208  		urlString := ""
   209  		if ingress.IP != "" {
   210  			urlString = fmt.Sprintf("http://%s:%d/", ingress.IP, port)
   211  		}
   212  
   213  		if ingress.Hostname != "" {
   214  			urlString = fmt.Sprintf("http://%s:%d/", ingress.Hostname, port)
   215  		}
   216  
   217  		if urlString == "" {
   218  			continue
   219  		}
   220  
   221  		url, err := url.Parse(urlString)
   222  		if err != nil {
   223  			return nil, errors.Wrap(err, "ServiceURL: malformed url")
   224  		}
   225  		return url, nil
   226  	}
   227  
   228  	// If the node has an IP that we can hit, we can also look
   229  	// at the NodePort. This is mostly useful for Minikube.
   230  	if ip != "" && nodePort != 0 {
   231  		url, err := url.Parse(fmt.Sprintf("http://%s:%d/", ip, nodePort))
   232  		if err != nil {
   233  			return nil, errors.Wrap(err, "ServiceURL: malformed url")
   234  		}
   235  		return url, nil
   236  	}
   237  
   238  	return nil, nil
   239  }
   240  
   241  func (k K8sClient) Upsert(ctx context.Context, entities []K8sEntity) ([]K8sEntity, error) {
   242  	span, ctx := opentracing.StartSpanFromContext(ctx, "daemon-k8sUpsert")
   243  	defer span.Finish()
   244  
   245  	result := make([]K8sEntity, 0, len(entities))
   246  
   247  	mutable, immutable := MutableAndImmutableEntities(entities)
   248  
   249  	if len(mutable) > 0 {
   250  		newEntities, err := k.applyEntitiesAndMaybeForce(ctx, mutable)
   251  		if err != nil {
   252  			return nil, err
   253  		}
   254  		result = append(result, newEntities...)
   255  	}
   256  
   257  	if len(immutable) > 0 {
   258  		newEntities, err := k.forceReplaceEntities(ctx, immutable)
   259  		if err != nil {
   260  			return nil, err
   261  		}
   262  		result = append(result, newEntities...)
   263  	}
   264  
   265  	return result, nil
   266  }
   267  
   268  func (k K8sClient) forceReplaceEntities(ctx context.Context, entities []K8sEntity) ([]K8sEntity, error) {
   269  	stdout, stderr, err := k.actOnEntities(ctx, []string{"replace", "-o", "yaml", "--force"}, entities)
   270  	if err != nil {
   271  		return nil, errors.Wrapf(err, "kubectl replace:\nstderr: %s", stderr)
   272  	}
   273  
   274  	return parseYAMLFromStringWithDeletedResources(stdout)
   275  }
   276  
   277  // applyEntitiesAndMaybeForce `kubectl apply`'s the given entities, and if the call fails with
   278  // an immutible field error, attempts to `replace --force` them.
   279  func (k K8sClient) applyEntitiesAndMaybeForce(ctx context.Context, entities []K8sEntity) ([]K8sEntity, error) {
   280  	stdout, stderr, err := k.actOnEntities(ctx, []string{"apply", "-o", "yaml"}, entities)
   281  	if err != nil {
   282  		shouldTryReplace := maybeImmutableFieldStderr(stderr)
   283  
   284  		if !shouldTryReplace {
   285  			return nil, errors.Wrapf(err, "kubectl apply:\nstderr: %s", stderr)
   286  		}
   287  
   288  		// If the kubectl apply failed due to an immutable field, fall back to kubectl delete && kubectl apply
   289  		// NOTE(maia): this is equivalent to `kubecutl replace --force`, but will ensure that all
   290  		// dependant pods get deleted rather than orphaned. We WANT these pods to be deleted
   291  		// and recreated so they have all the new labels, etc. of their controlling k8s entity.
   292  		logger.Get(ctx).Infof("Falling back to 'kubectl delete && apply' on immutable field error")
   293  		stdout, stderr, err = k.actOnEntities(ctx, []string{"delete"}, entities)
   294  		if err != nil {
   295  			return nil, errors.Wrapf(err, "kubectl delete (as part of delete && apply):\nstderr: %s", stderr)
   296  		}
   297  		stdout, stderr, err = k.actOnEntities(ctx, []string{"apply", "-o", "yaml"}, entities)
   298  		if err != nil {
   299  			return nil, errors.Wrapf(err, "kubectl apply (as part of delete && apply):\nstderr: %s", stderr)
   300  		}
   301  	}
   302  
   303  	return ParseYAMLFromString(stdout)
   304  }
   305  
   306  func (k K8sClient) ConnectedToCluster(ctx context.Context) error {
   307  	stdout, stderr, err := k.kubectlRunner.exec(ctx, []string{"cluster-info"})
   308  	if err != nil {
   309  		return errors.Wrapf(err, "Unable to connect to cluster via `kubectl cluster-info`:\nstdout: %s\nstderr: %s", stdout, stderr)
   310  	}
   311  
   312  	return nil
   313  }
   314  
   315  // We're using kubectl, so we only get stderr, not structured errors.
   316  //
   317  // Take a wild guess if the update is failing due to immutable field errors.
   318  //
   319  // This should bias towards false positives (i.e., we think something is an
   320  // immutable field error when it's not).
   321  func maybeImmutableFieldStderr(stderr string) bool {
   322  	return strings.Contains(stderr, validation.FieldImmutableErrorMsg) || ForbiddenFieldsRe.Match([]byte(stderr))
   323  }
   324  
   325  // Deletes all given entities.
   326  //
   327  // Currently ignores any "not found" errors, because that seems like the correct
   328  // behavior for our use cases.
   329  func (k K8sClient) Delete(ctx context.Context, entities []K8sEntity) error {
   330  	l := logger.Get(ctx)
   331  	for _, e := range entities {
   332  		l.Infof("Deleting via kubectl: %s/%s\n", e.GVK().Kind, e.Name())
   333  	}
   334  
   335  	_, stderr, err := k.actOnEntities(ctx, []string{"delete", "--ignore-not-found"}, entities)
   336  	if err != nil {
   337  		return errors.Wrapf(err, "kubectl delete:\nstderr: %s", stderr)
   338  	}
   339  	return nil
   340  }
   341  
   342  func (k K8sClient) actOnEntities(ctx context.Context, cmdArgs []string, entities []K8sEntity) (stdout string, stderr string, err error) {
   343  	args := append([]string{}, cmdArgs...)
   344  	args = append(args, "-f", "-")
   345  
   346  	rawYAML, err := SerializeSpecYAML(entities)
   347  	if err != nil {
   348  		return "", "", errors.Wrapf(err, "serializeYaml for kubectl %s", cmdArgs)
   349  	}
   350  
   351  	return k.kubectlRunner.execWithStdin(ctx, args, rawYAML)
   352  }
   353  
   354  func (k K8sClient) GetByReference(ctx context.Context, ref v1.ObjectReference) (K8sEntity, error) {
   355  	group := getGroup(ref)
   356  	kind := ref.Kind
   357  	namespace := ref.Namespace
   358  	name := ref.Name
   359  	resourceVersion := ref.ResourceVersion
   360  	uid := ref.UID
   361  	rm, err := k.drm.RESTMapping(schema.GroupKind{Group: group, Kind: kind})
   362  	if err != nil {
   363  		return K8sEntity{}, errors.Wrapf(err, "error mapping %s/%s", group, kind)
   364  	}
   365  
   366  	result, err := k.dynamic.Resource(rm.Resource).Namespace(namespace).Get(name, metav1.GetOptions{
   367  		ResourceVersion: resourceVersion,
   368  	})
   369  	if err != nil {
   370  		return K8sEntity{}, err
   371  	}
   372  	if uid != "" && result.GetUID() != uid {
   373  		return K8sEntity{}, apierrors.NewNotFound(v1.Resource(kind), name)
   374  	}
   375  	return NewK8sEntity(result), nil
   376  }
   377  
   378  // Tests whether a string is a valid version for a k8s resource type.
   379  // from https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definition-versioning/#version-priority
   380  // Versions start with a v followed by a number, an optional beta or alpha designation, and optional additional numeric
   381  // versioning information. Broadly, a version string might look like v2 or v2beta1.
   382  var versionRegex = regexp.MustCompile(`^v\d+(?:(?:alpha|beta)(?:\d+)?)?$`)
   383  
   384  func getGroup(involvedObject v1.ObjectReference) string {
   385  	// For some types, APIVersion is incorrectly just the group w/ no version, which leads GroupVersionKind to return
   386  	// a value where Group is empty and Version contains the group, so we need to correct for that.
   387  	// An empty Group is valid, though: it's empty for apps in the core group.
   388  	// So, we detect this situation by checking if the version field is valid.
   389  
   390  	// this stems from group/version not necessarily being populated at other points in the API. see more info here:
   391  	// https://github.com/kubernetes/client-go/issues/308
   392  	// https://github.com/kubernetes/kubernetes/issues/3030
   393  
   394  	gvk := involvedObject.GroupVersionKind()
   395  	group := gvk.Group
   396  	if !versionRegex.MatchString(gvk.Version) {
   397  		group = involvedObject.APIVersion
   398  	}
   399  
   400  	return group
   401  }
   402  
   403  func ProvideServerVersion(maybeClientset ClientsetOrError) (*version.Info, error) {
   404  	if maybeClientset.Error != nil {
   405  		return nil, maybeClientset.Error
   406  	}
   407  	return maybeClientset.Clientset.Discovery().ServerVersion()
   408  }
   409  
   410  type ClientsetOrError struct {
   411  	Clientset *kubernetes.Clientset
   412  	Error     error
   413  }
   414  
   415  func ProvideClientset(cfg RESTConfigOrError) ClientsetOrError {
   416  	if cfg.Error != nil {
   417  		return ClientsetOrError{Error: cfg.Error}
   418  	}
   419  	clientset, err := kubernetes.NewForConfig(cfg.Config)
   420  	return ClientsetOrError{Clientset: clientset, Error: err}
   421  }
   422  
   423  func ProvideClientConfig() clientcmd.ClientConfig {
   424  	rules := clientcmd.NewDefaultClientConfigLoadingRules()
   425  	rules.DefaultClientConfig = &clientcmd.DefaultClientConfig
   426  
   427  	overrides := &clientcmd.ConfigOverrides{}
   428  	return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
   429  		rules,
   430  		overrides)
   431  }
   432  
   433  // The namespace in the kubeconfig.
   434  // Used as a default namespace in some (but not all) client commands.
   435  // https://godoc.org/k8s.io/client-go/tools/clientcmd/api/v1#Context
   436  func ProvideConfigNamespace(clientLoader clientcmd.ClientConfig) Namespace {
   437  	namespace, explicit, err := clientLoader.Namespace()
   438  	if err != nil {
   439  		// If we can't get a namespace from the config, just fail gracefully to the default.
   440  		// If this error indicates a more serious problem, it will get handled downstream.
   441  		return ""
   442  	}
   443  
   444  	// TODO(nick): Right now, tilt doesn't provide a namespace flag. If we ever did,
   445  	// we would need to handle explicit namespaces different than implicit ones.
   446  	_ = explicit
   447  
   448  	return Namespace(namespace)
   449  }
   450  
   451  type RESTConfigOrError struct {
   452  	Config *rest.Config
   453  	Error  error
   454  }
   455  
   456  func ProvideRESTConfig(clientLoader clientcmd.ClientConfig) RESTConfigOrError {
   457  	config, err := clientLoader.ClientConfig()
   458  	return RESTConfigOrError{Config: config, Error: err}
   459  }