Skip to content
This repository has been archived by the owner on Jul 22, 2022. It is now read-only.

use new dynamic client #80

Merged
merged 5 commits into from
Sep 7, 2018
Merged

Conversation

crimsonfaith91
Copy link
Contributor

@crimsonfaith91 crimsonfaith91 commented Aug 23, 2018

This PR addresses issue #47. It changes the codebase to use new dynamic client.

Fixes #47
Fixes #79

@crimsonfaith91 crimsonfaith91 changed the title Client Use new dynamic client Aug 23, 2018
@crimsonfaith91 crimsonfaith91 changed the title Use new dynamic client Switch to new dynamic client Aug 23, 2018
@crimsonfaith91 crimsonfaith91 requested a review from enisoc August 23, 2018 01:01
type dynamicResourceClientListFunc func(metav1.ListOptions) (*unstructured.UnstructuredList, error)

func castToCacheListFunc(fn dynamicResourceClientListFunc) cache.ListFunc {
options := metav1.ListOptions{}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is an empty ListOptions acceptable?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice, I've only set the LabelSelector of a ListOption before. It might be good hygiene to set timeout but that will likely cause more problems on slow clusters that it would prevent.

So, I think an empty LabelSelector is alright.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is meant to just be an adapter function, so you should pass along the ListOptions from the caller. See my other comment below, which should make this clearer.

Gopkg.toml Outdated

[[override]]
name = "github.com/json-iterator/go"
version = "1.1.4" # as used by apimachinery@kubernetes-1.9.9
version = "1.1.5" # as used by apimachinery@kubernetes-1.11.0

[prune]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I take it this is working for you? I only say that because I was running into all sorts of grouble getting code generation to work. See #79. I was able to get it to work adding the following to my GoPkg.toml:

[prune]
  go-tests = true
  unused-packages = true

  [[prune.project]]
    name = "k8s.io/code-generator"
    unused-packages = false

I was getting errors that the generator couldn't find the boilerplate.go.txt that comes with the code-generator repository.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Me too - There is no boilerplate.go.txt within the vendor code-generator repository, so I added it manually (which is wrong) to unblock myself. I forgot to point this out, thanks for the reminder!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me sync with @enisoc, and get back to you later. I believe your way of getting around this is better.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crimsonfaith91 I agree with the solution @rlguarino suggested. Can you try adding that?

@rlguarino Thanks for tracking that down!

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enisoc Sure! I will include the [prune.project] section.

type dynamicResourceClientListFunc func(metav1.ListOptions) (*unstructured.UnstructuredList, error)

func castToCacheListFunc(fn dynamicResourceClientListFunc) cache.ListFunc {
options := metav1.ListOptions{}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice, I've only set the LabelSelector of a ListOption before. It might be good hygiene to set timeout but that will likely cause more problems on slow clusters that it would prevent.

So, I think an empty LabelSelector is alright.

@@ -471,7 +471,7 @@ func (pc *parentController) makeSelector(parent *unstructured.Unstructured, extr
}

func (pc *parentController) canAdoptFunc(parent *unstructured.Unstructured) func() error {
nsParentClient := pc.parentClient.WithNamespace(parent.GetNamespace())
nsParentClient := pc.parentClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we actually need to reassign this variable here? Could we just directly call ps.parentClient.Get(...) below?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree too. In addition, make sure pc.parentClient is a namespace-agnostic client since a single parentController instance manages objects in multiple namespaces. In other words, the line below should ultimately read pc.parentClient.Namespace(parent.GetNamespace()).Get(parent.GetName(), ...).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, thanks for pointing this out. :)

@@ -536,7 +536,7 @@ func (pc *parentController) updateParentStatus(parent *unstructured.Unstructured
// We can't use Patch() because we need to ensure that the UID matches.
// TODO(enisoc): Use /status subresource when that exists.
// TODO(enisoc): Update status.observedGeneration when spec.generation starts working.
nsParentClient := pc.parentClient.WithNamespace(parent.GetNamespace())
nsParentClient := pc.parentClient
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simmarly to above about calling pc.parentClient.UpdateWIthRetries()... directly instead of defining a new variable. We could use:

return pc.parentClient.UpdateWithRetries(...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, you are right. :)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also like above, this may need to change to account for pc.parentClient becoming namespace-agnostic. To make something like pc.parentClient.Namespace(...).UpdateWithRetries(...) work, we'll need to restructure how we add extra methods to ResourceInterface in our dynamic/clientset wrapper.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed explanation. Did you mean ResourceClient? NamespaceableResourceInterface is a vendor API.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original purpose of ResourceClient was to extend ResourceInterface with extra methods that are useful for Metacontroller, which works with many different resources from many different angles.

Now that the new dynamic client has been split into ResourceInterface and NamespaceableResourceInterface, we need to restructure ResourceClient to correspond to NamespaceableResourceInterface, and add another level of wrapper to extend ResourceInterface as well.

Gopkg.toml Outdated
@@ -30,19 +30,19 @@ required = [

[[constraint]]
name = "k8s.io/client-go"
version = "~6.0"
revision = "kubernetes-1.11.0"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should stick to version and use 8.0.0 (the current recommended tag from the client-go README). The client-go versions are meant to be somewhat independent of Kubernetes versions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I thought the requirement is to use the client-go version of 1.11 kubernetes version (misunderstood issue description).

Gopkg.toml Outdated

[[override]]
name = "github.com/json-iterator/go"
version = "1.1.4" # as used by apimachinery@kubernetes-1.9.9
version = "1.1.5" # as used by apimachinery@kubernetes-1.11.0
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How did you determine that apimachinery uses 1.1.5 at the kubernetes-1.11.0 tag? From the Godeps file (below) it looks like it still uses a commit that's contained in 1.1.4:

https://github.com/kubernetes/apimachinery/blob/103fd098999dc9c0c88536f5c9ad2e5da39373ae/Godeps/Godeps.json#L94-L95

That said, I'm fine updating to 1.1.5 if it works. We should just reword the comment to say # same minor version track as used by apimachinery@kubernetes-1.11.0

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enisoc How do you get the info that the commit is 1.1.4? The relevant PR can be known via this page, but tag 1.1.5 (not 1.1.4) at the top of the page is in bold.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what the bolding means, but I believe that list is all the tags that contain this commit in their histories. If we wanted to find the closest release tag at or after that commit, we should take the earliest tag that contains it. That's how I got 1.1.4 before.

However, as mentioned above, I think we should update to 1.1.5 as long as it's compatible, since it contains additional bug fixes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see - we should take the earliest (not latest) tag. Thank you!
I verify that 1.1.5 works.

Gopkg.toml Outdated

[[override]]
name = "github.com/json-iterator/go"
version = "1.1.4" # as used by apimachinery@kubernetes-1.9.9
version = "1.1.5" # as used by apimachinery@kubernetes-1.11.0

[prune]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@crimsonfaith91 I agree with the solution @rlguarino suggested. Can you try adding that?

@rlguarino Thanks for tracking that down!

@@ -471,7 +471,7 @@ func (pc *parentController) makeSelector(parent *unstructured.Unstructured, extr
}

func (pc *parentController) canAdoptFunc(parent *unstructured.Unstructured) func() error {
nsParentClient := pc.parentClient.WithNamespace(parent.GetNamespace())
nsParentClient := pc.parentClient
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree too. In addition, make sure pc.parentClient is a namespace-agnostic client since a single parentController instance manages objects in multiple namespaces. In other words, the line below should ultimately read pc.parentClient.Namespace(parent.GetNamespace()).Get(parent.GetName(), ...).

@@ -536,7 +536,7 @@ func (pc *parentController) updateParentStatus(parent *unstructured.Unstructured
// We can't use Patch() because we need to ensure that the UID matches.
// TODO(enisoc): Use /status subresource when that exists.
// TODO(enisoc): Update status.observedGeneration when spec.generation starts working.
nsParentClient := pc.parentClient.WithNamespace(parent.GetNamespace())
nsParentClient := pc.parentClient
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also like above, this may need to change to account for pc.parentClient becoming namespace-agnostic. To make something like pc.parentClient.Namespace(...).UpdateWithRetries(...) work, we'll need to restructure how we add extra methods to ResourceInterface in our dynamic/clientset wrapper.

Resource: resource.Name,
}
}

func (cs *Clientset) resource(apiResource *dynamicdiscovery.APIResource, namespace string) (*ResourceClient, error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the improvements in the new dynamic client is that it decouples specification of the namespace from the creation of the client. We should mirror that by removing the namespace argument here and in Clientset.Resource().

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 23, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And also Clientset.Kind()?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, that one too. Good catch. :)


dc *dynamic.Client
dc dynamic.Interface
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might not need to store dc here anymore, since we no longer need to make a new client just to change the namespace.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removal of dc means we have to find another way of initializing ResourceClient.NamespaceableResourceInterface. I will see what is the possible workaround. This will take me some time because I am basically have zero knowledge on API machinery.

gv schema.GroupVersion
resource *dynamicdiscovery.APIResource
}

func (rc *ResourceClient) WithNamespace(namespace string) *ResourceClient {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to add UpdateWithRetries() for namespaced clients, we probably need to override Namespace() here and make it return a new wrapper struct that anonymously embeds a dynamic.ResourceInterface as well as implementing UpdateWithRetries().

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hasn't UpdateWithRetries() already implemented here?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to implement UpdateWithRetries() twice, although they can both just call the same private function that does the real work. My goal was to match the pattern used in the new dynamic client for handling the fact that some API calls are namespaced and others are not.

Notice that NamespaceableResourceInterface contains both a Namespace() method and a full embedding of ResourceInterface:

type NamespaceableResourceInterface interface {
    Namespace(string) ResourceInterface
    ResourceInterface
}

That means, for example, you can do both dc.Resource().List() and dc.Resource().Namespace().List(). Which one you use depends on whether you want to send a namespaced request or a cluster-scoped request. For example, you can list objects for one namespace, or for all namespaces.

To match this for the extra functions we add like UpdateWithRetries(), we should make sure both rc.UpdateWithRetries() and rc.Namespace().UpdateWithRetries() work, because we want to be able to update objects that are either cluster-scoped or namespaced.

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, i see! I have to support rc.UpdateWithRetries() too for cluster-scoped operations. I thought we need to support only namespaced operations.

type dynamicResourceClientListFunc func(metav1.ListOptions) (*unstructured.UnstructuredList, error)

func castToCacheListFunc(fn dynamicResourceClientListFunc) cache.ListFunc {
options := metav1.ListOptions{}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is meant to just be an adapter function, so you should pass along the ListOptions from the caller. See my other comment below, which should make this clearer.

func newSharedResourceInformer(client *dynamicclientset.ResourceClient, defaultResyncPeriod time.Duration, close func()) *sharedResourceInformer {
informer := cache.NewSharedIndexInformer(
&cache.ListWatch{
ListFunc: client.List,
ListFunc: castToCacheListFunc(client.List),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we only need to do this once, I don't think it warrants all the stuff broken out above. It should be possible to write this concisely as something like this:

ListFunc: func(opts metav1.ListOptions) (runtime.Object, error) {
  return client.List(opts)
}

Note that you don't need to cast the return value because a type that implements an interface is already assignable to a variable of that interface type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a much better way to do it! Thanks for specifying that the casting is not necessary.

@crimsonfaith91 crimsonfaith91 changed the title Switch to new dynamic client use new dynamic client Aug 23, 2018
}

func New(config *rest.Config, resources *dynamicdiscovery.ResourceMap) *Clientset {
func New(config *rest.Config, resources *dynamicdiscovery.ResourceMap) (*Clientset, error) {
// TODO: find a way to obtain APIResource for getting GroupVersion.
Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enisoc I shifted dc field from ResourceClient to Clientset. Not sure whether this is what you suggested. Feel free to sync by person if I get you wrong.

When calling NewForConfig within New, I couldn't find any way to obtain apiVersion and resource info, so I couldn't call resources.Get(apiVersion, resource) to obtain the GroupVersion for populating config.

I most probably at the wrong track, but decided to show my current progress for facilitating discussion (the codes compiled, but the smoke tests did not pass).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My hope was that the new dynamic client is designed to let us use one dc for many different GroupVersions and Resources. If that's so, we should be able to make a new dc here without filling in any GroupVersion or Resource, since we will provide that later when calling dc.Resource().

This code looks like what I would expect to work if that were the case. However, if the tests don't pass it might be that we still need to make a new dc for different GroupVersions. Are you seeing errors in the Metacontroller logs?

Copy link
Contributor Author

@crimsonfaith91 crimsonfaith91 Aug 24, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for verifying that I am on the right track. I will post log errors later if I cannot resolve it. :)

Yes, the new dynamic client should allow us to use one dc across multiple GVRs.

return &ResourceClient{
NamespaceableResourceInterface: dc.Resource(resourceGVR),
dc: dc,
NamespaceableResourceInterface: cs.dc.Resource(gvr),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embedding dynamic interface dc into the clientset cs itself for initializing NamespaceableResourceInterface.

I could not find another way to initialize NamespaceableResourceInterface other than this.

@@ -111,7 +111,7 @@ func deleteChildren(client *dynamicclientset.ResourceClient, parent *unstructure
// This observed object wasn't listed as desired.
glog.Infof("%v %v/%v: deleting %v %v", parent.GetKind(), parent.GetNamespace(), parent.GetName(), obj.GetKind(), obj.GetName())
uid := obj.GetUID()
err := client.Delete(name, &metav1.DeleteOptions{
err := client.Namespace(parent.GetNamespace()).Delete(name, &metav1.DeleteOptions{
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can either set Namespace here, or earlier in the sync call tree via dynClient.Kind(apiVersion, kind).Namespace(parent.GetNamespace()). I prefer existing implementation because we may need to work with cluster-scoped client too.

}
}

func (nrc *NamespacedResourceClient) UpdateWithRetries(orig *unstructured.Unstructured, update func(obj *unstructured.Unstructured) bool) (result *unstructured.Unstructured, err error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we implement other functions (in another file) for NamespacedResourceClient?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, let's implement only UpdateWithRetries on NamespacedResourceClient. The others like GroupResource shouldn't be necessary if we pass around ResourceClient and only call .Namespace() as needed.

Regarding UpdateWithRetries, it should be possible to extract this into a helper method that accepts a dynamic.ResourceInterface to avoid duplicating code. Both ResourceClient and NamespacedResourceClient satisfy that interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sound good to me, thanks!

}
}

func (nrc *NamespacedResourceClient) UpdateWithRetries(orig *unstructured.Unstructured, update func(obj *unstructured.Unstructured) bool) (result *unstructured.Unstructured, err error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, let's implement only UpdateWithRetries on NamespacedResourceClient. The others like GroupResource shouldn't be necessary if we pass around ResourceClient and only call .Namespace() as needed.

Regarding UpdateWithRetries, it should be possible to extract this into a helper method that accepts a dynamic.ResourceInterface to avoid duplicating code. Both ResourceClient and NamespacedResourceClient satisfy that interface.

resource: apiResource,
}, nil
NamespaceableResourceInterface: cs.dc.Resource(apiResource.GroupVersionResource()),
gv: apiResource.GroupVersion(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember why I originally stored gv in addition to resource, since you can get one from the other. Can you try removing gv from the struct and see if that simplifies things without adding some other cost?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, will try removing gv.

@@ -105,74 +99,57 @@ func (rc *ResourceClient) Kind() string {
}

func (rc *ResourceClient) GroupVersion() schema.GroupVersion {
return rc.gv
return rc.resource.GroupVersion()
}

func (rc *ResourceClient) GroupResource() schema.GroupResource {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GroupResource() is still necessary for ResourceClient because it is called here.

Copy link

@enisoc enisoc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed one more problem (see below). Did the smoke tests already pass? I guess this shows we need more tests.

Everything else looks good to me after that's fixed.

@@ -499,7 +498,7 @@ func (pc *parentController) claimChildren(parent *unstructured.Unstructured) (co
childMap := make(common.ChildMap)
for _, child := range pc.cc.Spec.ChildResources {
// List all objects of the child kind in the parent object's namespace.
childClient, err := pc.dynClient.Resource(child.APIVersion, child.Resource, namespace)
childClient, err := pc.dynClient.Resource(child.APIVersion, child.Resource)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for not noticing this earlier: I think since we no longer set the namespace here, we need to fix UnstructuredManager in the dynamic/controllerref package to call Namespace() when needed (if the child object has a namespace set).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enisoc Yes, they passed. I ran all the tests for every commit now with image containing my changes via skaffold run after changing skaffold.yaml and image.yaml to my docker ID.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, I will change dynamic/controllerref package accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@enisoc Do we also need to fix ControllerRevisionManager? ControllerRevision are namespaced objects.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, I hadn't thought of that. I just checked though and it looks like ControllerRevisionManager is unaffected by your changes because we use a generated client for that rather than the dynamic client.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I will push a commit soon. Thanks!

@crimsonfaith91
Copy link
Contributor Author

@enisoc Should we combine these two if-blocks into one if-else-block?

@enisoc
Copy link

enisoc commented Sep 7, 2018

Style-wise, I prefer them as separate blocks. The first block follows the pattern of short-circuiting control flow (return/break/continue) if an error occurs. The second block is not part of error handling, but is actually part of the normal flow -- it's just that the normal flow happens to consist of an if block.

obj.SetOwnerReferences(ownerRefs)
return true
})
var err error
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic (check if it has a namespace and call UpdateWithRetries one way or the other) can be wrapped in a helper. If you do that, you can keep the code here looking simple (the only change will be calling your helper instead of directly calling UpdateWithRetries).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted, will move the logic to a helper function.

Copy link

@enisoc enisoc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@enisoc enisoc merged commit 8b53f27 into GoogleCloudPlatform:master Sep 7, 2018
@crimsonfaith91 crimsonfaith91 deleted the client branch September 18, 2018 00:52
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants