diff --git a/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs new file mode 100644 index 000000000..402ae1924 --- /dev/null +++ b/src/KubernetesClient/Fluent/Kubernetes.Fluent.cs @@ -0,0 +1,49 @@ +using System; +using System.Net.Http; +using k8s.Models; + +namespace k8s.Fluent +{ + public static class KubernetesFluent + { + /// Creates a new Kubernetes object of the given type and sets its and + /// . + /// + public static T New(this Kubernetes client) where T : IKubernetesObject, new() => client.Scheme.New(); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , and . + /// + public static T New(this Kubernetes client, string name) where T : IKubernetesObject, new() => client.Scheme.New(name); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , , and . + /// + public static T New(this Kubernetes client, string ns, string name) where T : IKubernetesObject, new() => client.Scheme.New(ns, name); + + /// Creates a new using the given + /// ( by default). + /// + public static KubernetesRequest Request(this Kubernetes client, HttpMethod method = null) => new KubernetesRequest(client).Method(method); + + /// Creates a new using the given + /// and resource URI components. + /// + public static KubernetesRequest Request(this Kubernetes client, + HttpMethod method, string type = null, string ns = null, string name = null, string group = null, string version = null) => + new KubernetesRequest(client).Method(method).Group(group).Version(version).Type(type).Namespace(ns).Name(name); + + /// Creates a new to access the given type of object. + public static KubernetesRequest Request(this Kubernetes client, Type type) => new KubernetesRequest(client).GVK(type); + + /// Creates a new to access the given type of object with an optional name and namespace. + public static KubernetesRequest Request(this Kubernetes client, HttpMethod method, Type type, string ns = null, string name = null) => + Request(client, method).GVK(type).Namespace(ns).Name(name); + + /// Creates a new to access the given type of object with an optional name and namespace. + public static KubernetesRequest Request(this Kubernetes client, string ns = null, string name = null) => Request(client, null, typeof(T), ns, name); + + /// Creates a new to access the given object. + public static KubernetesRequest Request(this Kubernetes client, IKubernetesObject obj, bool setBody = true) => new KubernetesRequest(client).Set(obj, setBody); + } +} diff --git a/src/KubernetesClient/Fluent/KubernetesRequest.cs b/src/KubernetesClient/Fluent/KubernetesRequest.cs new file mode 100644 index 000000000..4662b4f6e --- /dev/null +++ b/src/KubernetesClient/Fluent/KubernetesRequest.cs @@ -0,0 +1,686 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using k8s.Models; +using Microsoft.Rest; +using Newtonsoft.Json; + +namespace k8s.Fluent +{ + /// Represents a single request to Kubernetes. + public sealed class KubernetesRequest : ICloneable + { + /// Initializes a based on a . + public KubernetesRequest(Kubernetes client) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + (baseUri, credentials, this.client) = (client.BaseUri.ToString(), client.Credentials, client.HttpClient); + Scheme(client.Scheme); + } + + /// Initializes a based on a and + /// an . + /// + /// The used to connect to Kubernetes + /// The used to make the request, or null to use the default client. The default + /// is null. + /// + /// The used to map types to their Kubernetes group, version, and kind, or + /// null to use the default scheme. The default is null. + /// + /// Any necessary SSL configuration must have already been applied to the . + public KubernetesRequest(KubernetesClientConfiguration config, HttpClient client = null, KubernetesScheme scheme = null) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + this.baseUri = config.Host; + if (string.IsNullOrEmpty(this.baseUri)) throw new ArgumentException(nameof(config)+".Host"); + credentials = Kubernetes.CreateCredentials(config); + this.client = client ?? new HttpClient(); + Scheme(scheme); + } + + /// Initializes a based on a and an . + /// The absolute base URI of the Kubernetes API server + /// The used to connect to Kubernetes, or null if no credentials + /// of that type are needed. The default is null. + /// + /// The used to make the request, or null to use the default client. The default + /// is null. + /// + /// The used to map types to their Kubernetes group, version, and kind, or + /// null to use the default scheme. The default is null. + /// + /// Any necessary SSL configuration must have already been applied to the . + public KubernetesRequest(Uri baseUri, ServiceClientCredentials credentials = null, HttpClient client = null, KubernetesScheme scheme = null) + { + if (baseUri == null) throw new ArgumentNullException(nameof(baseUri)); + if (!baseUri.IsAbsoluteUri) throw new ArgumentException("The base URI must be absolute.", nameof(baseUri)); + (this.baseUri, this.credentials, this.client) = (baseUri.ToString(), credentials, client = client ?? new HttpClient()); + Scheme(scheme); + } + + /// Gets the value of the Accept header, or null to use the default of application/json. + public string Accept() => _accept; + + /// Sets the value of the Accept header, or null or empty to use the default of application/json. + public KubernetesRequest Accept(string mediaType) { _accept = NormalizeEmpty(mediaType); return this; } + + /// Adds a header to the request. Multiple header values with the same name can be set this way. + public KubernetesRequest AddHeader(string key, string value) => Add(ref headers, CheckHeaderName(key), value); + + /// Adds a header to the request. + public KubernetesRequest AddHeader(string key, IEnumerable values) => Add(ref headers, CheckHeaderName(key), values); + + /// Adds a header to the request. + public KubernetesRequest AddHeader(string key, params string[] values) => Add(ref headers, CheckHeaderName(key), values); + + /// Adds a query-string parameter to the request. Multiple parameters with the same name can be set this way. + public KubernetesRequest AddQuery(string key, string value) => Add(ref query, key, value); + + /// Adds query-string parameters to the request. + public KubernetesRequest AddQuery(string key, IEnumerable values) => Add(ref query, key, values); + + /// Adds query-string parameters to the request. + public KubernetesRequest AddQuery(string key, params string[] values) => Add(ref query, key, values); + + /// Gets the body to be sent to the server. + public object Body() => _body; + + /// Sets the body to be sent to the server. If null, no body will be sent. If a string, byte array, or stream, the + /// contents will be sent directly. Otherwise, the body will be serialized into JSON and sent. + /// + public KubernetesRequest Body(object body) { _body = body; return this; } + + /// Clears custom header values with the given name. + public KubernetesRequest ClearHeader(string headerName) + { + if (headerName == null) throw new ArgumentNullException(nameof(headerName)); + CheckHeaderName(headerName); + if (headers != null) headers.Remove(headerName); + return this; + } + + /// Clears all custom header values. + public KubernetesRequest ClearHeaders() + { + if (headers != null) headers.Clear(); + return this; + } + + /// Clears all query-string parameters. + public KubernetesRequest ClearQuery() + { + if (query != null) query.Clear(); + return this; + } + + /// Clears all query-string parameters with the given key. + public KubernetesRequest ClearQuery(string key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (query != null) query.Remove(key); + return this; + } + + /// Creates a deep copy of the . + public KubernetesRequest Clone() + { + var clone = (KubernetesRequest)MemberwiseClone(); + if (headers != null) + { + clone.headers = new Dictionary>(headers.Count); + foreach (KeyValuePair> pair in headers) clone.headers.Add(pair.Key, new List(pair.Value)); + } + if (query != null) + { + clone.query = new Dictionary>(query.Count); + foreach (KeyValuePair> pair in query) clone.query.Add(pair.Key, new List(pair.Value)); + } + return clone; + } + + /// Sets the to . + public KubernetesRequest Delete() => Method(HttpMethod.Delete); + + /// Sets the to . + public KubernetesRequest Get() => Method(HttpMethod.Get); + + /// Sets the to . + public KubernetesRequest Patch() => Method(new HttpMethod("PATCH")); + + /// Sets the to . + public KubernetesRequest Post() => Method(HttpMethod.Post); + + /// Sets the to . + public KubernetesRequest Put() => Method(HttpMethod.Put); + + /// Sets the value of the "dryRun" query-string parameter, as a boolean. + public bool DryRun() => !string.IsNullOrEmpty(GetQuery("dryRun")); + + /// Sets the value of the "dryRun" query-string parameter to "All" or removes it. + public KubernetesRequest DryRun(bool dryRun) => SetQuery("dryRun", dryRun ? "All" : null); + + /// Executes the request and returns a . The request can be executed multiple times, + /// and can be executed multiple times in parallel. + /// + public async Task ExecuteAsync(CancellationToken cancelToken = default) + { + cancelToken.ThrowIfCancellationRequested(); + HttpRequestMessage req = await CreateRequestMessage(cancelToken).ConfigureAwait(false); + // requests like watches may not send a body immediately, so return as soon as we've got the response headers + var completion = _streamResponse || _watchVersion != null ? + HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; + return new KubernetesResponse(await client.SendAsync(req, completion, cancelToken).ConfigureAwait(false)); + } + + /// Executes the request and returns the deserialized response body (or the default value of type + /// if the response was 404 Not Found). + /// + /// Thrown if the response was any error besides 404 Not Found. + public Task ExecuteAsync(CancellationToken cancelToken = default) => ExecuteAsync(false, cancelToken); + + /// Executes the request and returns the deserialized response body. + /// If true and the response is 404 Not Found, an exception will be thrown. If false, the default + /// value of type will be returned in that case. The default is false. + /// + /// A that can be used to cancel the request + /// Thrown if the response was any error besides 404 Not Found. + public async Task ExecuteAsync(bool failIfMissing, CancellationToken cancelToken = default) + { + if (_watchVersion != null) throw new InvalidOperationException("Watch requests cannot be deserialized all at once."); + cancelToken.ThrowIfCancellationRequested(); + HttpRequestMessage reqMsg = await CreateRequestMessage(cancelToken).ConfigureAwait(false); + return await ExecuteMessageAsync(reqMsg, failIfMissing, cancelToken).ConfigureAwait(false); + } + + /// Gets the "fieldManager" query-string parameter, or null if there is no field manager. + public string FieldManager() => NormalizeEmpty(GetQuery("fieldManager")); + + /// Sets the "fieldManager" query-string parameter, or removes it if the value is null or empty. + public KubernetesRequest FieldManager(string manager) => + SetQuery("fieldManager", !string.IsNullOrEmpty(manager) ? manager : null); + + /// Gets the "fieldSelector" query-string parameter, or null if there is no field selector. + public string FieldSelector() => NormalizeEmpty(GetQuery("fieldSelector")); + + /// Sets the "fieldSelector" query-string parameter, or removes it if the selector is null or empty. + public KubernetesRequest FieldSelector(string selector) => + SetQuery("fieldSelector", !string.IsNullOrEmpty(selector) ? selector : null); + + /// Gets the value of the named custom header, or null if it doesn't exist. + /// Thrown if there are multiple custom headers with the given name + public string GetHeader(string key) + { + List values = null; + if (headers != null) headers.TryGetValue(key, out values); + return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] : + throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'."); + } + + /// Gets the values of the named custom header, or null if it has no values. + /// The returned collection, if not null, can be mutated to change the set of values. + public List GetHeaderValues(string key) + { + List values = null; + if (headers != null) headers.TryGetValue(key, out values); + return values; + } + + /// Gets the value of the named query-string parameter, or null if it doesn't exist. + /// Thrown if there are multiple query-string parameters with the given name + public string GetQuery(string key) + { + List values = GetQueryValues(key); + return values == null || values.Count == 0 ? null : values.Count == 1 ? values[0] : + throw new InvalidOperationException($"There are multiple query-string parameters named '{key}'."); + } + + /// Gets the values of the named query-string parameter, or null if it has no values. + /// The returned collection, if not null, can be mutated to change the set of values. + public List GetQueryValues(string key) + { + List values = null; + if (query != null) query.TryGetValue(key, out values); + return values; + } + + /// Gets the Kubernetes API group to use, or null or empty to use the default, which is the core API group + /// unless a is given. + /// + public string Group() => _group; + + /// Sets the Kubernetes API group to use, or null or empty to use the default, which is the core API group + /// unless a is given. + /// + public KubernetesRequest Group(string group) { _group = NormalizeEmpty(group); return this; } + + /// Attempts to set the , , and based on an object. + /// The method calls with the object's type. Then, if + /// is set, it will override and . + /// + public KubernetesRequest GVK(IKubernetesObject obj) + { + if (obj == null) throw new ArgumentNullException(); + GVK(obj.GetType()); + if (!string.IsNullOrEmpty(obj.ApiVersion)) // if the object has an API version set, use it... + { + int slash = obj.ApiVersion.IndexOf('/'); // the ApiVersion field is in the form "version" or "group/version" + Group(slash >= 0 ? obj.ApiVersion.Substring(0, slash) : null).Version(obj.ApiVersion.Substring(slash+1)); + } + return this; + } + + /// Attempts to set the , , and based on a Kubernetes + /// group, version, and kind. The method uses heuristics and may not work in all cases. + /// + public KubernetesRequest GVK(string group, string version, string kind) => + Group(!string.IsNullOrEmpty(group) ? group : null).Version(!string.IsNullOrEmpty(version) ? version : null) + .Type(KubernetesScheme.GuessPath(kind)); + + /// Attempts to set the , , and based on a type of object, + /// such as . + /// + public KubernetesRequest GVK(Type type) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + _scheme.GetGVK(type, out string group, out string version, out string kind, out string path); + return Group(NormalizeEmpty(group)).Version(version).Type(path); + } + + /// Attempts to set the , , and based on a type of object, + /// such as . + /// + public KubernetesRequest GVK() => GVK(typeof(T)); + + /// Gets the "labelSelector" query-string parameter, or null if there is no label selector. + public string LabelSelector() => NormalizeEmpty(GetQuery("labelSelector")); + + /// Sets the "labelSelector" query-string parameter, or removes it if the selecor is null or empty. + public KubernetesRequest LabelSelector(string selector) => + SetQuery("labelSelector", !string.IsNullOrEmpty(selector) ? selector : null); + + /// Gets the value of the Content-Type header, or null to use the default of application/json. + public string MediaType() => _mediaType; + + /// Sets the value of the Content-Type header, not including any parameters, or null or empty to use the default + /// of application/json. The header value will only be used if a is supplied. + /// + public KubernetesRequest MediaType(string mediaType) { _mediaType = NormalizeEmpty(mediaType); return this; } + + /// Gets the to use. + public HttpMethod Method() => _method ?? HttpMethod.Get; + + /// Sets the to use, or null to use the default of . + public KubernetesRequest Method(HttpMethod method) { _method = method; return this; } + + /// Gets the name of the top-level Kubernetes resource to access. + public string Name() => _name; + + /// Sets the name of the top-level Kubernetes resource to access, or null or empty to not access a specific object. + public KubernetesRequest Name(string name) { _name = NormalizeEmpty(name); return this; } + + /// Gets the Kubernetes namespace to access. + public string Namespace() => _ns; + + /// Sets the Kubernetes namespace to access, or null or empty to not access a namespaced object. + public KubernetesRequest Namespace(string ns) { _ns = NormalizeEmpty(ns); return this; } + + /// Gets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or + /// null if the URL will be constructed piecemeal based on the other properties. + /// + public string RawUri() => _rawUri; + + /// Sets the raw URL to access, relative to the configured Kubernetes host and not including the query string, or + /// null or empty to construct the URI piecemeal based on the other properties. The URI must begin with a slash. + /// + public KubernetesRequest RawUri(string uri) + { + uri = NormalizeEmpty(uri); + if (uri != null && uri[0] != '/') throw new ArgumentException("The URI must begin with a slash."); + _rawUri = uri; + return this; + } + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync(Func update, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken); + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync( + Func> update, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class, IMetadata => ReplaceAsync(null, update, failIfMissing, cancelToken); + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// The initial value of the resource, or null if it should be retrieved with a GET request + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public Task ReplaceAsync(T obj, Func modify, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class + { + if (modify == null) throw new ArgumentNullException(nameof(modify)); + return ReplaceAsync(obj, (o,ct) => Task.FromResult(modify(o)), failIfMissing, cancelToken); + } + + /// Performs an atomic get-modify-replace operation, using the GET method to read the object and the PUT method to + /// replace it. + /// + /// The initial value of the resource, or null if it should be retrieved with a GET request + /// A function that modifies the resource, returning true if any changes were made and false if not + /// If true, an exception will be thrown if the object doesn't exist. If false, null will be + /// returned in that case. + /// + /// A that can be used to cancel the request + public async Task ReplaceAsync( + T obj, Func> modify, bool failIfMissing = false, CancellationToken cancelToken = default) + where T : class + { + if (modify == null) throw new ArgumentNullException(nameof(modify)); + if (_watchVersion != null) throw new InvalidOperationException("Watches cannot be updated."); + KubernetesRequest req = Clone(); + while(true) + { + if(obj == null) // if we need to load the resource... + { + cancelToken.ThrowIfCancellationRequested(); // load it with a GET request + obj = await req.Get().Body(null).ExecuteAsync(failIfMissing, cancelToken).ConfigureAwait(false); + } + cancelToken.ThrowIfCancellationRequested(); + // if the resource is missing or no changes are needed, return it as-is. otherwise, update it with a PUT request + if(obj == null || !await modify(obj, cancelToken).ConfigureAwait(false)) return obj; + KubernetesResponse resp = await req.Put().Body(obj).ExecuteAsync(cancelToken).ConfigureAwait(false); + if(resp.StatusCode != HttpStatusCode.Conflict) // if there was no conflict, return the result + { + if(resp.IsNotFound && !failIfMissing) return null; + else if(resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); + else return await resp.GetBodyAsync().ConfigureAwait(false); + } + obj = null; // otherwise, there was a conflict, so reload the item + } + } + + /// Gets the used to map types to their Kubernetes groups, version, and kinds. + public KubernetesScheme Scheme() => _scheme; + + /// Sets the used to map types to their Kubernetes groups, version, and kinds, or null to + /// use the scheme. + /// + public KubernetesRequest Scheme(KubernetesScheme scheme) { _scheme = scheme ?? KubernetesScheme.Default; return this; } + + /// Attempts to set the , , , , + /// , and optionally the based on the given object. + /// + /// If the object implements of , it will be used to set the + /// and . The will be set if + /// is set (on the assumption that you're accessing an existing object), and cleared it's clear (on the assumption that you're + /// creating a new object and want to POST to its container). + /// + public KubernetesRequest Set(IKubernetesObject obj, bool setBody = true) + { + GVK(obj); + if (setBody) Body(obj); + var kobj = obj as IMetadata; + if (kobj != null) Namespace(kobj.Namespace()).Name(!string.IsNullOrEmpty(kobj.Uid()) ? kobj.Name() : null); + return this; + } + + /// Sets a custom header value, or deletes it if the value is null. + public KubernetesRequest SetHeader(string headerName, string value) => Set(ref headers, CheckHeaderName(headerName), value); + + /// Sets a query-string value, or deletes it if the value is null. + public KubernetesRequest SetQuery(string key, string value) => Set(ref query, key, value); + + /// Sets the to "status", to get or set a resource's status. + public KubernetesRequest Status() => Subresource("status"); + + /// Gets whether the response must be streamed. If true, the response will be returned from + /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded + /// before returns, and you will not have to dispose it. Note that regardless of the + /// value of this property, the response is always streamed when is not null. + /// + public bool StreamResponse() => _streamResponse; + + /// Sets whether the response must be streamed. If true, the response will be returned from + /// as soon as the headers are read and you will have to dispose the response. Otherwise, the entire response will be downloaded + /// before returns, and you will not have to dispose it. The default is false. Note that regardless of the + /// value of this property, the response is always streamed when is not null. + /// + public KubernetesRequest StreamResponse(bool stream) { _streamResponse = stream; return this; } + + /// Gets the URL-encoded subresource to access, or null to not access a subresource. + public string Subresource() => _subresource; + + /// Sets the subresource to access, or null or empty to not access a subresource. The value must be URL-encoded + /// already if necessary. + /// + public KubernetesRequest Subresource(string subresource) { _subresource = NormalizeEmpty(subresource); return this; } + + /// Sets the value of the by joining together one or more path segments. The + /// segments will be URL-escaped (and so should not be URL-escaped already). + /// + public KubernetesRequest Subresources(params string[] subresources) => + Subresource(subresources != null && subresources.Length != 0 ? + string.Join("/", subresources.Select(Uri.EscapeDataString)) : null); + + /// + public override string ToString() => Method().Method + " " + GetRequestUri(); + + /// Gets the resource type access (e.g. "pods"). + public string Type() => _type; + + /// Sets the resource type access (e.g. "pods"). + public KubernetesRequest Type(string type) { _type = NormalizeEmpty(type); return this; } + + /// Gets the Kubernetes API version to use, or null to use the default, which is "v1" + /// unless a is given. + /// + public string Version() => _version; + + /// Sets the Kubernetes API version to use, or null or empty to use the default, which is "v1" + /// unless a is given. + /// + public KubernetesRequest Version(string version) { _version = NormalizeEmpty(version); return this; } + + /// Gets the resource version to use when watching a resource, or empty to watch the current version, or null + /// to not execute a watch. + /// + public string WatchVersion() => _watchVersion; + + /// Sets the resource version to use when watching a resource, or empty to watch the current version, or null to not + /// execute a watch. The default is null. When set, the response is always streamed (as though + /// was true). + /// + public KubernetesRequest WatchVersion(string resourceVersion) { _watchVersion = resourceVersion; return this; } + + /// Adds a value to the query string or headers. + KubernetesRequest Add(ref Dictionary> dict, string key, string value) + { + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if (dict == null) dict = new Dictionary>(); + if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + values.Add(value); + return this; + } + + /// Adds a value to the query string or headers. + KubernetesRequest Add(ref Dictionary> dict, string key, IEnumerable values) + { + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + if (values != null) + { + if (dict == null) dict = new Dictionary>(); + if (!dict.TryGetValue(key, out List list)) dict[key] = list = new List(); + list.AddRange(values); + } + return this; + } + + /// Sets a value in the query string or headers. + KubernetesRequest Set(ref Dictionary> dict, string key, string value) + { + if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); + dict = dict ?? new Dictionary>(); + if (!dict.TryGetValue(key, out List values)) dict[key] = values = new List(); + values.Clear(); + values.Add(value); + return this; + } + + /// Creates an representing the current request. +#if NETCOREAPP2_1 + async ValueTask CreateRequestMessage(CancellationToken cancelToken) +#else + async Task CreateRequestMessage(CancellationToken cancelToken) +#endif + { + var req = new HttpRequestMessage(Method(), GetRequestUri()); + if (credentials != null) await credentials.ProcessHttpRequestAsync(req, cancelToken).ConfigureAwait(false); + + // add the headers + if (_accept != null) req.Headers.Add("Accept", _accept); + List>> contentHeaders = null; + if (headers != null && headers.Count != 0) // add custom headers + { + contentHeaders = new List>>(); // some headers must be added to .Content.Headers. track them + foreach (KeyValuePair> pair in headers) + { + if (!req.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if it's not legal to set this header on the request... + { + contentHeaders.Add(new KeyValuePair>(pair.Key, pair.Value)); // assume we should set it on the content + break; + } + } + } + + // add the body, if any + if (_body != null) + { + if (_body is byte[] bytes) req.Content = new ByteArrayContent(bytes); + else if (_body is Stream stream) req.Content = new StreamContent(stream); + else + { + req.Content = new StringContent( + _body as string ?? JsonConvert.SerializeObject(_body, Kubernetes.DefaultJsonSettings), Encoding.UTF8); + } + req.Content.Headers.ContentType = new MediaTypeHeaderValue(_mediaType ?? "application/json") { CharSet = "UTF-8" }; + if (contentHeaders != null && contentHeaders.Count != 0) // go through the headers we couldn't set on the request + { + foreach (KeyValuePair> pair in contentHeaders) + { + if (!req.Content.Headers.TryAddWithoutValidation(pair.Key, pair.Value)) // if we can't set it on the content either... + { + throw new InvalidOperationException($"{pair.Value} is a response header and cannot be set on the request."); + } + } + } + } + return req; + } + + async Task ExecuteMessageAsync(HttpRequestMessage msg, bool failIfMissing, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + KubernetesResponse resp = new KubernetesResponse(await client.SendAsync(msg, cancelToken).ConfigureAwait(false)); + if (resp.IsNotFound && !failIfMissing) return default(T); + else if (resp.IsError) throw new KubernetesException(await resp.GetStatusAsync().ConfigureAwait(false)); + else return await resp.GetBodyAsync().ConfigureAwait(false); + } + + string GetRequestUri() + { + if (_rawUri != null && (_group ?? _name ?? _ns ?? _subresource ?? _type ?? _version) != null) + { + throw new InvalidOperationException("You cannot use both raw and piecemeal URIs."); + } + + // construct the request URL + var sb = new StringBuilder(); + sb.Append(baseUri); + if (_rawUri != null) // if a raw URL was given, use it + { + if (sb[sb.Length-1] == '/') sb.Length--; // the raw URI starts with a slash, so ensure the base URI doesn't end with one + sb.Append(_rawUri); + } + else // otherwise, construct it piecemeal + { + if (sb[sb.Length-1] != '/') sb.Append('/'); // ensure the base URI ends with a slash + if (_group != null) sb.Append("apis/").Append(_group); + else sb.Append("api"); + sb.Append('/').Append(_version ?? "v1"); + if (_ns != null) sb.Append("/namespaces/").Append(_ns); + sb.Append('/').Append(_type); + if (_name != null) sb.Append('/').Append(_name); + if (_subresource != null) sb.Append('/').Append(_subresource); + } + bool firstParam = true; + if (query != null) // then add the query string, if any + { + foreach (KeyValuePair> pair in query) + { + string key = Uri.EscapeDataString(pair.Key); + foreach (string value in pair.Value) + { + sb.Append(firstParam ? '?' : '&').Append(key).Append('='); + if (!string.IsNullOrEmpty(value)) sb.Append(Uri.EscapeDataString(value)); + firstParam = false; + } + } + } + if (_watchVersion != null) + { + sb.Append(firstParam ? '?' : '&').Append("watch=1"); + if (_watchVersion.Length != 0) sb.Append("&resourceVersion=").Append(_watchVersion); + } + return sb.ToString(); + } + + object ICloneable.Clone() => Clone(); + + readonly HttpClient client; + readonly string baseUri; + readonly ServiceClientCredentials credentials; + Dictionary> headers, query; + string _accept = "application/json", _mediaType = "application/json"; + string _group, _name, _ns, _rawUri, _subresource, _type, _version, _watchVersion; + object _body; + HttpMethod _method; + KubernetesScheme _scheme; + bool _streamResponse; + + static string CheckHeaderName(string name) + { + if (name == "Accept" || name == "Content-Type") + { + throw new ArgumentException($"The {name} header must be set using the corresponding property."); + } + return name; + } + + static string NormalizeEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; // normalizes empty strings to null + } +} diff --git a/src/KubernetesClient/Fluent/KubernetesResponse.cs b/src/KubernetesClient/Fluent/KubernetesResponse.cs new file mode 100644 index 000000000..3d6c0ff87 --- /dev/null +++ b/src/KubernetesClient/Fluent/KubernetesResponse.cs @@ -0,0 +1,93 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using k8s.Models; +using Newtonsoft.Json; + +namespace k8s.Fluent +{ + /// Represents a response to a . + public sealed class KubernetesResponse : IDisposable + { + /// Initializes a new from an . + public KubernetesResponse(HttpResponseMessage message) => Message = message ?? throw new ArgumentNullException(nameof(message)); + + /// Indicates whether the server returned an error response. + public bool IsError => (int)StatusCode >= 400; + + /// Indicates whether the server returned a 404 Not Found response. + public bool IsNotFound => StatusCode == HttpStatusCode.NotFound; + + /// Gets the underlying . + public HttpResponseMessage Message { get; } + + /// Gets the of the response. + public HttpStatusCode StatusCode => Message.StatusCode; + + /// + public void Dispose() => Message.Dispose(); + + /// Returns the response body as a string. + public async Task GetBodyAsync() + { + if (body == null) + { + body = Message.Content != null ? await Message.Content.ReadAsStringAsync().ConfigureAwait(false) : string.Empty; + } + return body; + } + + /// Deserializes the response body from JSON as a value of the given type, or null if the response body is empty. + /// The type of object to return + /// If false, an empty response body will be returned as null. If true, an exception will be thrown if + /// the body is empty. The default is false. + /// + public async Task GetBodyAsync(Type type, bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) + { + if (!failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return null; + } + return JsonConvert.DeserializeObject(body, type, Kubernetes.DefaultJsonSettings); + } + + /// Deserializes the response body from JSON as a value of type , or the default value of + /// type if the response body is empty. + /// + /// If false, an empty response body will be returned as the default value of type + /// . If true, an exception will be thrown if the body is empty. The default is false. + /// + public async Task GetBodyAsync(bool failIfEmpty = false) + { + string body = await GetBodyAsync().ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(body)) + { + if (failIfEmpty) throw new InvalidOperationException("The response body was empty."); + return default(T); + } + return JsonConvert.DeserializeObject(body, Kubernetes.DefaultJsonSettings); + } + + /// Deserializes the response body as a object, or creates one from the status code if the + /// response body is not a JSON object. + /// + public async Task GetStatusAsync() + { + try + { + var status = await GetBodyAsync().ConfigureAwait(false); + if (status != null && (status.Status == "Success" || status.Status == "Failure")) return status; + } + catch (JsonException) { } + return new V1Status() + { + Status = IsError ? "Failure" : "Success", Code = (int)StatusCode, Reason = StatusCode.ToString(), Message = body + }; + } + + string body; + } +} diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index 16cc03380..9b3b6eb8f 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -8,6 +8,7 @@ using k8s.Exceptions; using k8s.Models; using Microsoft.Rest; +using Newtonsoft.Json; namespace k8s { @@ -41,9 +42,8 @@ public Kubernetes(KubernetesClientConfiguration config, HttpClient httpClient) : public Kubernetes(KubernetesClientConfiguration config, HttpClient httpClient, bool disposeHttpClient) : this(httpClient, disposeHttpClient) { ValidateConfig(config); - CaCerts = config.SslCaCerts; - SkipTlsVerify = config.SkipTlsVerify; - SetCredentials(config); + this.config = config; + SetCredentials(); } /// @@ -59,11 +59,25 @@ public Kubernetes(KubernetesClientConfiguration config, params DelegatingHandler : this(handlers) { ValidateConfig(config); - CaCerts = config.SslCaCerts; - SkipTlsVerify = config.SkipTlsVerify; - InitializeFromConfig(config); + this.config = config; + InitializeFromConfig(); } + /// Gets or sets the used to map types to their Kubernetes groups, versions, and kinds. + /// The default is . + /// + /// Gets or sets the used to map types to their Kubernetes groups, version, and kinds. + public KubernetesScheme Scheme + { + get => _scheme; + set + { + if (value == null) throw new ArgumentNullException(nameof(Scheme)); + _scheme = value; + } + } + + private void ValidateConfig(KubernetesClientConfiguration config) { if (config == null) @@ -86,7 +100,7 @@ private void ValidateConfig(KubernetesClientConfiguration config) } } - private void InitializeFromConfig(KubernetesClientConfiguration config) + private void InitializeFromConfig() { if (BaseUri.Scheme == "https") { @@ -107,25 +121,25 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) } else { - if (CaCerts == null) + if (config.SslCaCerts == null) { throw new KubeConfigException("A CA must be set when SkipTlsVerify === false"); } #if NET452 ((WebRequestHandler) HttpClientHandler).ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, certificate, chain, sslPolicyErrors); }; #elif XAMARINIOS1_0 System.Net.ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => { var cert = new X509Certificate2(certificate); - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, cert, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, cert, chain, sslPolicyErrors); }; #elif MONOANDROID8_1 var certList = new System.Collections.Generic.List(); - foreach (X509Certificate2 caCert in CaCerts) + foreach (X509Certificate2 caCert in config.SslCaCerts) { using (var certStream = new System.IO.MemoryStream(caCert.RawData)) { @@ -141,21 +155,17 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) #else HttpClientHandler.ServerCertificateCustomValidationCallback = (sender, certificate, chain, sslPolicyErrors) => { - return Kubernetes.CertificateValidationCallBack(sender, CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, config.SslCaCerts, certificate, chain, sslPolicyErrors); }; #endif } } - // set credentails for the kubernetes client - SetCredentials(config); + // set credentials for the kubernetes client + SetCredentials(); config.AddCertificates(HttpClientHandler); } - private X509Certificate2Collection CaCerts { get; } - - private bool SkipTlsVerify { get; } - partial void CustomInitialize() { #if NET452 @@ -189,26 +199,16 @@ partial void CustomInitialize() } /// - /// Set credentials for the Client + /// Set credentials for the Client based on the config /// - /// k8s client configuration - private void SetCredentials(KubernetesClientConfiguration config) + private void SetCredentials() { - // set the Credentails for token based auth - if (!string.IsNullOrWhiteSpace(config.AccessToken)) - { - Credentials = new TokenCredentials(config.AccessToken); - } - else if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password)) - { - Credentials = new BasicAuthenticationCredentials - { - UserName = config.Username, - Password = config.Password - }; - } + Credentials = CreateCredentials(config); } + internal readonly KubernetesClientConfiguration config; + private KubernetesScheme _scheme = KubernetesScheme.Default; + /// /// SSl Cert Validation Callback /// @@ -264,5 +264,33 @@ public static bool CertificateValidationCallBack( // In all other cases, return false. return false; } + + /// Creates the JSON serializer settings used for serializing request bodies and deserializing responses. + public static JsonSerializerSettings CreateSerializerSettings() + { + var settings = new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore }; + settings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter()); + return settings; + } + + /// Creates from a Kubernetes configuration, or returns null if the configuration + /// contains no credentials of that type. + /// + internal static ServiceClientCredentials CreateCredentials(KubernetesClientConfiguration config) + { + if (config == null) throw new ArgumentNullException(nameof(config)); + if (!string.IsNullOrEmpty(config.AccessToken)) + { + return new TokenCredentials(config.AccessToken); + } + else if (!string.IsNullOrEmpty(config.Username)) + { + return new BasicAuthenticationCredentials() { UserName = config.Username, Password = config.Password }; + } + return null; + } + + /// Gets the used to serialize and deserialize Kubernetes objects. + internal static readonly JsonSerializerSettings DefaultJsonSettings = CreateSerializerSettings(); } } diff --git a/src/KubernetesClient/Kubernetes.WebSocket.cs b/src/KubernetesClient/Kubernetes.WebSocket.cs index 5da20c8c8..275e6db55 100644 --- a/src/KubernetesClient/Kubernetes.WebSocket.cs +++ b/src/KubernetesClient/Kubernetes.WebSocket.cs @@ -277,19 +277,19 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.CaCerts != null) + if (this.config?.SslCaCerts != null) { webSocketBuilder.SetServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } #endif #if NETCOREAPP2_1 - if (this.CaCerts != null) + if (this.config?.SslCaCerts != null) { - webSocketBuilder.ExpectServerCertificate(this.CaCerts); + webSocketBuilder.ExpectServerCertificate(this.config.SslCaCerts); } - if (this.SkipTlsVerify) + if (this.config?.SkipTlsVerify == true) { webSocketBuilder.SkipServerCertificateValidation(); } @@ -365,7 +365,7 @@ public partial class Kubernetes } #if (NET452 || NETSTANDARD2_0) - if (this.CaCerts != null) + if (this.config?.SslCaCerts != null) { webSocketBuilder.CleanupServerCertificateValidationCallback(this.ServerCertificateValidationCallback); } @@ -377,7 +377,7 @@ public partial class Kubernetes #if (NET452 || NETSTANDARD2_0) internal bool ServerCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - return Kubernetes.CertificateValidationCallBack(sender, this.CaCerts, certificate, chain, sslPolicyErrors); + return Kubernetes.CertificateValidationCallBack(sender, this.config?.SslCaCerts, certificate, chain, sslPolicyErrors); } #endif } diff --git a/src/KubernetesClient/KubernetesScheme.cs b/src/KubernetesClient/KubernetesScheme.cs new file mode 100644 index 000000000..2ea07a1a0 --- /dev/null +++ b/src/KubernetesClient/KubernetesScheme.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using k8s.Models; + +namespace k8s +{ + /// Represents a map between object types and Kubernetes group, version, and kind triplets. + public sealed class KubernetesScheme + { + /// Gets the Kubernetes group, version, and kind for the given type of object. + public void GetGVK(Type type, out string group, out string version, out string kind) => + GetGVK(type, out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public void GetGVK(Type type, out string group, out string version, out string kind, out string path) + { + if (!TryGetGVK(type, out group, out version, out kind, out path)) + { + throw new ArgumentException($"The GVK of type {type.Name} is unknown."); + } + } + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public void GetGVK(out string group, out string version, out string kind) => + GetGVK(typeof(T), out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public void GetGVK(out string group, out string version, out string kind, out string path) => + GetGVK(typeof(T), out group, out version, out kind, out path); + + /// Gets the Kubernetes API version (including the group, if any), and kind for the given type of object. + public void GetVK(Type type, out string apiVersion, out string kind) => GetVK(type, out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public void GetVK(Type type, out string apiVersion, out string kind, out string path) + { + string group, version; + GetGVK(type, out group, out version, out kind, out path); + apiVersion = string.IsNullOrEmpty(group) ? version : group + "/" + version; + } + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public void GetVK(out string apiVersion, out string kind) => GetVK(typeof(T), out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public void GetVK(out string apiVersion, out string kind, out string path) => + GetVK(typeof(T), out apiVersion, out kind, out path); + + /// Creates a new Kubernetes object of the given type and sets its and + /// . + /// + public T New() where T : IKubernetesObject, new() + { + string apiVersion, kind; + GetVK(typeof(T), out apiVersion, out kind); + return new T() { ApiVersion = apiVersion, Kind = kind }; + } + + /// Creates a new Kubernetes object of the given type and sets its , + /// , and . + /// + public T New(string name) where T : IKubernetesObject, new() => New(null, name); + + /// Creates a new Kubernetes object of the given type and sets its , + /// , , and . + /// + public T New(string ns, string name) where T : IKubernetesObject, new() + { + T obj = New(); + obj.Metadata = new V1ObjectMeta() { NamespaceProperty = ns, Name = name }; + return obj; + } + + /// Removes GVK information about the given type of object. + public void RemoveGVK(Type type) + { + lock (gvks) gvks.Remove(type); + } + + /// Removes GVK information about the given type of object. + public void RemoveGVK() => RemoveGVK(typeof(T)); + + /// Sets GVK information for the given type of object. + public void SetGVK(Type type, string group, string version, string kind, string path) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + if (string.IsNullOrEmpty(version)) throw new ArgumentNullException(nameof(version)); + if (string.IsNullOrEmpty(kind)) throw new ArgumentNullException(nameof(kind)); + if (string.IsNullOrEmpty(path)) throw new ArgumentNullException(nameof(path)); + lock (gvks) gvks[type] = Tuple.Create(group ?? string.Empty, version, kind, path); + } + + /// Sets GVK information for the given type of object. + public void SetGVK(string group, string version, string kind, string path) => + SetGVK(typeof(T), group, version, kind, path); + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public bool TryGetGVK(Type type, out string group, out string version, out string kind) + => TryGetGVK(type, out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public bool TryGetGVK(Type type, out string group, out string version, out string kind, out string path) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + lock (gvks) + { + if (!gvks.TryGetValue(type, out Tuple gvk)) + { + var attr = type.GetCustomAttribute(); // newer types have this attribute + if (attr != null) + { + gvk = Tuple.Create(attr.Group, attr.ApiVersion, attr.Kind, attr.PluralName); + } + else // some older types (and ours) just have static/const fields + { + const BindingFlags Flags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static; + FieldInfo kindf = type.GetField("KubeKind", Flags), versionf = type.GetField("KubeApiVersion", Flags); + if (kindf != null && versionf != null) + { + FieldInfo groupf = type.GetField("KubeGroup", Flags); + string k = (string)kindf.GetValue(null); + gvk = Tuple.Create( + (string)groupf?.GetValue(null) ?? string.Empty, (string)versionf.GetValue(null), k, GuessPath(k)); + } + } + gvks[type] = gvk; + } + + if (gvk != null) + { + (group, version, kind, path) = gvk; + return true; + } + else + { + group = version = kind = path = null; + return false; + } + } + } + + /// Gets the Kubernetes group, version, and kind for the given type of object. + public bool TryGetGVK(out string group, out string version, out string kind) => + TryGetGVK(typeof(T), out group, out version, out kind, out string path); + + /// Gets the Kubernetes group, version, kind, and API path segment for the given type of object. + public bool TryGetGVK(out string group, out string version, out string kind, out string path) => + TryGetGVK(typeof(T), out group, out version, out kind, out path); + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public bool TryGetVK(Type type, out string apiVersion, out string kind) => + TryGetVK(type, out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public bool TryGetVK(Type type, out string apiVersion, out string kind, out string path) + { + if (TryGetGVK(type, out string group, out string version, out kind, out path)) + { + apiVersion = string.IsNullOrEmpty(group) ? version : group + "/" + version; + return true; + } + else + { + apiVersion = null; + return false; + } + } + + /// Gets the Kubernetes API version (including the group, if any) and kind for the given type of object. + public bool TryGetVK(out string apiVersion, out string kind) => TryGetVK(typeof(T), out apiVersion, out kind, out string path); + + /// Gets the Kubernetes API version (including the group, if any), kind, and API path segment for the given type of + /// object. + /// + public bool TryGetVK(out string apiVersion, out string kind, out string path) => + TryGetVK(typeof(T), out apiVersion, out kind, out path); + + /// Gets the default . + public static readonly KubernetesScheme Default = new KubernetesScheme(); + + /// Attempts to guess a type's API path segment based on its kind. + internal static string GuessPath(string kind) + { + if (string.IsNullOrEmpty(kind)) return null; + if (kind.Length > 4 && kind.EndsWith("List")) kind = kind.Substring(0, kind.Length-4); // e.g. PodList -> Pod + kind = kind.ToLowerInvariant(); // e.g. Pod -> pod + return kind + (kind[kind.Length-1] == 's' ? "es" : "s"); // e.g. pod -> pods + } + + readonly Dictionary> gvks = new Dictionary>(); + } +} diff --git a/src/KubernetesClient/ModelExtensions.cs b/src/KubernetesClient/ModelExtensions.cs new file mode 100644 index 000000000..f16a0665a --- /dev/null +++ b/src/KubernetesClient/ModelExtensions.cs @@ -0,0 +1,364 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; + +namespace k8s.Models +{ + /// Adds convenient extensions for Kubernetes objects. + public static class ModelExtensions + { + /// Adds the given finalizer to a Kubernetes object if it doesn't already exist. + /// Returns true if the finalizer was added and false if it already existed. + public static bool AddFinalizer(this IMetadata obj, string finalizer) + { + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + if (EnsureMetadata(obj).Finalizers == null) obj.Metadata.Finalizers = new List(); + if (obj.Metadata.Finalizers.Contains(finalizer)) return false; + obj.Metadata.Finalizers.Add(finalizer); + return true; + } + + /// Extracts the Kubernetes API group from the . + public static string ApiGroup(this IKubernetesObject obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) return null; + int slash = obj.ApiVersion.IndexOf('/'); + return slash < 0 ? string.Empty : obj.ApiVersion.Substring(0, slash); + } + + /// Extracts the Kubernetes API version (excluding the group) from the . + public static string ApiGroupVersion(this IKubernetesObject obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) return null; + int slash = obj.ApiVersion.IndexOf('/'); + return slash < 0 ? obj.ApiVersion : obj.ApiVersion.Substring(slash+1); + } + + /// Splits the Kubernetes API version into the group and version. + public static (string, string) ApiGroupAndVersion(this IKubernetesObject obj) + { + string group, version; + GetApiGroupAndVersion(obj, out group, out version); + return (group, version); + } + + /// Splits the Kubernetes API version into the group and version. + public static void GetApiGroupAndVersion(this IKubernetesObject obj, out string group, out string version) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.ApiVersion == null) + { + group = version = null; + } + else + { + int slash = obj.ApiVersion.IndexOf('/'); + if (slash < 0) (group, version) = (string.Empty, obj.ApiVersion); + else (group, version) = (obj.ApiVersion.Substring(0, slash), obj.ApiVersion.Substring(slash+1)); + } + } + + /// Gets the continuation token version of a Kubernetes list. + public static string Continue(this IMetadata list) => list.Metadata?.ContinueProperty; + + /// Ensures that the metadata field is set, and returns it. + public static V1ListMeta EnsureMetadata(this IMetadata obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.Metadata == null) obj.Metadata = new V1ListMeta(); + return obj.Metadata; + } + + /// Gets the resource version of a Kubernetes list. + public static string ResourceVersion(this IMetadata list) => list.Metadata?.ResourceVersion; + + /// Adds an owner reference to the object. No attempt is made to ensure the reference is correct or fits with the + /// other references. + /// + public static void AddOwnerReference(this IMetadata obj, V1OwnerReference ownerRef) + { + if (ownerRef == null) throw new ArgumentNullException(nameof(ownerRef)); + if (EnsureMetadata(obj).OwnerReferences == null) obj.Metadata.OwnerReferences = new List(); + obj.Metadata.OwnerReferences.Add(ownerRef); + } + + /// Adds an owner reference to the object. No attempt is made to ensure the reference is correct or fits with the + /// other references. + /// + public static void AddOwnerReference( + this IMetadata obj, IKubernetesObject owner, bool? controller = null, bool? blockDeletion = null) => + AddOwnerReference(obj, CreateOwnerReference(owner, controller, blockDeletion)); + + /// Gets the annotations of a Kubernetes object. + public static IDictionary Annotations(this IMetadata obj) => obj.Metadata?.Annotations; + + /// Creates a that refers to the given object. + public static V1ObjectReference CreateObjectReference(this IKubernetesObject obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object + if (string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + { + KubernetesScheme.Default.GetVK(obj.GetType(), out apiVersion, out kind); // get it from the default scheme + } + return new V1ObjectReference() + { + ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), NamespaceProperty = obj.Namespace(), Uid = obj.Uid(), + ResourceVersion = obj.ResourceVersion() + }; + } + + /// Creates a that refers to the given object. + public static V1OwnerReference CreateOwnerReference(this IKubernetesObject obj, bool? controller = null, bool? blockDeletion = null) + { + if(obj == null) throw new ArgumentNullException(nameof(obj)); + string apiVersion = obj.ApiVersion, kind = obj.Kind; // default to using the API version and kind from the object + if(string.IsNullOrEmpty(apiVersion) || string.IsNullOrEmpty(kind)) // but if either of them is missing... + { + KubernetesScheme.Default.GetVK(obj.GetType(), out apiVersion, out kind); // get it from the default scheme + } + return new V1OwnerReference() + { + ApiVersion = apiVersion, Kind = kind, Name = obj.Name(), Uid = obj.Uid(), Controller = controller, BlockOwnerDeletion = blockDeletion + }; + } + + /// Gets the creation time of a Kubernetes object, or null if it hasn't been created yet. + public static DateTime? CreationTimestamp(this IMetadata obj) => obj.Metadata?.CreationTimestamp; + + /// Gets the deletion time of a Kubernetes object, or null if it hasn't been scheduled for deletion. + public static DateTime? DeletionTimestamp(this IMetadata obj) => obj.Metadata?.DeletionTimestamp; + + /// Ensures that the metadata field is set, and returns it. + public static V1ObjectMeta EnsureMetadata(this IMetadata obj) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (obj.Metadata == null) obj.Metadata = new V1ObjectMeta(); + return obj.Metadata; + } + + /// Gets the of a Kubernetes object. + public static IList Finalizers(this IMetadata obj) => obj.Metadata?.Finalizers; + + /// Gets the index of the that matches the given object, or -1 if no such + /// reference could be found. + /// + public static int FindOwnerReference(this IMetadata obj, IKubernetesObject owner) => + FindOwnerReference(obj, r => r.Matches(owner)); + + /// Gets the index of the that matches the given predicate, or -1 if no such + /// reference could be found. + /// + public static int FindOwnerReference(this IMetadata obj, Predicate predicate) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + var ownerRefs = obj.OwnerReferences(); + if (ownerRefs != null) + { + for (int i = 0; i < ownerRefs.Count; i++) + { + if (predicate(ownerRefs[i])) return i; + } + } + return -1; + } + + /// Gets the generation a Kubernetes object. + public static long? Generation(this IMetadata obj) => obj.Metadata?.Generation; + + /// Returns the given annotation from a Kubernetes object or null if the annotation was not found. + public static string GetAnnotation(this IMetadata obj, string key) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + IDictionary annotations = obj.Annotations(); + return annotations != null && annotations.TryGetValue(key, out string value) ? value : null; + } + + /// Gets the for the controller of this object, or null if it couldn't be found. + public static V1OwnerReference GetController(this IMetadata obj) => + obj.OwnerReferences()?.FirstOrDefault(r => r.Controller.GetValueOrDefault()); + + /// Returns the given label from a Kubernetes object or null if the label was not found. + public static string GetLabel(this IMetadata obj, string key) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + IDictionary labels = obj.Labels(); + return labels != null && labels.TryGetValue(key, out string value) ? value : null; + } + + /// Gets that matches the given object, or null if no matching reference exists. + public static V1OwnerReference GetOwnerReference(this IMetadata obj, IKubernetesObject owner) => + GetOwnerReference(obj, r => r.Matches(owner)); + + /// Gets the that matches the given predicate, or null if no matching reference exists. + public static V1OwnerReference GetOwnerReference(this IMetadata obj, Predicate predicate) + { + int index = FindOwnerReference(obj, predicate); + return index >= 0 ? obj.Metadata.OwnerReferences[index] : null; + } + + /// Determines whether the Kubernetes object has the given finalizer. + public static bool HasFinalizer(this IMetadata obj, string finalizer) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + return obj.Finalizers() != null && obj.Metadata.Finalizers.Contains(finalizer); + } + + /// Determines whether one object is owned by another. + public static bool IsOwnedBy(this IMetadata obj, IKubernetesObject owner) => + FindOwnerReference(obj, owner) >= 0; + + /// Gets the labels of a Kubernetes object. + public static IDictionary Labels(this IMetadata obj) => obj.Metadata?.Labels; + + /// Gets the name of a Kubernetes object. + public static string Name(this IMetadata obj) => obj.Metadata?.Name; + + /// Gets the namespace of a Kubernetes object. + public static string Namespace(this IMetadata obj) => obj.Metadata?.NamespaceProperty; + + /// Gets the owner references of a Kubernetes object. + public static IList OwnerReferences(this IMetadata obj) => obj.Metadata?.OwnerReferences; + + /// Removes the given finalizer from a Kubernetes object if it exists. + /// Returns true if the finalizer was removed and false if it didn't exist. + public static bool RemoveFinalizer(this IMetadata obj, string finalizer) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (string.IsNullOrEmpty(finalizer)) throw new ArgumentNullException(nameof(finalizer)); + return obj.Finalizers() != null && obj.Metadata.Finalizers.Remove(finalizer); + } + + /// Removes the first that matches the given object and returns it, or returns null if no + /// matching reference could be found. + /// + public static V1OwnerReference RemoveOwnerReference(this IMetadata obj, IKubernetesObject owner) + { + int index = FindOwnerReference(obj, owner); + V1OwnerReference ownerRef = index >= 0 ? obj.Metadata.OwnerReferences[index] : null; + if (index >= 0) obj.Metadata.OwnerReferences.RemoveAt(index); + return ownerRef; + } + + /// Removes all owner references that match the given predicate, and returns true if + /// any were removed. + /// + public static bool RemoveOwnerReferences(this IMetadata obj, Predicate predicate) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (predicate == null) throw new ArgumentNullException(nameof(predicate)); + bool removed = false; + IList refs = obj.Metadata?.OwnerReferences; + if (refs != null) + { + for (int i = refs.Count-1; i >= 0; i--) + { + if (predicate(refs[i])) + { + refs.RemoveAt(i); + removed = true; + } + } + } + return removed; + } + + /// Removes all owner references that match the given object, and returns true if + /// any were removed. + /// + public static bool RemoveOwnerReferences(this IMetadata obj, IKubernetesObject owner) => + RemoveOwnerReferences(obj, r => r.Matches(owner)); + + /// Gets the resource version of a Kubernetes object. + public static string ResourceVersion(this IMetadata obj) => obj.Metadata?.ResourceVersion; + + /// Sets or removes an annotation on a Kubernetes object. + public static void SetAnnotation(this IMetadata obj, string key, string value) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value != null) obj.EnsureMetadata().EnsureAnnotations()[key] = value; + else obj.Metadata?.Annotations?.Remove(key); + } + + /// Sets or removes a label on a Kubernetes object. + public static void SetLabel(this IMetadata obj, string key, string value) + { + if (obj == null) throw new ArgumentNullException(nameof(obj)); + if (key == null) throw new ArgumentNullException(nameof(key)); + if (value != null) obj.EnsureMetadata().EnsureLabels()[key] = value; + else obj.Metadata?.Labels?.Remove(key); + } + + /// Gets the unique ID of a Kubernetes object. + public static string Uid(this IMetadata obj) => obj.Metadata?.Uid; + + /// Ensures that the field is not null, and returns it. + public static IDictionary EnsureAnnotations(this V1ObjectMeta meta) + { + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Annotations == null) meta.Annotations = new Dictionary(); + return meta.Annotations; + } + + /// Ensures that the field is not null, and returns it. + public static IList EnsureFinalizers(this V1ObjectMeta meta) + { + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Finalizers == null) meta.Finalizers = new List(); + return meta.Finalizers; + } + + /// Ensures that the field is not null, and returns it. + public static IDictionary EnsureLabels(this V1ObjectMeta meta) + { + if (meta == null) throw new ArgumentNullException(nameof(meta)); + if (meta.Labels == null) meta.Labels = new Dictionary(); + return meta.Labels; + } + + /// Gets the namespace from Kubernetes metadata. + public static string Namespace(this V1ObjectMeta meta) => meta.NamespaceProperty; + + /// Sets the namespace from Kubernetes metadata. + public static void SetNamespace(this V1ObjectMeta meta, string ns) => meta.NamespaceProperty = ns; + + /// Determines whether an object reference references the given object. + public static bool Matches(this V1ObjectReference objref, IKubernetesObject obj) + { + if (objref == null) throw new ArgumentNullException(nameof(objref)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + return objref.ApiVersion == obj.ApiVersion && objref.Kind == obj.Kind && objref.Name == obj.Name() && objref.Uid == obj.Uid() && + objref.NamespaceProperty == obj.Namespace(); + } + + /// Determines whether an owner reference references the given object. + public static bool Matches(this V1OwnerReference owner, IKubernetesObject obj) + { + if (owner == null) throw new ArgumentNullException(nameof(owner)); + if (obj == null) throw new ArgumentNullException(nameof(obj)); + return owner.ApiVersion == obj.ApiVersion && owner.Kind == obj.Kind && owner.Name == obj.Name() && owner.Uid == obj.Uid(); + } + } + + public partial class V1Status + { + /// Converts a object representing an error into a short description of the error. + public override string ToString() + { + string reason = Reason; + if (string.IsNullOrEmpty(reason) && Code.GetValueOrDefault() != 0) + { + reason = ((HttpStatusCode)Code.Value).ToString(); + } + return string.IsNullOrEmpty(Message) ? reason : string.IsNullOrEmpty(reason) ? Message : $"{reason} - {Message}"; + } + } +} diff --git a/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs new file mode 100644 index 000000000..b6f846967 --- /dev/null +++ b/tests/KubernetesClient.Tests/Kubernetes.Fluent.Tests.cs @@ -0,0 +1,494 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using k8s.Fluent; +using k8s.Models; +using k8s.Tests.Mock; +using Newtonsoft.Json; +using Xunit; + +namespace k8s.Tests +{ + public class FluentTests + { + [Fact] + public void TestRequestProperties() + { + var testScheme = new KubernetesScheme(); + var r = new KubernetesRequest(new Uri("http://somewhere"), scheme: testScheme); + + // verify the initial values + Assert.Equal("application/json", r.Accept()); + Assert.Null(r.Body()); + Assert.False(r.DryRun()); + Assert.Null(r.FieldManager()); + Assert.Null(r.FieldSelector()); + Assert.Null(r.Group()); + Assert.Null(r.LabelSelector()); + Assert.Equal("application/json", r.MediaType()); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + Assert.Null(r.RawUri()); + Assert.Same(testScheme, r.Scheme()); + Assert.False(r.StreamResponse()); + Assert.Null(r.Type()); + Assert.Null(r.Version()); + Assert.Null(r.WatchVersion()); + + // test basic value setters + r.Accept("foo/bar") + .Body("abc") + .DryRun(true) + .FieldManager("joe") + .FieldSelector("fs") + .Group("mygroup") + .LabelSelector("x=y") + .MediaType("bar/baz") + .Method(HttpMethod.Post) + .Name("name") + .Namespace("ns") + .RawUri("/anywhere") + .StreamResponse(true) + .Subresource("exec") + .Type("mytype") + .Version("v2") + .WatchVersion("42"); + Assert.Equal("foo/bar", r.Accept()); + Assert.Equal("abc", (string)r.Body()); + Assert.True(r.DryRun()); + Assert.Equal("joe", r.FieldManager()); + Assert.Equal("fs", r.FieldSelector()); + Assert.Equal("mygroup", r.Group()); + Assert.Equal("x=y", r.LabelSelector()); + Assert.Equal("bar/baz", r.MediaType()); + Assert.Same(HttpMethod.Post, r.Method()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("/anywhere", r.RawUri()); + Assert.True(r.StreamResponse()); + Assert.Equal("mytype", r.Type()); + Assert.Equal("v2", r.Version()); + Assert.Equal("42", r.WatchVersion()); + + // test value normalization + r.Accept("") + .FieldManager("") + .FieldSelector("") + .Group("") + .LabelSelector("") + .MediaType("") + .Method(null) + .Name("") + .Namespace("") + .RawUri("") + .Scheme(null) + .Subresource("") + .Type("") + .Version("") + .WatchVersion(""); + Assert.Null(r.Accept()); + Assert.Null(r.FieldManager()); + Assert.Null(r.FieldSelector()); + Assert.Null(r.Group()); + Assert.Null(r.LabelSelector()); + Assert.Null(r.MediaType()); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + Assert.Null(r.RawUri()); + Assert.Same(KubernetesScheme.Default, r.Scheme()); + Assert.Null(r.Type()); + Assert.Null(r.Version()); + Assert.Equal("", r.WatchVersion()); + + // test exceptions from property setters + Assert.Throws(() => r.RawUri("foo")); + + // test more advanced/specific setters + r.Delete(); + Assert.Same(HttpMethod.Delete, r.Method()); + r.Get(); + Assert.Same(HttpMethod.Get, r.Method()); + r.Patch(); + Assert.Equal(new HttpMethod("PATCH"), r.Method()); + r.Post(); + Assert.Same(HttpMethod.Post, r.Method()); + r.Put(); + Assert.Same(HttpMethod.Put, r.Method()); + + r.Status(); + Assert.Equal("status", r.Subresource()); + r.Subresources("a", "b c"); + Assert.Equal("a/b%20c", r.Subresource()); + + r.GVK(); + Assert.Equal("v3", r.Version()); + Assert.Equal("cgrp", r.Group()); + Assert.Equal("newz", r.Type()); + + r.GVK(); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + + r.GVK("grp", "v1", "PigList"); + Assert.Equal("v1", r.Version()); + Assert.Equal("grp", r.Group()); + Assert.Equal("pigs", r.Type()); + + r.GVK(new CustomOld()); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + + var res = new CustomNew() { ApiVersion = "coolstuff/v7", Kind = "yep", Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns" } }; + r.GVK(res); + Assert.Equal("v7", r.Version()); + Assert.Equal("coolstuff", r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Null(r.Namespace()); + + res.ApiVersion = "v7"; + r.Body(null).Set(res, setBody: false); + Assert.Equal("v7", r.Version()); + Assert.Null(r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Null(r.Body()); + + (res.ApiVersion, res.Metadata.Uid) = ("", "id"); + r.Set(res); + Assert.Equal("v3", r.Version()); + Assert.Equal("cgrp", r.Group()); + Assert.Equal("newz", r.Type()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Same(res, r.Body()); + + // test with a custom scheme + var scheme = new KubernetesScheme(); + scheme.SetGVK(typeof(CustomOld), "group", "version", "Custom", "customs"); + r.Scheme(scheme).GVK(); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + Assert.Equal("customs", r.Type()); + } + + [Fact] + public void TestRequestQuery() + { + var r = new KubernetesRequest(new Uri("http://somewhere")); + + // test basic query-string operations + Assert.Null(r.GetQuery("k")); + r.AddQuery("k", "y"); + Assert.Equal("y", r.GetQuery("k")); + r.AddQuery("k", "x"); + Assert.Throws(() => r.GetQuery("k")); + Assert.Equal(new[] { "y", "x" }, r.GetQueryValues("k")); + r.SetQuery("k", "z"); + Assert.Equal("z", r.GetQuery("k")); + r.SetQuery("k2", "a").ClearQuery("k"); + Assert.Null(r.GetQuery("k")); + Assert.Equal("a", r.GetQuery("k2")); + r.ClearQuery(); + Assert.Null(r.GetQuery("k2")); + r.AddQuery("k", "a", "b"); + Assert.Equal(new[] { "a", "b" }, r.GetQueryValues("k")); + r.SetQuery("k", "x"); + Assert.Equal("x", r.GetQuery("k")); + r.SetQuery("k", null); + Assert.Null(r.GetQuery("k")); + + // test property setters that work via the query string + r.DryRun(true).FieldManager("fm").FieldSelector("fs").LabelSelector("ls"); + Assert.Equal("All", r.GetQuery("dryRun")); + Assert.Equal("fm", r.GetQuery("fieldManager")); + Assert.Equal("fs", r.GetQuery("fieldSelector")); + Assert.Equal("ls", r.GetQuery("labelSelector")); + } + + [Fact] + public void TestRequestHeaders() + { + var r = new KubernetesRequest(new Uri("http://somewhere")); + + // test basic header operations + Assert.Null(r.GetHeader("k")); + r.AddHeader("k", "y"); + Assert.Equal("y", r.GetHeader("k")); + r.AddHeader("k", "x"); + Assert.Throws(() => r.GetHeader("k")); + Assert.Equal(new[] { "y", "x" }, r.GetHeaderValues("k")); + r.SetHeader("k", "z"); + Assert.Equal("z", r.GetHeader("k")); + r.SetHeader("k2", "a").ClearHeader("k"); + Assert.Null(r.GetHeader("k")); + Assert.Equal("a", r.GetHeader("k2")); + r.ClearHeaders(); + Assert.Null(r.GetHeader("k2")); + r.AddHeader("k", "a", "b"); + Assert.Equal(new[] { "a", "b" }, r.GetHeaderValues("k")); + r.SetHeader("k", "x"); + Assert.Equal("x", r.GetHeader("k")); + r.SetHeader("k", null); + Assert.Null(r.GetHeader("k")); + + // test exceptions + Assert.Null(r.GetHeader("Accept")); + Assert.Throws(() => r.AddHeader("Accept", "text/plain")); + Assert.Throws(() => r.AddHeader("Accept", Enumerable.Repeat("text/plain", 2))); + Assert.Throws(() => r.AddHeader("Accept", "text/plain", "text/fancy")); + Assert.Throws(() => r.ClearHeader("Content-Type")); + Assert.Throws(() => r.SetHeader("Content-Type", "text/plain")); + } + + [Fact] + public async Task TestExecution() + { + var h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"apiVersion\":\"xyz\"}") }); + var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + + await c.Request().AddHeader("Test", "yes").AddQuery("x", "a", "b c").Body("hello").ExecuteAsync(); + Assert.Equal(HttpMethod.Get, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/api/v1/pods?x=a&x=b%20c"), h.Request.RequestUri); + Assert.Equal("yes", h.Request.Headers.GetValues("Test").Single()); + Assert.Equal("application/json", h.Request.Headers.Accept.ToString()); + Assert.Equal("application/json; charset=UTF-8", h.Request.Content.Headers.ContentType.ToString()); + Assert.Equal("hello", await h.Request.Content.ReadAsStringAsync()); + + var res = new CustomNew() { ApiVersion = "abc" }; + await c.Request("ns", "name") + .Accept("text/plain").MediaType("text/rtf").Delete().DryRun(true).Body(res).Status().ExecuteAsync(); + Assert.Equal(HttpMethod.Delete, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/apis/cgrp/v3/namespaces/ns/newz/name/status?dryRun=All"), h.Request.RequestUri); + Assert.Equal("text/plain", h.Request.Headers.Accept.ToString()); + Assert.Equal("text/rtf; charset=UTF-8", h.Request.Content.Headers.ContentType.ToString()); + Assert.Equal("{\"apiVersion\":\"abc\"}", await h.Request.Content.ReadAsStringAsync()); + + await c.Request().RawUri("/foobar").Post().LabelSelector("ls").WatchVersion("3").Body(Encoding.UTF8.GetBytes("bytes")).ExecuteAsync(); + Assert.Equal(HttpMethod.Post, h.Request.Method); + Assert.Equal(new Uri("http://localhost:8080/foobar?labelSelector=ls&watch=1&resourceVersion=3"), h.Request.RequestUri); + Assert.Equal("bytes", await h.Request.Content.ReadAsStringAsync()); + + await c.Request().RawUri("/foobar/").WatchVersion("").Body(new MemoryStream(Encoding.UTF8.GetBytes("streaming"))).ExecuteAsync(); + Assert.Equal(new Uri("http://localhost:8080/foobar/?watch=1"), h.Request.RequestUri); + Assert.Equal("streaming", await h.Request.Content.ReadAsStringAsync()); + + await Assert.ThrowsAsync(() => c.Request().Name("x").RawUri("/y").ExecuteAsync()); // can't use raw and non-raw + + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", AccessToken = "token" }, new HttpClient(h)); + await c.Request().ExecuteAsync(); + Assert.Equal(new Uri("http://localhost:8080/api/v1/"), h.Request.RequestUri); + Assert.Equal("Bearer token", h.Request.Headers.Authorization.ToString()); + + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080", Username = "joe" }, new HttpClient(h)); + await c.Request().ExecuteAsync(); + Assert.Equal("Basic am9lOg==", h.Request.Headers.Authorization.ToString()); + + res = await c.Request().ExecuteAsync(); + Assert.Equal("xyz", res.ApiVersion); + + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + res = await c.Request().ExecuteAsync(); + Assert.Null(res); + res = await c.Request().ExecuteAsync(failIfMissing: true); // missing only refers to 404 Not Found + Assert.Null(res); + + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("{}") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + res = await c.Request().ExecuteAsync(); + Assert.Null(res); + await Assert.ThrowsAsync(() => c.Request().ExecuteAsync(failIfMissing: true)); + + h = new MockHttpHandler(_ => new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{}") }); + c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + await Assert.ThrowsAsync(() => c.Request().ExecuteAsync()); + } + + [Fact] + public void TestNewRequest() + { + var c = new Kubernetes(new Uri("http://somewhere"), new Microsoft.Rest.TokenCredentials("token"), new MockHttpHandler(null)); + c.Scheme = new KubernetesScheme(); + c.Scheme.SetGVK(typeof(CustomOld), "group", "version", "Custom", "customs"); + + // test c.Request(HttpMethod = null) + var r = c.Request(); + Assert.Same(HttpMethod.Get, r.Method()); + r = c.Request(HttpMethod.Delete); + Assert.Same(HttpMethod.Delete, r.Method()); + + // test c.Request(Type) + r = c.Request(typeof(V1MutatingWebhookConfiguration)); + Assert.Equal("admissionregistration.k8s.io", r.Group()); + Assert.Equal("v1", r.Version()); + Assert.Equal("mutatingwebhookconfigurations", r.Type()); + r = c.Request(typeof(CustomOld)); // ensure it defaults to the scheme from the client + Assert.Same(c.Scheme, r.Scheme()); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + Assert.Equal("customs", r.Type()); + + // test c.Request(obj, bool) + var res = new CustomNew() { ApiVersion = "coolstuff/v7", Kind = "yep", Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns" } }; + r = c.Request(res); + Assert.Equal("coolstuff", r.Group()); + Assert.Equal("v7", r.Version()); + Assert.Equal("newz", r.Type()); + Assert.Null(r.Name()); + Assert.Equal("ns", r.Namespace()); + Assert.Same(res, r.Body()); + + res.Metadata.Uid = "id"; + r = c.Request(res, setBody: false); + Assert.Equal("name", r.Name()); + Assert.Null(r.Body()); + + // test c.Request(HttpMethod, Type, string, string) + r = c.Request(null, typeof(V1PodList), "ns", "name"); + Assert.Same(HttpMethod.Get, r.Method()); + Assert.Null(r.Group()); + Assert.Equal("v1", r.Version()); + Assert.Equal("pods", r.Type()); + Assert.Equal("name", r.Name()); + Assert.Equal("ns", r.Namespace()); + r = c.Request(HttpMethod.Delete, typeof(V1PodList), "ns", "name"); + Assert.Same(HttpMethod.Delete, r.Method()); + + // test c.Request(HttpMethod, string, string, string, string, string) + r = c.Request(HttpMethod.Put, "type", "ns", "name", "group", "version"); + Assert.Same(HttpMethod.Put, r.Method()); + Assert.Equal("type", r.Type()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("name", r.Name()); + Assert.Equal("group", r.Group()); + Assert.Equal("version", r.Version()); + + // test c.Request(string, string) + c.Scheme = KubernetesScheme.Default; + r = c.Request("ns", "name"); + Assert.Equal("v0", r.Version()); + Assert.Equal("ogrp", r.Group()); + Assert.Equal("nos", r.Type()); + Assert.Equal("ns", r.Namespace()); + Assert.Equal("name", r.Name()); + } + + [Fact] + public async Task TestReplace() + { + string value = "{}"; + bool conflict = true; + var h = new MockHttpHandler(req => + { + if(value == null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + else if(req.Method == HttpMethod.Get) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(value) }; + } + else if(req.Method == HttpMethod.Put) + { + if(conflict) + { + conflict = false; + return new HttpResponseMessage(HttpStatusCode.Conflict); + } + value = req.Content.ReadAsStringAsync().Result; + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(value) }; + } + throw new Exception("i shouldn't exist"); + }); + var c = new Kubernetes(new KubernetesClientConfiguration() { Host = "http://localhost:8080" }, new HttpClient(h)); + + int i = 0; + var res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("a", (i++).ToString(CultureInfo.InvariantCulture)); return true; }); + Assert.Equal("1", res.GetAnnotation("a")); + + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("b", "x"); return true; }); + Assert.Equal("1", res.GetAnnotation("a")); + Assert.Equal("x", res.GetAnnotation("b")); + + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("c", "y"); return false; }); + Assert.Equal("x", res.GetAnnotation("b")); + Assert.Equal("y", res.GetAnnotation("c")); + + res = await c.Request().ReplaceAsync(r => false); + Assert.Equal("x", res.GetAnnotation("b")); + Assert.Null(res.GetAnnotation("c")); + + value = null; + res = await c.Request().ReplaceAsync(r => { r.SetAnnotation("a", "x"); return true; }); + Assert.Null(res); + } + + [Fact] + public async Task TestResponse() + { + var msg = new HttpResponseMessage(HttpStatusCode.Ambiguous) { Content = new StringContent("{\"ApiVersion\":\"123\"}") }; + var resp = new KubernetesResponse(msg); + Assert.False(resp.IsError); + Assert.False(resp.IsNotFound); + Assert.Same(msg, resp.Message); + Assert.Equal(HttpStatusCode.Ambiguous, resp.StatusCode); + Assert.Equal("{\"ApiVersion\":\"123\"}", await resp.GetBodyAsync()); + var cn = await resp.GetBodyAsync(); + Assert.Equal("123", cn.ApiVersion); + var status = await resp.GetStatusAsync(); + Assert.Equal("Success", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("{\"ApiVersion\":\"123\"}", status.Message); + + msg = new HttpResponseMessage(HttpStatusCode.NotFound); + resp = new KubernetesResponse(msg); + Assert.True(resp.IsError); + Assert.True(resp.IsNotFound); + Assert.Equal("", await resp.GetBodyAsync()); + Assert.Null(await resp.GetBodyAsync()); + await Assert.ThrowsAsync(() => resp.GetBodyAsync(failIfEmpty: true)); + status = await resp.GetStatusAsync(); + Assert.Equal("Failure", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("", status.Message); + + msg = new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("It's bad, yo.") }; + resp = new KubernetesResponse(msg); + Assert.True(resp.IsError); + Assert.False(resp.IsNotFound); + Assert.Equal("It's bad, yo.", await resp.GetBodyAsync()); + await Assert.ThrowsAnyAsync(() => resp.GetBodyAsync()); + status = await resp.GetStatusAsync(); + Assert.Equal("Failure", status.Status); + Assert.Equal((int)resp.StatusCode, status.Code.Value); + Assert.Equal("It's bad, yo.", status.Message); + } + + [KubernetesEntity(ApiVersion = "v3", Group = "cgrp", Kind = "yes", PluralName = "newz")] + class CustomNew : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class CustomOld : IKubernetesObject + { + public const string KubeApiVersion = "v0", KubeGroup = "ogrp", KubeKind = "no"; + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + } +} diff --git a/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs b/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs new file mode 100644 index 000000000..9c2739286 --- /dev/null +++ b/tests/KubernetesClient.Tests/KubernetesSchemeTests.cs @@ -0,0 +1,172 @@ +using System; +using k8s.Models; +using Xunit; + +namespace k8s.Tests +{ + public class KubernetesSchemeTests + { + [Fact] + public void TestGVK() + { + Assert.NotNull(KubernetesScheme.Default); + + var s = new KubernetesScheme(); + + // test s.GetGVK + string g, v, k, p; + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("cgrp", g); + Assert.Equal("v3", v); + Assert.Equal("Yes", k); + Assert.Equal("newz", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("ogrp", g); + Assert.Equal("v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.Throws(() => s.GetGVK(out g, out v, out k)); + + // test s.GetVK + s.GetVK(out v, out k, out p); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("cgrp/v3", v); + Assert.Equal("Yes", k); + Assert.Equal("newz", p); + + s.GetVK(out v, out k, out p); + Assert.Equal("ogrp/v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.Throws(() => s.GetVK(out v, out k)); + + // test s.TryGetGVK + Assert.True(s.TryGetGVK(out g, out v, out k, out p)); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + Assert.False(s.TryGetGVK(out g, out v, out k, out p)); + + // test s.TryGetVK + Assert.True(s.TryGetVK(out v, out k, out p)); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + Assert.True(s.TryGetVK(out v, out k, out p)); + Assert.Equal("ogrp/v0", v); + Assert.Equal("No", k); + Assert.Equal("nos", p); + + Assert.False(s.TryGetVK(out v, out k, out p)); + + // test s.SetGVK and s.RemoveGVK + s.SetGVK("g", "v", "k", "p"); + s.GetVK(out v, out k, out p); + Assert.Equal("g/v", v); + Assert.Equal("k", k); + Assert.Equal("p", p); + + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("PodList", k); + Assert.Equal("pods", p); + + s.SetGVK("g2", "v2", "k2", "p2"); + s.GetVK(out v, out k, out p); + Assert.Equal("g2/v2", v); + Assert.Equal("k2", k); + Assert.Equal("p2", p); + + s.RemoveGVK(); + s.GetGVK(out g, out v, out k, out p); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("Pod", k); + Assert.Equal("pods", p); + + s.RemoveGVK(); + Assert.Throws(() => s.GetGVK(out g, out v, out k)); + } + + [Fact] + public void TestNew() + { + var s = new KubernetesScheme(); + + var p = s.New(); + Assert.Equal("v1", p.ApiVersion); + Assert.Equal("Pod", p.Kind); + + var cn = s.New("name"); + Assert.Equal("cgrp/v3", cn.ApiVersion); + Assert.Equal("Yes", cn.Kind); + Assert.Equal("name", cn.Metadata.Name); + + var co = s.New("ns", "name"); + Assert.Equal("ogrp/v0", co.ApiVersion); + Assert.Equal("No", co.Kind); + Assert.Equal("name", co.Metadata.Name); + Assert.Equal("ns", co.Metadata.NamespaceProperty); + + Assert.Throws(() => s.New()); + s.SetGVK("g", "v", "k", "p"); + + var c = s.New("ns", "name"); + Assert.Equal("g/v", c.ApiVersion); + Assert.Equal("k", c.Kind); + Assert.Equal("name", c.Metadata.Name); + Assert.Equal("ns", c.Metadata.NamespaceProperty); + } + + [KubernetesEntity(ApiVersion = "v3", Group = "cgrp", Kind = "Yes", PluralName = "newz")] + class CustomNew : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class CustomOld : IKubernetesObject + { + public const string KubeApiVersion = "v0", KubeGroup = "ogrp", KubeKind = "No"; + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + + class Custom : IKubernetesObject + { + public string ApiVersion { get; set; } + public string Kind { get; set; } + public V1ObjectMeta Metadata { get; set; } + } + } +} diff --git a/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs b/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs new file mode 100644 index 000000000..ad2a01999 --- /dev/null +++ b/tests/KubernetesClient.Tests/Mock/MockHttpHandler.cs @@ -0,0 +1,22 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace k8s.Tests.Mock +{ + class MockHttpHandler : HttpClientHandler + { + public MockHttpHandler(Func respFunc) => this.respFunc = respFunc; + + public HttpRequestMessage Request; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(respFunc(request)); + } + + readonly Func respFunc; + } +} diff --git a/tests/KubernetesClient.Tests/ModelExtensionTests.cs b/tests/KubernetesClient.Tests/ModelExtensionTests.cs new file mode 100644 index 000000000..27ec02c75 --- /dev/null +++ b/tests/KubernetesClient.Tests/ModelExtensionTests.cs @@ -0,0 +1,175 @@ +using System; +using k8s.Models; +using Xunit; + +namespace k8s.Tests +{ + public class ModelExtensionTests + { + [Fact] + public void TestMetadata() + { + // test getters on null metadata + var pod = new V1Pod(); + Assert.Null(pod.Annotations()); + Assert.Null(pod.ApiGroup()); + var (g, v) = pod.ApiGroupAndVersion(); + Assert.Null(g); + Assert.Null(v); + Assert.Null(pod.ApiGroupVersion()); + Assert.Null(pod.CreationTimestamp()); + Assert.Null(pod.DeletionTimestamp()); + Assert.Null(pod.Finalizers()); + Assert.Equal(-1, pod.FindOwnerReference(r => true)); + Assert.Null(pod.Generation()); + Assert.Null(pod.GetAnnotation("x")); + Assert.Null(pod.GetController()); + Assert.Null(pod.GetLabel("x")); + Assert.Null(pod.GetOwnerReference(r => true)); + Assert.False(pod.HasFinalizer("x")); + Assert.Null(pod.Labels()); + Assert.Null(pod.Name()); + Assert.Null(pod.Namespace()); + Assert.Null(pod.OwnerReferences()); + Assert.Null(pod.ResourceVersion()); + Assert.Null(pod.Uid()); + Assert.Null(pod.Metadata); + + // test API version stuff + pod = new V1Pod() { ApiVersion = "v1" }; + Assert.Equal("", pod.ApiGroup()); + (g, v) = pod.ApiGroupAndVersion(); + Assert.Equal("", g); + Assert.Equal("v1", v); + Assert.Equal("v1", pod.ApiGroupVersion()); + pod.ApiVersion = "abc/v2"; + Assert.Equal("abc", pod.ApiGroup()); + (g, v) = pod.ApiGroupAndVersion(); + Assert.Equal("abc", g); + Assert.Equal("v2", v); + Assert.Equal("v2", pod.ApiGroupVersion()); + + // test the Ensure*() functions + Assert.NotNull(pod.EnsureMetadata()); + Assert.NotNull(pod.Metadata); + Assert.NotNull(pod.Metadata.EnsureAnnotations()); + Assert.NotNull(pod.Metadata.Annotations); + Assert.NotNull(pod.Metadata.EnsureFinalizers()); + Assert.NotNull(pod.Metadata.Finalizers); + Assert.NotNull(pod.Metadata.EnsureLabels()); + Assert.NotNull(pod.Metadata.Labels); + + // test getters with non-null values + DateTime ts = DateTime.UtcNow, ts2 = DateTime.Now; + pod.Metadata = new V1ObjectMeta() + { + CreationTimestamp = ts, DeletionTimestamp = ts2, Generation = 1, Name = "name", NamespaceProperty = "ns", ResourceVersion = "42", Uid = "id" + }; + Assert.Equal(ts, pod.CreationTimestamp().Value); + Assert.Equal(ts2, pod.DeletionTimestamp().Value); + Assert.Equal(1, pod.Generation().Value); + Assert.Equal("name", pod.Name()); + Assert.Equal("ns", pod.Namespace()); + Assert.Equal("42", pod.ResourceVersion()); + Assert.Equal("id", pod.Uid()); + + // test annotations and labels + pod.SetAnnotation("x", "y"); + pod.SetLabel("a", "b"); + Assert.Equal(1, pod.Annotations().Count); + Assert.Equal(1, pod.Labels().Count); + Assert.Equal("y", pod.GetAnnotation("x")); + Assert.Equal("y", pod.Metadata.Annotations["x"]); + Assert.Null(pod.GetAnnotation("a")); + Assert.Equal("b", pod.GetLabel("a")); + Assert.Equal("b", pod.Metadata.Labels["a"]); + Assert.Null(pod.GetLabel("x")); + pod.SetAnnotation("x", null); + Assert.Equal(0, pod.Annotations().Count); + pod.SetLabel("a", null); + Assert.Equal(0, pod.Labels().Count); + + // test finalizers + Assert.False(pod.HasFinalizer("abc")); + Assert.True(pod.AddFinalizer("abc")); + Assert.True(pod.HasFinalizer("abc")); + Assert.False(pod.AddFinalizer("abc")); + Assert.False(pod.HasFinalizer("xyz")); + Assert.False(pod.RemoveFinalizer("xyz")); + Assert.True(pod.RemoveFinalizer("abc")); + Assert.False(pod.HasFinalizer("abc")); + Assert.False(pod.RemoveFinalizer("abc")); + } + + [Fact] + public void TestReferences() + { + // test object references + var pod = new V1Pod() { ApiVersion = "abc/xyz", Kind = "sometimes" }; + pod.Metadata = new V1ObjectMeta() { Name = "name", NamespaceProperty = "ns", ResourceVersion = "ver", Uid = "id" }; + var objr = pod.CreateObjectReference(); + Assert.Equal(pod.ApiVersion, objr.ApiVersion); + Assert.Equal(pod.Kind, objr.Kind); + Assert.Equal(pod.Name(), objr.Name); + Assert.Equal(pod.Namespace(), objr.NamespaceProperty); + Assert.Equal(pod.ResourceVersion(), objr.ResourceVersion); + Assert.Equal(pod.Uid(), objr.Uid); + Assert.True(objr.Matches(pod)); + + (pod.ApiVersion, pod.Kind) = (null, null); + objr = pod.CreateObjectReference(); + Assert.Equal("v1", objr.ApiVersion); + Assert.Equal("Pod", objr.Kind); + Assert.False(objr.Matches(pod)); + (pod.ApiVersion, pod.Kind) = (objr.ApiVersion, objr.Kind); + Assert.True(objr.Matches(pod)); + pod.Metadata.Name = "nome"; + Assert.False(objr.Matches(pod)); + + // test owner references + (pod.ApiVersion, pod.Kind) = ("abc/xyz", "sometimes"); + var ownr = pod.CreateOwnerReference(true, false); + Assert.Equal(pod.ApiVersion, ownr.ApiVersion); + Assert.Equal(pod.Kind, ownr.Kind); + Assert.Equal(pod.Name(), ownr.Name); + Assert.Equal(pod.Uid(), ownr.Uid); + Assert.True(ownr.Controller.Value); + Assert.False(ownr.BlockOwnerDeletion.Value); + Assert.True(ownr.Matches(pod)); + + (pod.ApiVersion, pod.Kind) = (null, null); + Assert.False(ownr.Matches(pod)); + ownr = pod.CreateOwnerReference(); + Assert.Equal("v1", ownr.ApiVersion); + Assert.Equal("Pod", ownr.Kind); + Assert.Null(ownr.Controller); + Assert.Null(ownr.BlockOwnerDeletion); + Assert.False(ownr.Matches(pod)); + (pod.ApiVersion, pod.Kind) = (ownr.ApiVersion, ownr.Kind); + Assert.True(ownr.Matches(pod)); + ownr.Name = "nim"; + Assert.False(ownr.Matches(pod)); + ownr.Name = pod.Name(); + + var svc = new V1Service(); + svc.AddOwnerReference(ownr); + Assert.Equal(0, svc.FindOwnerReference(pod)); + Assert.Equal(-1, svc.FindOwnerReference(svc)); + Assert.Same(ownr, svc.GetOwnerReference(pod)); + Assert.Null(svc.GetOwnerReference(svc)); + Assert.Null(svc.GetController()); + svc.OwnerReferences()[0].Controller = true; + Assert.Same(ownr, svc.GetController()); + Assert.Same(ownr, svc.RemoveOwnerReference(pod)); + Assert.Equal(0, svc.OwnerReferences().Count); + svc.AddOwnerReference(pod.CreateOwnerReference(true)); + svc.AddOwnerReference(pod.CreateOwnerReference(false)); + svc.AddOwnerReference(pod.CreateOwnerReference()); + Assert.Equal(3, svc.OwnerReferences().Count); + Assert.NotNull(svc.RemoveOwnerReference(pod)); + Assert.Equal(2, svc.OwnerReferences().Count); + Assert.True(svc.RemoveOwnerReferences(pod)); + Assert.Equal(0, svc.OwnerReferences().Count); + } + } +}