Merge pull request #14700 from liggitt/kubelet_authz

Auto commit by PR queue bot
This commit is contained in:
k8s-merge-robot 2015-10-09 03:01:43 -07:00
commit b793c3edf1
8 changed files with 351 additions and 23 deletions

View File

@ -147,8 +147,8 @@ type KubeletServer struct {
type KubeletBootstrap interface {
BirthCry()
StartGarbageCollection()
ListenAndServe(net.IP, uint, *kubelet.TLSOptions, bool)
ListenAndServeReadOnly(net.IP, uint)
ListenAndServe(address net.IP, port uint, tlsOptions *kubelet.TLSOptions, auth kubelet.AuthInterface, enableDebuggingHandlers bool)
ListenAndServeReadOnly(address net.IP, port uint)
Run(<-chan kubeletTypes.PodUpdate)
RunOnce(<-chan kubeletTypes.PodUpdate) ([]kubelet.RunPodResult, error)
}
@ -216,7 +216,7 @@ func (s *KubeletServer) AddFlags(fs *pflag.FlagSet) {
fs.BoolVar(&s.EnableServer, "enable-server", s.EnableServer, "Enable the Kubelet's server")
fs.IPVar(&s.Address, "address", s.Address, "The IP address for the Kubelet to serve on (set to 0.0.0.0 for all interfaces)")
fs.UintVar(&s.Port, "port", s.Port, "The port for the Kubelet to serve on. Note that \"kubectl logs\" will not work if you set this flag.") // see #9325
fs.UintVar(&s.ReadOnlyPort, "read-only-port", s.ReadOnlyPort, "The read-only port for the Kubelet to serve on (set to 0 to disable)")
fs.UintVar(&s.ReadOnlyPort, "read-only-port", s.ReadOnlyPort, "The read-only port for the Kubelet to serve on with no authentication/authorization (set to 0 to disable)")
fs.StringVar(&s.TLSCertFile, "tls-cert-file", s.TLSCertFile, ""+
"File containing x509 Certificate for HTTPS. (CA cert, if any, concatenated after server cert). "+
"If --tls-cert-file and --tls-private-key-file are not provided, a self-signed certificate and key "+
@ -281,9 +281,9 @@ func (s *KubeletServer) AddFlags(fs *pflag.FlagSet) {
fs.Uint64Var(&s.MaxOpenFiles, "max-open-files", 1000000, "Number of files that can be opened by Kubelet process. [default=1000000]")
}
// KubeletConfig returns a KubeletConfig suitable for being run, or an error if the server setup
// is not valid. It will not start any background processes.
func (s *KubeletServer) KubeletConfig() (*KubeletConfig, error) {
// UnsecuredKubeletConfig returns a KubeletConfig suitable for being run, or an error if the server setup
// is not valid. It will not start any background processes, and does not include authentication/authorization
func (s *KubeletServer) UnsecuredKubeletConfig() (*KubeletConfig, error) {
hostNetworkSources, err := kubeletTypes.GetValidatedSources(strings.Split(s.HostNetworkSources, ","))
if err != nil {
return nil, err
@ -345,6 +345,7 @@ func (s *KubeletServer) KubeletConfig() (*KubeletConfig, error) {
return &KubeletConfig{
Address: s.Address,
AllowPrivileged: s.AllowPrivileged,
Auth: nil, // default does not enforce auth[nz]
CAdvisorInterface: nil, // launches background processes, not set here
CgroupRoot: s.CgroupRoot,
Cloud: nil, // cloud provider might start background processes
@ -413,7 +414,7 @@ func (s *KubeletServer) KubeletConfig() (*KubeletConfig, error) {
// will be ignored.
func (s *KubeletServer) Run(kcfg *KubeletConfig) error {
if kcfg == nil {
cfg, err := s.KubeletConfig()
cfg, err := s.UnsecuredKubeletConfig()
if err != nil {
return err
}
@ -747,7 +748,7 @@ func startKubelet(k KubeletBootstrap, podCfg *config.PodConfig, kc *KubeletConfi
// start the kubelet server
if kc.EnableServer {
go util.Until(func() {
k.ListenAndServe(kc.Address, kc.Port, kc.TLSOptions, kc.EnableDebuggingHandlers)
k.ListenAndServe(kc.Address, kc.Port, kc.TLSOptions, kc.Auth, kc.EnableDebuggingHandlers)
}, 0, util.NeverStop)
}
if kc.ReadOnlyPort > 0 {
@ -784,6 +785,7 @@ func makePodSourceConfig(kc *KubeletConfig) *config.PodConfig {
type KubeletConfig struct {
Address net.IP
AllowPrivileged bool
Auth kubelet.AuthInterface
Builder KubeletBuilder
CAdvisorInterface cadvisor.Interface
CgroupRoot string

View File

@ -433,7 +433,7 @@ type kubeletExecutor struct {
clientConfig *client.Config
}
func (kl *kubeletExecutor) ListenAndServe(address net.IP, port uint, tlsOptions *kubelet.TLSOptions, enableDebuggingHandlers bool) {
func (kl *kubeletExecutor) ListenAndServe(address net.IP, port uint, tlsOptions *kubelet.TLSOptions, auth kubelet.AuthInterface, enableDebuggingHandlers bool) {
// this func could be called many times, depending how often the HTTP server crashes,
// so only execute certain initialization procs once
kl.initialize.Do(func() {
@ -445,7 +445,7 @@ func (kl *kubeletExecutor) ListenAndServe(address net.IP, port uint, tlsOptions
}()
})
log.Infof("Starting kubelet server...")
kubelet.ListenAndServeKubeletServer(kl, address, port, tlsOptions, enableDebuggingHandlers)
kubelet.ListenAndServeKubeletServer(kl, address, port, tlsOptions, auth, enableDebuggingHandlers)
}
// runs the main kubelet loop, closing the kubeletFinished chan when the loop exits.

View File

@ -104,7 +104,7 @@ HTTP server: The kubelet can also listen for HTTP and respond to a simple API
--pod-cidr="": The CIDR to use for pod IP addresses, only used in standalone mode. In cluster mode, this is obtained from the master.
--pod-infra-container-image="": The image whose network/ipc namespaces containers in each pod will use.
--port=0: The port for the Kubelet to serve on. Note that "kubectl logs" will not work if you set this flag.
--read-only-port=0: The read-only port for the Kubelet to serve on (set to 0 to disable)
--read-only-port=0: The read-only port for the Kubelet to serve on with no authentication/authorization (set to 0 to disable)
--really-crash-for-testing=false: If true, when panics occur crash. Intended for testing.
--register-node=false: Register the node with the apiserver (defaults to true if --api-server is set)
--registry-burst=0: Maximum size of a bursty pulls, temporarily allows pulls to burst to this number, while still not exceeding registry-qps. Only used if --registry-qps > 0

View File

@ -17,6 +17,8 @@ limitations under the License.
package authorizer
import (
"net/http"
"k8s.io/kubernetes/pkg/auth/user"
)
@ -63,6 +65,11 @@ func (f AuthorizerFunc) Authorize(a Attributes) error {
return f(a)
}
// RequestAttributesGetter provides a function that extracts Attributes from an http.Request
type RequestAttributesGetter interface {
GetRequestAttributes(user.Info, *http.Request) Attributes
}
// AttributesRecord implements Attributes interface.
type AttributesRecord struct {
User user.Info

37
pkg/kubelet/auth.go Normal file
View File

@ -0,0 +1,37 @@
/*
Copyright 2015 The Kubernetes Authors All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package kubelet
import (
"k8s.io/kubernetes/pkg/auth/authenticator"
"k8s.io/kubernetes/pkg/auth/authorizer"
)
// KubeletAuth implements AuthInterface
type KubeletAuth struct {
// authenticator identifies the user for requests to the Kubelet API
authenticator.Request
// authorizerAttributeGetter builds authorization.Attributes for a request to the Kubelet API
authorizer.RequestAttributesGetter
// authorizer determines whether a given authorization.Attributes is allowed
authorizer.Authorizer
}
// NewKubeletAuth returns a kubelet.AuthInterface composed of the given authenticator, attribute getter, and authorizer
func NewKubeletAuth(authenticator authenticator.Request, authorizerAttributeGetter authorizer.RequestAttributesGetter, authorizer authorizer.Authorizer) AuthInterface {
return &KubeletAuth{authenticator, authorizerAttributeGetter, authorizer}
}

View File

@ -2786,8 +2786,8 @@ func (kl *Kubelet) GetCachedMachineInfo() (*cadvisorApi.MachineInfo, error) {
return kl.machineInfo, nil
}
func (kl *Kubelet) ListenAndServe(address net.IP, port uint, tlsOptions *TLSOptions, enableDebuggingHandlers bool) {
ListenAndServeKubeletServer(kl, address, port, tlsOptions, enableDebuggingHandlers)
func (kl *Kubelet) ListenAndServe(address net.IP, port uint, tlsOptions *TLSOptions, auth AuthInterface, enableDebuggingHandlers bool) {
ListenAndServeKubeletServer(kl, address, port, tlsOptions, auth, enableDebuggingHandlers)
}
func (kl *Kubelet) ListenAndServeReadOnly(address net.IP, port uint) {

View File

@ -35,12 +35,15 @@ import (
"github.com/golang/glog"
cadvisorApi "github.com/google/cadvisor/info/v1"
"github.com/prometheus/client_golang/prometheus"
"k8s.io/kubernetes/pkg/api"
apierrs "k8s.io/kubernetes/pkg/api/errors"
"k8s.io/kubernetes/pkg/api/latest"
"k8s.io/kubernetes/pkg/api/unversioned"
"k8s.io/kubernetes/pkg/api/v1"
"k8s.io/kubernetes/pkg/api/validation"
"k8s.io/kubernetes/pkg/auth/authenticator"
"k8s.io/kubernetes/pkg/auth/authorizer"
"k8s.io/kubernetes/pkg/healthz"
"k8s.io/kubernetes/pkg/httplog"
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
@ -54,8 +57,9 @@ import (
// Server is a http.Handler which exposes kubelet functionality over HTTP.
type Server struct {
auth AuthInterface
host HostInterface
restfulCont *restful.Container
restfulCont containerInterface
}
type TLSOptions struct {
@ -64,10 +68,38 @@ type TLSOptions struct {
KeyFile string
}
// containerInterface defines the restful.Container functions used on the root container
type containerInterface interface {
Add(service *restful.WebService) *restful.Container
Handle(path string, handler http.Handler)
Filter(filter restful.FilterFunction)
ServeHTTP(w http.ResponseWriter, r *http.Request)
RegisteredWebServices() []*restful.WebService
// RegisteredHandlePaths returns the paths of handlers registered directly with the container (non-web-services)
// Used to test filters are being applied on non-web-service handlers
RegisteredHandlePaths() []string
}
// filteringContainer delegates all Handle(...) calls to Container.HandleWithFilter(...),
// so we can ensure restful.FilterFunctions are used for all handlers
type filteringContainer struct {
*restful.Container
registeredHandlePaths []string
}
func (a *filteringContainer) Handle(path string, handler http.Handler) {
a.HandleWithFilter(path, handler)
a.registeredHandlePaths = append(a.registeredHandlePaths, path)
}
func (a *filteringContainer) RegisteredHandlePaths() []string {
return a.registeredHandlePaths
}
// ListenAndServeKubeletServer initializes a server to respond to HTTP network requests on the Kubelet.
func ListenAndServeKubeletServer(host HostInterface, address net.IP, port uint, tlsOptions *TLSOptions, enableDebuggingHandlers bool) {
func ListenAndServeKubeletServer(host HostInterface, address net.IP, port uint, tlsOptions *TLSOptions, auth AuthInterface, enableDebuggingHandlers bool) {
glog.Infof("Starting to listen on %s:%d", address, port)
handler := NewServer(host, enableDebuggingHandlers)
handler := NewServer(host, auth, enableDebuggingHandlers)
s := &http.Server{
Addr: net.JoinHostPort(address.String(), strconv.FormatUint(uint64(port), 10)),
Handler: &handler,
@ -84,8 +116,7 @@ func ListenAndServeKubeletServer(host HostInterface, address net.IP, port uint,
// ListenAndServeKubeletReadOnlyServer initializes a server to respond to HTTP network requests on the Kubelet.
func ListenAndServeKubeletReadOnlyServer(host HostInterface, address net.IP, port uint) {
glog.V(1).Infof("Starting to listen read-only on %s:%d", address, port)
s := NewServer(host, false)
s.restfulCont.Handle("/metrics", prometheus.Handler())
s := NewServer(host, nil, false)
server := &http.Server{
Addr: net.JoinHostPort(address.String(), strconv.FormatUint(uint64(port), 10)),
@ -95,6 +126,13 @@ func ListenAndServeKubeletReadOnlyServer(host HostInterface, address net.IP, por
glog.Fatal(server.ListenAndServe())
}
// AuthInterface contains all methods required by the auth filters
type AuthInterface interface {
authenticator.Request
authorizer.RequestAttributesGetter
authorizer.Authorizer
}
// HostInterface contains all the kubelet methods required by the server.
// For testablitiy.
type HostInterface interface {
@ -118,10 +156,14 @@ type HostInterface interface {
}
// NewServer initializes and configures a kubelet.Server object to handle HTTP requests.
func NewServer(host HostInterface, enableDebuggingHandlers bool) Server {
func NewServer(host HostInterface, auth AuthInterface, enableDebuggingHandlers bool) Server {
server := Server{
host: host,
restfulCont: restful.NewContainer(),
auth: auth,
restfulCont: &filteringContainer{Container: restful.NewContainer()},
}
if auth != nil {
server.InstallAuthFilter()
}
server.InstallDefaultHandlers()
if enableDebuggingHandlers {
@ -130,6 +172,37 @@ func NewServer(host HostInterface, enableDebuggingHandlers bool) Server {
return server
}
// InstallAuthFilter installs authentication filters with the restful Container.
func (s *Server) InstallAuthFilter() {
s.restfulCont.Filter(func(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {
// Authenticate
u, ok, err := s.auth.AuthenticateRequest(req.Request)
if err != nil {
glog.Errorf("Unable to authenticate the request due to an error: %v", err)
resp.WriteErrorString(http.StatusUnauthorized, "Unauthorized")
return
}
if !ok {
resp.WriteErrorString(http.StatusUnauthorized, "Unauthorized")
return
}
// Get authorization attributes
attrs := s.auth.GetRequestAttributes(u, req.Request)
// Authorize
if err := s.auth.Authorize(attrs); err != nil {
msg := fmt.Sprintf("Forbidden (user=%s, verb=%s, namespace=%s, resource=%s)", u.GetName(), attrs.GetVerb(), attrs.GetNamespace(), attrs.GetResource())
glog.V(2).Info(msg)
resp.WriteErrorString(http.StatusForbidden, msg)
return
}
// Continue
chain.ProcessFilter(req, resp)
})
}
// InstallDefaultHandlers registers the default set of supported HTTP request
// patterns with the restful Container.
func (s *Server) InstallDefaultHandlers() {
@ -149,6 +222,7 @@ func (s *Server) InstallDefaultHandlers() {
s.restfulCont.Add(ws)
s.restfulCont.Handle("/stats/", &httpHandler{f: s.handleStats})
s.restfulCont.Handle("/metrics", prometheus.Handler())
ws = new(restful.WebService)
ws.
@ -227,8 +301,6 @@ func (s *Server) InstallDebuggingHandlers() {
Operation("getContainerLogs"))
s.restfulCont.Add(ws)
s.restfulCont.Handle("/metrics", prometheus.Handler())
handlePprofEndpoint := func(req *restful.Request, resp *restful.Response) {
name := strings.TrimPrefix(req.Request.URL.Path, pprofBasePath)
switch name {

View File

@ -19,6 +19,7 @@ package kubelet
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
@ -35,12 +36,15 @@ import (
cadvisorApi "github.com/google/cadvisor/info/v1"
"k8s.io/kubernetes/pkg/api"
apierrs "k8s.io/kubernetes/pkg/api/errors"
"k8s.io/kubernetes/pkg/auth/authorizer"
"k8s.io/kubernetes/pkg/auth/user"
kubecontainer "k8s.io/kubernetes/pkg/kubelet/container"
"k8s.io/kubernetes/pkg/kubelet/dockertools"
kubeletTypes "k8s.io/kubernetes/pkg/kubelet/types"
"k8s.io/kubernetes/pkg/types"
"k8s.io/kubernetes/pkg/util/httpstream"
"k8s.io/kubernetes/pkg/util/httpstream/spdy"
"k8s.io/kubernetes/pkg/util/sets"
)
type fakeKubelet struct {
@ -131,15 +135,38 @@ func (fk *fakeKubelet) StreamingConnectionIdleTimeout() time.Duration {
return fk.streamingConnectionIdleTimeoutFunc()
}
type fakeAuth struct {
authenticateFunc func(*http.Request) (user.Info, bool, error)
attributesFunc func(user.Info, *http.Request) authorizer.Attributes
authorizeFunc func(authorizer.Attributes) (err error)
}
func (f *fakeAuth) AuthenticateRequest(req *http.Request) (user.Info, bool, error) {
return f.authenticateFunc(req)
}
func (f *fakeAuth) GetRequestAttributes(u user.Info, req *http.Request) authorizer.Attributes {
return f.attributesFunc(u, req)
}
func (f *fakeAuth) Authorize(a authorizer.Attributes) (err error) {
return f.authorizeFunc(a)
}
type serverTestFramework struct {
serverUnderTest *Server
fakeKubelet *fakeKubelet
fakeAuth *fakeAuth
testHTTPServer *httptest.Server
}
func newServerTest() *serverTestFramework {
fw := &serverTestFramework{}
fw.fakeKubelet = &fakeKubelet{
containerVersionFunc: func() (kubecontainer.Version, error) {
return dockertools.NewVersion("1.15")
},
hostnameFunc: func() string {
return "127.0.0.1"
},
podByNameFunc: func(namespace, name string) (*api.Pod, bool) {
return &api.Pod{
ObjectMeta: api.ObjectMeta{
@ -149,7 +176,18 @@ func newServerTest() *serverTestFramework {
}, true
},
}
server := NewServer(fw.fakeKubelet, true)
fw.fakeAuth = &fakeAuth{
authenticateFunc: func(req *http.Request) (user.Info, bool, error) {
return &user.DefaultInfo{Name: "test"}, true, nil
},
attributesFunc: func(u user.Info, req *http.Request) authorizer.Attributes {
return &authorizer.AttributesRecord{User: u}
},
authorizeFunc: func(a authorizer.Attributes) (err error) {
return nil
},
}
server := NewServer(fw.fakeKubelet, fw.fakeAuth, true)
fw.serverUnderTest = &server
fw.testHTTPServer = httptest.NewServer(fw.serverUnderTest)
return fw
@ -502,6 +540,178 @@ func assertHealthFails(t *testing.T, httpURL string, expectedErrorCode int) {
}
}
type authTestCase struct {
Method string
Path string
}
func TestAuthFilters(t *testing.T) {
fw := newServerTest()
testcases := []authTestCase{}
// This is a sanity check that the Handle->HandleWithFilter() delegation is working
// Ideally, these would move to registered web services and this list would get shorter
expectedPaths := []string{"/healthz", "/stats/", "/metrics"}
paths := sets.NewString(fw.serverUnderTest.restfulCont.RegisteredHandlePaths()...)
for _, expectedPath := range expectedPaths {
if !paths.Has(expectedPath) {
t.Errorf("Expected registered handle path %s was missing", expectedPath)
}
}
// Test all the non-web-service handlers
for _, path := range fw.serverUnderTest.restfulCont.RegisteredHandlePaths() {
testcases = append(testcases, authTestCase{"GET", path})
testcases = append(testcases, authTestCase{"POST", path})
// Test subpaths for directory handlers
if strings.HasSuffix(path, "/") {
testcases = append(testcases, authTestCase{"GET", path + "foo"})
testcases = append(testcases, authTestCase{"POST", path + "foo"})
}
}
// Test all the generated web-service paths
for _, ws := range fw.serverUnderTest.restfulCont.RegisteredWebServices() {
for _, r := range ws.Routes() {
testcases = append(testcases, authTestCase{r.Method, r.Path})
}
}
for _, tc := range testcases {
var (
expectedUser = &user.DefaultInfo{Name: "test"}
expectedAttributes = &authorizer.AttributesRecord{User: expectedUser}
calledAuthenticate = false
calledAuthorize = false
calledAttributes = false
)
fw.fakeAuth.authenticateFunc = func(req *http.Request) (user.Info, bool, error) {
calledAuthenticate = true
return expectedUser, true, nil
}
fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes {
calledAttributes = true
if u != expectedUser {
t.Fatalf("%s: expected user %v, got %v", tc.Path, expectedUser, u)
}
return expectedAttributes
}
fw.fakeAuth.authorizeFunc = func(a authorizer.Attributes) (err error) {
calledAuthorize = true
if a != expectedAttributes {
t.Fatalf("%s: expected attributes %v, got %v", tc.Path, expectedAttributes, a)
}
return errors.New("Forbidden")
}
req, err := http.NewRequest(tc.Method, fw.testHTTPServer.URL+tc.Path, nil)
if err != nil {
t.Errorf("%s: unexpected error: %v", tc.Path, err)
continue
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Errorf("%s: unexpected error: %v", tc.Path, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s: unexpected status code %d", tc.Path, resp.StatusCode)
continue
}
if !calledAuthenticate {
t.Errorf("%s: Authenticate was not called", tc.Path)
continue
}
if !calledAttributes {
t.Errorf("%s: Attributes were not called", tc.Path)
continue
}
if !calledAuthorize {
t.Errorf("%s: Authorize was not called", tc.Path)
continue
}
}
}
func TestAuthenticationFailure(t *testing.T) {
var (
expectedUser = &user.DefaultInfo{Name: "test"}
expectedAttributes = &authorizer.AttributesRecord{User: expectedUser}
calledAuthenticate = false
calledAuthorize = false
calledAttributes = false
)
fw := newServerTest()
fw.fakeAuth.authenticateFunc = func(req *http.Request) (user.Info, bool, error) {
calledAuthenticate = true
return nil, false, nil
}
fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes {
calledAttributes = true
return expectedAttributes
}
fw.fakeAuth.authorizeFunc = func(a authorizer.Attributes) (err error) {
calledAuthorize = true
return errors.New("not allowed")
}
assertHealthFails(t, fw.testHTTPServer.URL+"/healthz", http.StatusUnauthorized)
if !calledAuthenticate {
t.Fatalf("Authenticate was not called")
}
if calledAttributes {
t.Fatalf("Attributes was called unexpectedly")
}
if calledAuthorize {
t.Fatalf("Authorize was called unexpectedly")
}
}
func TestAuthorizationSuccess(t *testing.T) {
var (
expectedUser = &user.DefaultInfo{Name: "test"}
expectedAttributes = &authorizer.AttributesRecord{User: expectedUser}
calledAuthenticate = false
calledAuthorize = false
calledAttributes = false
)
fw := newServerTest()
fw.fakeAuth.authenticateFunc = func(req *http.Request) (user.Info, bool, error) {
calledAuthenticate = true
return expectedUser, true, nil
}
fw.fakeAuth.attributesFunc = func(u user.Info, req *http.Request) authorizer.Attributes {
calledAttributes = true
return expectedAttributes
}
fw.fakeAuth.authorizeFunc = func(a authorizer.Attributes) (err error) {
calledAuthorize = true
return nil
}
assertHealthIsOk(t, fw.testHTTPServer.URL+"/healthz")
if !calledAuthenticate {
t.Fatalf("Authenticate was not called")
}
if !calledAttributes {
t.Fatalf("Attributes were not called")
}
if !calledAuthorize {
t.Fatalf("Authorize was not called")
}
}
func TestSyncLoopCheck(t *testing.T) {
fw := newServerTest()
fw.fakeKubelet.containerVersionFunc = func() (kubecontainer.Version, error) {