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

Fix Client#get_resources for custom resources that are not yet defined #14

Merged
merged 16 commits into from
Aug 13, 2018
Merged
1 change: 1 addition & 0 deletions lib/k8s-client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
require 'k8s/resource_client'
require 'k8s/stack'
require 'k8s/transport'
require 'k8s/util'
32 changes: 23 additions & 9 deletions lib/k8s/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,22 @@ def api_groups
end

# @param api_versions [Array<String>] defaults to all APIs
# @param prefetch_resources [Boolean] prefetch any missing api_resources for each api_version
# @param skip_missing [Boolean] return APIClient without api_resources? if 404
# @return [Array<APIClient>]
def apis(api_versions = nil, prefetch_resources: false)
def apis(api_versions = nil, prefetch_resources: false, skip_missing: false)
api_versions ||= ['v1'] + self.api_groups

if prefetch_resources
# api groups that are missing their api_resources
api_paths = api_versions
.uniq
.select{|api_version| !api(api_version).api_resources? }
.map{|api_version| APIClient.path(api_version) }

# load into APIClient.api_resources=
@transport.gets(*api_paths, response_class: K8s::API::MetaV1::APIResourceList).each do |api_resource_list|
api(api_resource_list.groupVersion).api_resources = api_resource_list.resources
@transport.gets(*api_paths, response_class: K8s::API::MetaV1::APIResourceList, skip_missing: skip_missing).each do |api_resource_list|
api(api_resource_list.groupVersion).api_resources = api_resource_list.resources if api_resource_list
end
end

Expand Down Expand Up @@ -116,21 +119,32 @@ def get_resource(resource)
client_for_resource(resource).get_resource(resource)
end

# Returns nils for any resources that do not exist.
# This includes custom resources that were not yet defined.
#
# @param resources [Array<K8s::Resource>]
# @return [Array<K8s::Resource, nil>]
# @return [Array<K8s::Resource, nil>] matching resources array 1:1
def get_resources(resources)
# prefetch api resources
apis(resources.map{|resource| resource.apiVersion }.uniq, prefetch_resources: true)
# prefetch api resources, skip missing APIs
resource_apis = apis(resources.map{ |resource| resource.apiVersion }, prefetch_resources: true, skip_missing: true)

