Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow authorization with Google application default credentials #213

Merged
merged 1 commit into from
Apr 27, 2018

Conversation

jeremywadsack
Copy link
Contributor

This adds support for reading the application default
credentials installed on a system by Google. For example, on a
development system where gcloud is configured or on a GCP
instance.

I think this is a better solution to #210.

@simon3z
Copy link
Collaborator

simon3z commented Nov 18, 2016

@moolitayer can you review this? Especially the googleauth dependency? (It may create dependency issues in other proejcts).

@jeremywadsack
Copy link
Contributor Author

Hmm.. the travis failure is because it's running on a system that does not have any default application credentials configured. So I need to figure out how to test around that. I didn't see any stubbing in the existing tests except with webmock. I'll look into how to stub that with minitest's native stubbing.

@@ -70,6 +72,9 @@ def initialize_client(
elsif auth_options[:bearer_token_file]
validate_bearer_token_file
bearer_token(File.read(@auth_options[:bearer_token_file]))
elsif auth_options[:default_credentials]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please initialize this variable (line 23) with nil

Copy link
Collaborator

Choose a reason for hiding this comment

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

You will also need to add it to the input validation in line 442

README.md Outdated

```ruby
auth_options = {
default_credentials: true
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would name this use_default since its boolean. It seems more consistent with the other auth keys that all contain string values. Also maybe use_default_gce or use_gce is better since it's currently only gce?
Naming is hard 🐏

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naming is hard. And often having more people looking at it helps. :)

How about use_default_gcp because this applies to GKE as well.


def default_credentials_token
scopes = ['https://www.googleapis.com/auth/cloud-platform']
authorization = Google::Auth.get_application_default(scopes)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Google::Auth.get_application_default(scopes).apply({}).access_token

Copy link
Contributor Author

Choose a reason for hiding this comment

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

#access_token is a method on the object returned by #get_application_default, not on the #apply method result (it's not chainable). I could use #tap if you don't want the interim local variable but I tend to find that harder to read.

.to_return(body: open_test_file('pod_list.json'),
status: 200)

client = Kubeclient::Client.new 'http://localhost:8080/api/',
Copy link
Collaborator

Choose a reason for hiding this comment

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

Parenthesis around parameter list please

default_credentials: true
}
end
assert_equal expected_msg, exception.message
Copy link
Collaborator

Choose a reason for hiding this comment

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

Parenthesis around parameter list please

Kubeclient::Client.new 'http://localhost:8080',
auth_options: {
default_credentials: true
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Parenthesis around parameter list please

@moolitayer
Copy link
Collaborator

I think for consistency we would need to catch Signet::AuthorizationError and throw a KubeException.

This seems like a good direction. Thanks for the patch @jeremywadsack 👏

@moolitayer
Copy link
Collaborator

@moolitayer can you review this? Especially the googleauth dependency? (It may create dependency issues in other proejcts).

@simon3z about the dependency honestly I hope it won't break anything like the dependency problems we have been having lately. Maybe if we encounter problems in the future we can define such dependencies as optional to mitigate the problem.

@jeremywadsack
Copy link
Contributor Author

@moolitayer I made most of the changes you requested (except can't inline Google::Auth.get_application_default(scopes).apply({}).access_token).

Also:

  • Fixed the tests to build on a systems without default credentials (like Travis).
  • Updated use of parentheses for all calls in the test file for consistency. Note that there are other files that are not using that (including the README).
  • Updated the two tests for auth exceptions that were not testing the JSON message parsing so that they would (because I extracted that parsing code).

I also thought about making the dependency optional, especially for those not on Google platforms. Let me know if you want to to take a stab at that.

I can squash and rebase if you want once you are done reviewing.

auth = Minitest::Mock.new
auth.expect(:apply, nil, [{}])
auth.expect(:access_token, 'valid_token')
Google::Auth.stub(:get_application_default, auth) do
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

@moolitayer
Copy link
Collaborator

Updated use of parentheses for all calls in the test file for consistency. Note that there are other files that are not using that (including the README).

That's good but it makes this change harder to review. If you will submit that in a separate pr we can get it merged fast. You can wait for that or you can use the old style here for consistency.
(I usually don't look at all the file when reviewing so sorry for not noticing that, but if your pr matches the style of the file that is actually preferable)

I also thought about making the dependency optional, especially for those not on Google platforms. Let me know if you want to to take a stab at that.

Cool, don't invest time in that just yet. Go ahead and squash your changes

@@ -389,16 +400,18 @@ def test_api_bearer_token_success
def test_api_bearer_token_failure
error_message = '"/api/v1" is forbidden because ' \
'system:anonymous cannot list on pods in'
response = OpenStruct.new(code: 401, message: error_message)
response = RestClientResponseStub.new('', 403)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you explain the change in expectation?
Also is there a functional need in using a class extending string instead of OpenStruct.?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The previous code was raising a KubeException in the request, which meant that #handle_exception was just passing through the exception. So all the expectations in this test were just testing the OpenStruct, not testing the code within #handle_exception that retains the status code and response body.

Instead of raising a KubeException this raises the RestClient::Exception which is what #handle_exception is rescuing and parsing. The response is required by RestClient::Exception. The stub extends String because the RestClient::Response object extends String and is expected to behave the same way (that is, the response is the body).

@jeremywadsack
Copy link
Contributor Author

@moolitayer I was on holiday for a week and just now catching up.

I'll try to push the style changes as a separate PR today. Rolling back is harder. Once that's merged I can squash and rebase this on top of that.

@moolitayer
Copy link
Collaborator

LGTM +1 once squashed

@simon3z please review. Also please comment if you would like us to explore the path of optional dependencies. I didn't find any built in solution in gemspec but ad hock ones. I saw Keenan comment on those issues so he might be able to help us down that path.

Some stats: without googleauth kubeclinet depends on 29 gems. Adding it takes us up to 39. Those dependencies are (I'm using 2.3):

faraday-0.10.0
googleauth-0.5.1
jwt-1.5.6
little-plugger-1.1.4
logging-2.1.0
memoist-0.15.0
multi_json-1.12.1
multipart-post-2.0.0
os-0.9.6
signet-0.7.3

@jeremywadsack
Copy link
Contributor Author

jeremywadsack commented Dec 4, 2016 via email

{}
end
err_message = json_error_msg['message'] || e.message
err_message = json_error_message(e.response) || e.message
Copy link
Collaborator

Choose a reason for hiding this comment

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

@jeremywadsack is this change related? Why is it needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@simon3z Because I used the same code for parsing the error message from the body of a Signet::AuthorizationError, so I encapsulated the code in a method.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍

@simon3z
Copy link
Collaborator

simon3z commented Dec 5, 2016

@jeremywadsack I am wondering... isn't this something that could wrap kubeclient instead of being part of it?

@jeremywadsack
Copy link
Contributor Author

@simon3z I could certainly create a gem that adds this. Let me know which way you prefer to go.

@jeremywadsack
Copy link
Contributor Author

@simon3z My goal here was to resolve the fact that kubeclient doesn't work on systems authorized for Google Cloud (#210).

@jeremywadsack
Copy link
Contributor Author

@simon3z @moolitayer Rebased and squashed.

@xldenis
Copy link

xldenis commented Mar 14, 2017

What's the status of these changes? Is there a chance they will get merged in the near future?

@xldenis
Copy link

xldenis commented Apr 28, 2017

@jeremywadsack Do you have time to work on this? Or should someone else take over?

@jeremywadsack
Copy link
Contributor Author

@xldenis: From @simon3z's last comment I didn't think this was something that they wanted in this gem. I don't have time at the moment to look into building a wrapper gem and injecting all the functionality.

@xldenis
Copy link

xldenis commented Apr 28, 2017

That's very disappointing, it would be useful to support GCE/GKE. Thanks for the update though.

@xldenis
Copy link

xldenis commented May 11, 2017

That's totally possible. If you'd like I'll whip up a patch merging my config wrapper with the main one and conditionally guarding the behaviour around a gem presence check.

@jeremywadsack
Copy link
Contributor Author

@xldenis Thank you for picking this up. I would also love to have this supported natively in the gem for the same reason: GKE is a major platform for Kubernetes.

As I posted before we can just add a README entry that says that add the googleauth gem for GCE/GKE support. @bglusman's suggestion of checking for defined? constant is smart.

@simon3z
Copy link
Collaborator

simon3z commented May 12, 2017

That's totally possible. If you'd like I'll whip up a patch merging my config wrapper with the main one and conditionally guarding the behaviour around a gem presence check.

@xldenis sounds good to me. And I'm OK with the dependency in dev.

Copy link
Collaborator

@cben cben left a comment

Choose a reason for hiding this comment

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

Hi, I'd like to somehow move this along.
I'm again questioning whether obtaining the token belongs in kubeclient :-)
Please don't read this as objection — I see there was consensus on "optional dependency" approach, and I'm good with that!
I'm just trying to understand whether it's the most convenient thing for GCP users, or maybe obtaining the token yourself and passing it to Client gives much more flexibility... (I don't know anything about GCP)

P.S. thanks everyone for #210, #211 and Shopify/krane#88, the multiple implementations are very helpful for understand the problem space 🙇‍♂️

@@ -509,22 +524,22 @@ def test_init_username_and_bearer_token
assert_equal(expected_msg, exception.message)
end

def test_init_user_and_bearer_token
def test_init_username_and_bearer_token_file
Copy link
Collaborator

Choose a reason for hiding this comment

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

At first I wanted to ask why we need both bearer_token and bearer_token_file but then noticed it's already supported and documented, thanks for adding tests! 👏

@@ -70,6 +73,9 @@ def initialize_client(
elsif auth_options[:bearer_token_file]
validate_bearer_token_file
bearer_token(File.read(@auth_options[:bearer_token_file]))
elsif auth_options[:use_default_gcp]
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if this should be an option for the Client.
It's resolved to a token on initialization, and after that the Client doesn't care.
Could it be responsibility of Kubeclient::Config instead? (like in Shopify/krane#88)
Well, it's also useful for people who don't use kubeconfig.
But maybe expose a class method gcp_auth_options or something like that, also used by Kubeclient::Config?

  • README recommends calling config.context several times in Client instantiation, and it's silly if that will result in obtaining many tokens :-(
    • Seems solvable by moving it from fetch_user_auth_options now called during config.context to only the call context.auth_options.
  • It should be possible to use a single Config instance for many Client instantiations, obtaining a new token for each one.
  • Conversely it should be possible to reuse one token for many Client instances (e.g. it's currently common to create several instances for each API group/version you use).


authorization.apply({})

authorization.access_token
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not a lot of code, so I'm wondering if kubeclient needs to include it at all.

The alternative I'm thinking about is:

  • Document example code in README.
  • Those directly passing auth_options: just write this or similar code and pass the token.
  • For those reading a config file, Kubeclient::Config could have a hook to plug your own handling of 'auth-provider'. E.g. auth-provider: gcp would call fetch_auth_provider_options_gcp method one could override in a subclass.

See also my other comment about one Config / many context calls / many Client instances / one or many tokens. Perhaps for gcp you better take matters into your own hands and not call config.context.auth_options at all? (for that, Config should let user access the raw auth-provider portion of config)

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'm sure there are other cases with GCP but I can think of the two:

  1. User has authorized gcloud for their GCP project (and by extension kubectl). In that case, the token is only good for an hour as I pointed out in Authentication from "gcp" provider #210. I tried to address this in Enable "gcp" authentication structure for bearer token in .kube/config #211 but I couldn't get it to work for more than an hour and I'm not sure if we can.

  2. This is on a GCP server (GKE, GCE, App Engine, Cloud Functions), where Google has installed "default credentials". That's what this PR addresses. (You can always install default credentials on your local dev machine as "long-running" credentials.)

I see your point that this could just be documented. It would be really nice if kubeclient just worked out-of-the-box for gcloud-configured machines (e.g. development machine) and GCE/GKE machines. I can see that sometimes users are going to want to modify the scope and may eventually want to use specific keys for role-based-access (rather than authorizing the default credentials on a machine for this permission). So maybe there's a place for basic "out-of-the-box" support and anything beyond that is documented.

It does make more sense to use Config than Client. However, the shopify solution depends on the presence of auth-provider.name in the configuration file but there is no such file in a GCP server. So it wouldn't solve for that case.

authorization.apply({})

authorization.access_token
rescue Signet::AuthorizationError => e
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this wrapping helping? It was suggested "for consistency" but perhaps it's nicer to be able to rescue original Signet exceptions rather than a kubeclient exception?
Plus, we rebranded our main exception as Kubeclient::HttpError this makes less sense.

@cben
Copy link
Collaborator

cben commented Feb 1, 2018

A lot of my questions above are around Config vs tokens vs Client lifespans, which are not one-size-fits-all because tokens expire.
@garethr mentions on #210 (comment) that gcloud can produce a fixed non-expiring client cert. Can googleauth gem do that?

@cben
Copy link
Collaborator

cben commented Feb 4, 2018 via email

@jeremywadsack
Copy link
Contributor Author

so how do you detect/activate GCP support for use case 2?

@cben I think either have a configuration option that tells the config/client to look for the default Google authorization (and require googleauth) or have kubeclient detect if googleauth is installed and attempt to authenticate via default credentials if no other authorization option is specified.

From the comments here, my guess is this team is partial to an explicit selection.

@fw42
Copy link

fw42 commented Apr 12, 2018

Any idea if this can be merged soon? Anyone still actively working on it? Anyone need help with anything?

@cben
Copy link
Collaborator

cben commented Apr 12, 2018

@moolitayer what do you think? You've LGTM'd this in the past.
I've asked lots of questions, got a good answer and I'm ok with this.
(I mildly prefer Signet::AuthorizationError to just raise unwrapped but won't insist.)
Are we good if this gets rebased?

@jeremywadsack
Copy link
Contributor Author

jeremywadsack commented Apr 12, 2018

@cben It's been so long since I looked at this that it's hard to recall where my head was at the time. As it happens, I'm just looking into this again (or into the code that we built that depends on this) and so it's a good time to rethink.

In production in our GKE cluster, we're using this:

          Kubeclient::Config::Context.new(
              "https://kubernetes",
              "v1",
              {ca_file: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"},
              bearer_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token"
          )

That's basically what's described in the README for "using kubeclient inside a Kubernetes cluster" (with the addition of a well-known location for the API host and CA file). Given that it's documented, I don't know that we need to do anything to "make this work" in GKE. Although adding additional details for GKE might be helpful. (Incidentally, it's documented in Kubernetes as the recommended way to access the API server.)

I have no experience running kubeclient from GCE, App Engine, or Cloud Functions, but I believe that those are all enabled with Google's "Application Default Credentials". A gcloud-configured kuebclient machine may have Default Application Credentials.*

Here's a proposal, let me know what you think:

  • 1. I'll open a new PR with documentation details about how to configure the client for a GKE system.
  • 2. I'll rebase this, move the code into Kubeclient::Config as suggested, and make the dependecy optional.
  • 3. I'll consider a PR separately, after Application Default Credentials support is in, to adapt the #read('.kube/config') method to work on gcloud-configured machines (essentially the Add google cloud configuration object for kubeclient Shopify/krane#88 solution).*

Does that sound good?


*Using gcloud to authorize kubectl for a GKE cluster does not generate Application Default Credentials. It used to but now it says this:

WARNING: `gcloud auth login` no longer writes application default credentials.
If you need to use ADC, see:
   gcloud auth application-default --help

@cben
Copy link
Collaborator

cben commented Apr 20, 2018

@jeremywadsack Sorry for delay, I was hoping @moolitayer would respond too (nag nag 😉)...
Your proposal sounds great.
Honestly, you have much better picture of the Google stuff than we do, I trust whatever you decide is right for kubeclient.

@jeremywadsack
Copy link
Contributor Author

@cben @moolitayer: I've rewritten this and I think it should make more sense and be simpler.

First, note that the "application default credentials" aren't related to having kubectl installed. So I didn't think the Kubeclient::Config was the place to put the code. As you had wanted this to not pollute the Kubeclient itself, I opted for a new "token provider" that will generate the token and added documentation on how to use that.

I think this is a much simpler and more useful approach.

I removed the dependency on googleauth from the gem file, but retained if for testing. Because the code that calls Google::Auth isn't part of Kubeclient it won't raise any errors without googleauth installed unless you explicitly call Kubeclient::ApplicationDefaultCredentials.token. My README notes say that you need to have googleauth in your bundle and show that you need to require 'googleauth' as well to use the token provider.

Let me know if your thoughts and any changes you'd like. If this looks good, I can squash commits.

Copy link
Collaborator

@cben cben left a comment

Choose a reason for hiding this comment

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

nice! few last minor questions.

@@ -0,0 +1,17 @@
# frozen_string_literal: true

require 'googleauth'
Copy link
Collaborator

Choose a reason for hiding this comment

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

With this file required in kubeclient.rb, I think this means you can't require kubeclient without having googleauth gem. Can you move this into token method so it's only done if you use it?

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 catch. Fixed in 774177b.


module Kubeclient
# Get a bearer token from the Google's application default credentials.
class ApplicationDefaultCredentials
Copy link
Collaborator

Choose a reason for hiding this comment

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

This name sounds pretty generic, perhaps should somehow include "Google".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done 24145e9. I thought that the name was already pretty long... ;) But I agree.

`kubeclient` dependencies so you should add it to your bundle.

```ruby
require 'googleauth'
Copy link
Collaborator

Choose a reason for hiding this comment

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

this is not strictly neccessary here, just to emphasize the dependency on the gem, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Originally I think I was thinking of not having a require in the library code at all, in which case this would be necessary. As it is now it's not necessary, but it follows the example below this for Celluloid::IO.

However, when the googleauth gem is not installed, and you try to use get a token, it raises a load error:

2.3.1 :002 > Kubeclient::ApplicationDefaultCredentials.token
LoadError: cannot load such file -- googleauth

I think this is obscure (even though it's documented in the README). What do you think about adding a message here that tells what to do. One of these options:

  1. Do not include the require (because presumably it's already been done by bundler) and rescue NameError around Google::Auth.get_application_default and provide a message.
  2. Retain the require (because it is a dependency) and rescue the LoadError around the require and provide a message.

Message would be something like this: "GoogleApplicationDefaultCredentials requires the googleauth gem. Please make sure this is included in your bundle."

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd rather not assume bundler does require. Some people practice require: false in Gemfile. And I suppose some people [citation needed ;-] use gem install outside bundler.
=> if we don't require in the code, keep this require in README.

Personally I'd do require on demand inside the token method. Because it is a run time dependency of this code, only not a bundler dependency. So user is responsible to install it, but if installed we can use it.
But I don't care much, your call.

IMHO LoadError is clear & actionable enough, tells you what gem you need.
(But an uncatched NameError would be too obscure to my taste.)
If you want a rescue for a friendlier message, that's OK too...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I'll leave it as is. It accomplishes the job and I don't want to add more code.

Copy link
Collaborator

@cben cben left a comment

Choose a reason for hiding this comment

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

LGTM 👍

Just to be sure, I confirmed manually that kubeclient works with this PR without googleauth in the bundle. (Not checked by Travis, due to googleauth dev dependency. I think that's OK.)

@moolitayer can we merge?

@@ -1,6 +1,7 @@
require 'json'
require 'rest-client'

require 'kubeclient/google_application_default_credentials'
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: please keep this list sorted

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 meant to do that. Then I renamed the file. I've fixed that and will squash and rebase (to address merge conflicts in README).

@moolitayer
Copy link
Collaborator

Nice and clean, thanks!

This adds support for reading the application default
credentials installed on a system by Google. For example, on a
development system where gcloud is configured or on a GCP
instance.
Copy link
Collaborator

@cben cben left a comment

Choose a reason for hiding this comment

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

rebase looks good, merging.

Thanks for the perseverance! 🍰

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants