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 }