# map each resource to excon request options, or nil if resource is not (yet) defined
requests = resources.zip(resource_apis).map{ |resource, api_client|
next nil unless api_client.api_resources?

resource_client = api_client.client_for_resource(resource)

resource_clients = resources.map{|resource| client_for_resource(resource) }
requests = resources.zip(resource_clients).map{|resource, resource_client|
{
method: 'GET',
path: resource_client.path(resource.metadata.name, namespace: resource.metadata.namespace),
response_class: resource_client.resource_class,
}
}
responses = @transport.requests(*requests, skip_missing: true)

# map non-nil requests to response objects, or nil for nil request options
Util.compact_map(requests) { |reqs|
@transport.requests(*reqs, skip_missing: true)
}
end

# @param resource [K8s::Resource]
Expand Down
1 change: 1 addition & 0 deletions lib/k8s/stack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def prune(client, keep_resources: )
client.delete_resource(resource)
rescue K8s::Error::NotFound
# assume aliased objects in multiple API groups, like for Deployments
# alternatively, a custom resource whose definition was already deleted earlier
end
end
end
Expand Down
15 changes: 8 additions & 7 deletions lib/k8s/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -201,19 +201,21 @@ def request(response_class: nil, **options)
# @param skip_missing [Boolean] return nil for HTTP 404 responses
# @param retry_errors [Boolean] retry with non-pipelined request for HTTP 503 responses
# @return [Array<response_class, Hash, nil>]
def requests(*options, response_class: nil, skip_missing: false, retry_errors: true)
def requests(*options, skip_missing: false, retry_errors: true, **common_options)
return [] if options.empty? # excon chokes

start = Time.now
responses = excon.requests(
options.map{|options| request_options(**options)}
options.map{|options| request_options(**common_options.merge(options))}
)
t = Time.now - start

objects = responses.zip(options).map{|response, request_options|
response_class = request_options[:response_class] || common_options[:response_class]

begin
parse_response(response, request_options,
response_class: request_options[:response_class] || response_class,
response_class: response_class,
)
rescue K8s::Error::NotFound
if skip_missing
Expand All @@ -226,7 +228,7 @@ def requests(*options, response_class: nil, skip_missing: false, retry_errors: t
logger.warn { "Retry #{format_request(request_options)} => HTTP #{exc.code} #{exc.reason} in #{'%.3f' % t}s" }

# only retry the failed request, not the entire pipeline
request(response_class: response_class, **request_options)
request(response_class: response_class, **common_options.merge(request_options))
else
raise
end
Expand All @@ -250,13 +252,12 @@ def get(*path, **options)
end

# @param *paths [String]
def gets(*paths, response_class: nil, **options)
def gets(*paths, **options)
requests(*paths.map{|path| {
method: 'GET',
path: self.path(path),
**options,
} },
response_class: response_class,
**options
)
end
end
Expand Down
22 changes: 22 additions & 0 deletions lib/k8s/util.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module K8s
module Util
# Yield with all non-nil args, returning matching array with corresponding return values or nils.
#
# Args must be usable as hash keys. Duplicate args will all map to the same return value.
#
# @param args [Array<nil, Object>]
# @yield args
# @yieldparam args [Array<Object>] omitting any nil values
# @return [Array<nil, Object>] matching args array 1:1, containing yielded values for non-nil args
def self.compact_map(args)
func_args = args.compact

values = yield func_args

# Hash{arg => value}
value_map = Hash[func_args.zip(values)]

args.map{|arg| value_map[arg] }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because of how the hash is used, any duplicate args will map to the same return value... shouldn't be a problem when used with idempotent methods like GET requests, but it's a limitation.

end
end
end
15 changes: 15 additions & 0 deletions spec/fixtures/api/configmaps-bar-404.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"kind": "Status",
"apiVersion": "v1",
"metadata": {

},
"status": "Failure",
"message": "configmaps \"bar\" not found",
"reason": "NotFound",
"details": {
"name": "bar",
"kind": "configmaps"
},
"code": 404
}
15 changes: 15 additions & 0 deletions spec/fixtures/api/configmaps-bar.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"apiVersion": "v1",
"data": {
"foo": "bar"
},
"kind": "ConfigMap",
"metadata": {
"creationTimestamp": "2018-08-13T08:07:22Z",
"name": "bar",
"namespace": "default",
"resourceVersion": "3718",
"selfLink": "/api/v1/namespaces/default/configmaps/bar",
"uid": "e8293208-9ecf-11e8-b464-76d458035df6"
}
}
30 changes: 30 additions & 0 deletions spec/fixtures/api/services-foo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"creationTimestamp": "2018-08-13T08:06:38Z",
"name": "foo",
"namespace": "default",
"resourceVersion": "3646",
"selfLink": "/api/v1/namespaces/default/services/foo",
"uid": "cdc7144a-9ecf-11e8-b464-76d458035df6"
},
"spec": {
"clusterIP": "10.250.83.45",
"ports": [
{
"port": 80,
"protocol": "TCP",
"targetPort": 80
}
],
"selector": {
"app": "foo"
},
"sessionAffinity": "None",
"type": "ClusterIP"
},
"status": {
"loadBalancer": {}
}
}
7 changes: 7 additions & 0 deletions spec/fixtures/resources/configmap-bar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ConfigMap
metadata:
namespace: default
name: bar
data:
foo: bar
11 changes: 11 additions & 0 deletions spec/fixtures/resources/crd-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: tests.pharos-test.k8s.io
spec:
group: pharos-test.k8s.io
version: v0
names:
kind: Test
plural: tests
scope: Namespaced
11 changes: 11 additions & 0 deletions spec/fixtures/resources/service-foo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
namespace: default
name: foo
spec:
selector:
app: foo
type: ClusterIP
ports:
- port: 80
7 changes: 7 additions & 0 deletions spec/fixtures/resources/test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
apiVersion: pharos-test.k8s.io/v0
kind: Test
metadata:
name: test
namespace: default
spec:
test: true
113 changes: 113 additions & 0 deletions spec/pharos/kube/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -237,5 +237,118 @@
end
end
end

describe '#get_resources' do
context "for standard resources" do
let(:resources) {[
K8s::Resource.from_file(fixture_path('resources/service-foo.yaml')),
K8s::Resource.from_file(fixture_path('resources/configmap-bar.yaml')),
]}

context "which already exist" do
before do
stub_request(:get, 'localhost:8080/api/v1/namespaces/default/services/foo')
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: fixture('api/services-foo.json'),
)
stub_request(:get, 'localhost:8080/api/v1/namespaces/default/configmaps/bar')
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: fixture('api/configmaps-bar.json'),
)
end

it "prefetches API resources and pipelines the requests" do
expect(transport).to receive(:requests).once.with(
hash_including(path: '/api/v1'),
skip_missing: true,
response_class: anything,
).and_call_original
expect(transport).to receive(:requests).once.with(
hash_including(path: '/api/v1/namespaces/default/services/foo'),
hash_including(path: '/api/v1/namespaces/default/configmaps/bar'),
skip_missing: true,
).and_call_original

r = subject.get_resources(resources)

expect(r).to match [K8s::Resource, K8s::Resource]
expect(r[0].to_hash).to match hash_including(
apiVersion: 'v1',
kind: 'Service',
)
expect(r[1].to_hash).to match hash_including(
apiVersion: 'v1',
kind: 'ConfigMap',
)
end
end

context "which may be missing" do
before do
stub_request(:get, 'localhost:8080/api/v1/namespaces/default/services/foo')
.to_return(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: fixture('api/services-foo.json'),
)
stub_request(:get, 'localhost:8080/api/v1/namespaces/default/configmaps/bar')
.to_return(
status: 404,
headers: { 'Content-Type' => 'application/json' },
body: fixture('api/configmaps-bar-404.json'),
)
end

it "returns mixed nils" do
r = subject.get_resources(resources)

expect(r).to match [K8s::Resource, nil]
expect(r[0].to_hash).to match hash_including(
apiVersion: 'v1',
kind: 'Service',
)
expect(r[1]).to be nil
end
end
end

context "for custom resources" do
let(:resources) {[
K8s::Resource.from_file(fixture_path('resources/crd-test.yaml')),
K8s::Resource.from_file(fixture_path('resources/test.yaml')),
]}

context "which do not yet exist" do
before do
stub_request(:get, 'localhost:8080/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/tests.pharos-test.k8s.io')
.to_return(
status: 404,
headers: { 'Content-Type' => 'text/plain' },
body: '404 page not found',
)
stub_request(:get, 'localhost:8080/apis/pharos-test.k8s.io/v0')
.to_return(
status: 404,
headers: { 'Content-Type' => 'text/plain' },
body: '404 page not found',
)
stub_request(:get, 'localhost:8080/apis/pharos-test.k8s.io/v0/namespaces/default/tests/test')
.to_return(
status: 404,
)
end

it "returns nils" do
r = subject.get_resources(resources)

expect(r).to match [nil, nil]
end
end
end
end
end
end
23 changes: 23 additions & 0 deletions spec/pharos/kube/util_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
RSpec.describe K8s::Util do
describe '#compact_map' do
it "returns an empty array for an empty array" do
expect(described_class.compact_map([]) { |args| args.map(&:to_s) }).to eq []
end

it "returns an array of nil for an array of nil" do
expect(described_class.compact_map([nil]) { |args| args.map(&:to_s) }).to eq [nil]
end

it "translates non-nil values" do
expect(described_class.compact_map([1]) { |args| args.map(&:to_s) }).to eq ['1']
end

it "translates non-nil values and passes through nil values" do
expect(described_class.compact_map([nil, 1]) { |args| args.map(&:to_s) }).to eq [nil, '1']
end

it "handles duplicates" do
expect(described_class.compact_map([nil, 1, 2, 1]) { |args| args.map(&:to_s) }).to eq [nil, '1', '2', '1']
end
end
end
Loading