diff --git a/Sources/SwiftkubeClient/Client/KubernetesClient.swift b/Sources/SwiftkubeClient/Client/KubernetesClient.swift index affcf8f..061943b 100644 --- a/Sources/SwiftkubeClient/Client/KubernetesClient.swift +++ b/Sources/SwiftkubeClient/Client/KubernetesClient.swift @@ -105,34 +105,34 @@ public actor KubernetesClient { #endif #if os(Linux) || os(macOS) - /// Create a new instance of the Kubernetes client. - /// - /// The client tries to resolve a `kube config` automatically from different sources in the following order: - /// - /// - A Kube config file at path of environment variable `KUBECONFIG` (if set) - /// - A Kube config file in the user's `$HOME/.kube/config` directory - /// - `ServiceAccount` token located at `/var/run/secrets/kubernetes.io/serviceaccount/token` and a mounted CA certificate, if it's running in Kubernetes. - /// - /// Returns `nil` if a configuration can't be found. - /// - /// - Note: This initializer is only available on Linux and macOS. On iOS, tvOS, and watchOS, you must use - /// `init(config:provider:logger:)` with a manually configured `KubernetesClientConfig`. - /// - /// - Parameters: - /// - provider: A ``EventLoopGroupProvider`` to specify how ``EventLoopGroup`` will be created. - /// - logger: The logger to use for this client. - public init?( - provider: HTTPClient.EventLoopGroupProvider = .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1)), - logger: Logger? = nil - ) { - guard - let config = try? KubernetesClientConfig.initialize(logger: logger) - else { - return nil - } + /// Create a new instance of the Kubernetes client. + /// + /// The client tries to resolve a `kube config` automatically from different sources in the following order: + /// + /// - A Kube config file at path of environment variable `KUBECONFIG` (if set) + /// - A Kube config file in the user's `$HOME/.kube/config` directory + /// - `ServiceAccount` token located at `/var/run/secrets/kubernetes.io/serviceaccount/token` and a mounted CA certificate, if it's running in Kubernetes. + /// + /// Returns `nil` if a configuration can't be found. + /// + /// - Note: This initializer is only available on Linux and macOS. On iOS, tvOS, and watchOS, you must use + /// `init(config:provider:logger:)` with a manually configured `KubernetesClientConfig`. + /// + /// - Parameters: + /// - provider: A ``EventLoopGroupProvider`` to specify how ``EventLoopGroup`` will be created. + /// - logger: The logger to use for this client. + public init?( + provider: HTTPClient.EventLoopGroupProvider = .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1)), + logger: Logger? = nil + ) { + guard + let config = try? KubernetesClientConfig.initialize(logger: logger) + else { + return nil + } - self.init(config: config, provider: provider, logger: logger) - } + self.init(config: config, provider: provider, logger: logger) + } #endif /// Create a new instance of the Kubernetes client. diff --git a/Sources/SwiftkubeClient/Client/KubernetesRequest.swift b/Sources/SwiftkubeClient/Client/KubernetesRequest.swift index eb8557a..517410b 100644 --- a/Sources/SwiftkubeClient/Client/KubernetesRequest.swift +++ b/Sources/SwiftkubeClient/Client/KubernetesRequest.swift @@ -102,7 +102,7 @@ public struct KubernetesRequest: Sendable { // MARK: - RequestBody -internal enum RequestBody: Sendable { +internal enum RequestBody { case resource(payload: any KubernetesAPIResource) case subResource(type: ResourceType, payload: any KubernetesResource) diff --git a/Sources/SwiftkubeClient/Config/AuthInfo+Authentication.swift b/Sources/SwiftkubeClient/Config/AuthInfo+Authentication.swift index be04ecc..a599d5a 100644 --- a/Sources/SwiftkubeClient/Config/AuthInfo+Authentication.swift +++ b/Sources/SwiftkubeClient/Config/AuthInfo+Authentication.swift @@ -44,16 +44,8 @@ public extension AuthInfo { return .bearer(token: token) } - do { - if let tokenFile = tokenFile { - let fileURL = URL(fileURLWithPath: tokenFile) - let token = try String(contentsOf: fileURL, encoding: .utf8) - return .bearer(token: token) - } - } catch { - logger?.warning( - "Error initializing authentication from token file \(String(describing: tokenFile)): \(error)" - ) + if let tokenFile = tokenFile { + return .tokenFile(source: CachedFileTokenSource(path: tokenFile)) } do { @@ -122,9 +114,9 @@ public extension AuthInfo { // MARK: - ExecCredential -// It seems that AWS doesn't implement properly the model for client.authentication.k8s.io/v1beta1 -// Acordingly with the doc https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/ -// ExecCredential.Spec.interactive is required as long as the ones in the Status object. +/// It seems that AWS doesn't implement properly the model for client.authentication.k8s.io/v1beta1 +/// Acordingly with the doc https://kubernetes.io/docs/reference/config-api/client-authentication.v1beta1/ +/// ExecCredential.Spec.interactive is required as long as the ones in the Status object. public struct ExecCredential: Codable { let apiVersion: String let kind: String diff --git a/Sources/SwiftkubeClient/Config/KubeConfig+Loaders.swift b/Sources/SwiftkubeClient/Config/KubeConfig+Loaders.swift index 27b20f5..a7e64a6 100644 --- a/Sources/SwiftkubeClient/Config/KubeConfig+Loaders.swift +++ b/Sources/SwiftkubeClient/Config/KubeConfig+Loaders.swift @@ -32,122 +32,122 @@ public extension KubeConfig { } #if os(Linux) || os(macOS) - static func fromEnvironment(envVar: String = "KUBECONFIG", logger: Logger? = nil) throws -> KubeConfig? { - guard let varContent = ProcessInfo.processInfo.environment[envVar] else { - logger?.info("Skipping kubeconfig because environment variable \(envVar) is not set") - return nil - } + static func fromEnvironment(envVar: String = "KUBECONFIG", logger: Logger? = nil) throws -> KubeConfig? { + guard let varContent = ProcessInfo.processInfo.environment[envVar] else { + logger?.info("Skipping kubeconfig because environment variable \(envVar) is not set") + return nil + } - let expanded = varContent.stringByExpandingTildePath() - let kubeConfigURL = URL(fileURLWithPath: expanded) - logger?.info("Loading configuration from \(kubeConfigURL)") + let expanded = varContent.stringByExpandingTildePath() + let kubeConfigURL = URL(fileURLWithPath: expanded) + logger?.info("Loading configuration from \(kubeConfigURL)") - return try from(url: kubeConfigURL) - } - #endif - - #if os(Linux) || os(macOS) - static func fromDefaultLocalConfig(logger: Logger? = nil) throws -> KubeConfig? { - guard let homePath = ProcessInfo.processInfo.environment["HOME"] else { - logger?.info("Skipping kubeconfig in $HOME/.kube/config because HOME env variable is not set.") - return nil + return try from(url: kubeConfigURL) } - - let kubeConfigURL = URL(fileURLWithPath: homePath + "/.kube/config") - logger?.info("Loading configuration from \(kubeConfigURL)") - - return try from(url: kubeConfigURL) - } #endif #if os(Linux) || os(macOS) - static func fromServiceAccount(logger: Logger? = nil) throws -> KubeConfig? { - guard - let host = ProcessInfo.processInfo.environment["KUBERNETES_SERVICE_HOST"], - let port = ProcessInfo.processInfo.environment["KUBERNETES_SERVICE_PORT"] - else { - logger?.warning("Skipping service account kubeconfig because either KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT is not set") - return nil - } + static func fromDefaultLocalConfig(logger: Logger? = nil) throws -> KubeConfig? { + guard let homePath = ProcessInfo.processInfo.environment["HOME"] else { + logger?.info("Skipping kubeconfig in $HOME/.kube/config because HOME env variable is not set.") + return nil + } - let apiServerUrl = if host.contains(":") { - "https://[\(host)]:\(port)" - } else { - "https://\(host):\(port)" - } + let kubeConfigURL = URL(fileURLWithPath: homePath + "/.kube/config") + logger?.info("Loading configuration from \(kubeConfigURL)") - let tokenFile = URL(fileURLWithPath: "/var/run/secrets/kubernetes.io/serviceaccount/token") - guard let token = try? String(contentsOf: tokenFile, encoding: .utf8) else { - logger?.warning("Did not find service account token at /var/run/secrets/kubernetes.io/serviceaccount/token") - return nil + return try from(url: kubeConfigURL) } + #endif - let namespaceFile = URL(fileURLWithPath: "/var/run/secrets/kubernetes.io/serviceaccount/namespace") - let namespace = try? String(contentsOf: namespaceFile, encoding: .utf8) - if namespace == nil { - logger?.debug("Did not find service account namespace at /var/run/secrets/kubernetes.io/serviceaccount/namespace") + #if os(Linux) || os(macOS) + static func fromServiceAccount(logger: Logger? = nil) throws -> KubeConfig? { + guard + let host = ProcessInfo.processInfo.environment["KUBERNETES_SERVICE_HOST"], + let port = ProcessInfo.processInfo.environment["KUBERNETES_SERVICE_PORT"] + else { + logger?.warning("Skipping service account kubeconfig because either KUBERNETES_SERVICE_HOST or KUBERNETES_SERVICE_PORT is not set") + return nil + } + + let apiServerUrl = if host.contains(":") { + "https://[\(host)]:\(port)" + } else { + "https://\(host):\(port)" + } + + let tokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + guard FileManager.default.fileExists(atPath: tokenPath) else { + logger?.warning("Did not find service account token at \(tokenPath)") + return nil + } + + let namespaceFile = URL(fileURLWithPath: "/var/run/secrets/kubernetes.io/serviceaccount/namespace") + let namespace = try? String(contentsOf: namespaceFile, encoding: .utf8) + if namespace == nil { + logger?.debug("Did not find service account namespace at /var/run/secrets/kubernetes.io/serviceaccount/namespace") + } + + let certificateAuthorityFile = URL(fileURLWithPath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") + let certificateAuthorityData = try? Data(contentsOf: certificateAuthorityFile) + + return KubeConfig( + kind: "Config", + apiVersion: "v1", + clusters: [ + NamedCluster( + name: "kubernetes-cluster-local", + cluster: Cluster( + server: apiServerUrl, + insecureSkipTLSVerify: certificateAuthorityData == nil, + certificateAuthorityData: certificateAuthorityData + ) + ), + ], + users: [ + NamedAuthInfo( + name: "service-account-user", + authInfo: AuthInfo( + tokenFile: tokenPath + ) + ), + ], + contexts: [ + NamedContext( + name: "service-account-context", + context: Context( + cluster: "kubernetes-cluster-local", + user: "service-account-user", + namespace: namespace + ) + ), + ], + currentContext: "service-account-context" + ) } - - let certificateAuthorityFile = URL(fileURLWithPath: "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt") - let certificateAuthorityData = try? Data(contentsOf: certificateAuthorityFile) - - return KubeConfig( - kind: "Config", - apiVersion: "v1", - clusters: [ - NamedCluster( - name: "kubernetes-cluster-local", - cluster: Cluster( - server: apiServerUrl, - insecureSkipTLSVerify: certificateAuthorityData == nil, - certificateAuthorityData: certificateAuthorityData - ) - ), - ], - users: [ - NamedAuthInfo( - name: "service-account-user", - authInfo: AuthInfo( - token: token - ) - ), - ], - contexts: [ - NamedContext( - name: "service-account-context", - context: Context( - cluster: "kubernetes-cluster-local", - user: "service-account-user", - namespace: namespace - ) - ), - ], - currentContext: "service-account-context" - ) - } #endif } #if os(Linux) || os(macOS) -internal extension String { + internal extension String { - func stringByExpandingTildePath() -> String { - guard !isEmpty else { - return "" - } + func stringByExpandingTildePath() -> String { + guard !isEmpty else { + return "" + } - if self == "~" { - return FileManager.default.homeDirectoryForCurrentUser.path - } + if self == "~" { + return FileManager.default.homeDirectoryForCurrentUser.path + } - guard hasPrefix("~/") else { - return self - } + guard hasPrefix("~/") else { + return self + } - var relativePath = self - relativePath.removeFirst(2) + var relativePath = self + relativePath.removeFirst(2) - return FileManager.default.homeDirectoryForCurrentUser.path + "/" + relativePath + return FileManager.default.homeDirectoryForCurrentUser.path + "/" + relativePath + } } -} #endif diff --git a/Sources/SwiftkubeClient/Config/KubernetesClientConfig+Init.swift b/Sources/SwiftkubeClient/Config/KubernetesClientConfig+Init.swift index 0028f0c..0921d18 100644 --- a/Sources/SwiftkubeClient/Config/KubernetesClientConfig+Init.swift +++ b/Sources/SwiftkubeClient/Config/KubernetesClientConfig+Init.swift @@ -23,53 +23,53 @@ import Yams public extension KubernetesClientConfig { #if os(Linux) || os(macOS) - /// Initializes a client configuration. - /// - /// This factory method tries to resolve a `kube config` automatically from different sources in the following order: - /// - /// - A Kube config file at path of environment variable `KUBECONFIG` (if set) - /// - A Kube config file in the user's `$HOME/.kube/config` directory - /// - `ServiceAccount` token located at `/var/run/secrets/kubernetes.io/serviceaccount/token` and a mounted CA certificate, if it's running in Kubernetes. - /// - /// It is also possible to override the default values for the underlying `HTTPClient` timeout and redirect config. - /// - /// - Note: This method is only available on Linux and macOS. On iOS, tvOS, and watchOS, you must manually configure - /// the client using `KubernetesClientConfig.init()` or `from(kubeConfig:)`. - /// - /// - Parameters: - /// - timeout: The desired timeout configuration to apply. If not provided, then `connect` timeout will default to 10 seconds. - /// - redirectConfiguration: Specifies redirect processing settings. If not provided, then it will default to a maximum of 5 follows w/o cycles. - /// - logger: The logger to use for the underlying configuration loaders. - /// - Returns: An instance of KubernetesClientConfig for the Swiftkube KubernetesClient - static func initialize( - timeout: HTTPClient.Configuration.Timeout? = nil, - redirectConfiguration: HTTPClient.Configuration.RedirectConfiguration? = nil, - logger: Logger? - ) throws -> KubernetesClientConfig? { - let kubeConfig: KubeConfig? = { - if let config = try? KubeConfig.fromEnvironment() { - return config - } - - if let config = try? KubeConfig.fromDefaultLocalConfig() { - return config + /// Initializes a client configuration. + /// + /// This factory method tries to resolve a `kube config` automatically from different sources in the following order: + /// + /// - A Kube config file at path of environment variable `KUBECONFIG` (if set) + /// - A Kube config file in the user's `$HOME/.kube/config` directory + /// - `ServiceAccount` token located at `/var/run/secrets/kubernetes.io/serviceaccount/token` and a mounted CA certificate, if it's running in Kubernetes. + /// + /// It is also possible to override the default values for the underlying `HTTPClient` timeout and redirect config. + /// + /// - Note: This method is only available on Linux and macOS. On iOS, tvOS, and watchOS, you must manually configure + /// the client using `KubernetesClientConfig.init()` or `from(kubeConfig:)`. + /// + /// - Parameters: + /// - timeout: The desired timeout configuration to apply. If not provided, then `connect` timeout will default to 10 seconds. + /// - redirectConfiguration: Specifies redirect processing settings. If not provided, then it will default to a maximum of 5 follows w/o cycles. + /// - logger: The logger to use for the underlying configuration loaders. + /// - Returns: An instance of KubernetesClientConfig for the Swiftkube KubernetesClient + static func initialize( + timeout: HTTPClient.Configuration.Timeout? = nil, + redirectConfiguration: HTTPClient.Configuration.RedirectConfiguration? = nil, + logger: Logger? + ) throws -> KubernetesClientConfig? { + let kubeConfig: KubeConfig? = { + if let config = try? KubeConfig.fromEnvironment() { + return config + } + + if let config = try? KubeConfig.fromDefaultLocalConfig() { + return config + } + + return try? KubeConfig.fromServiceAccount() + }() + + guard let kubeConfig = kubeConfig else { + return nil } - return try? KubeConfig.fromServiceAccount() - }() - - guard let kubeConfig = kubeConfig else { - return nil + return try from( + kubeConfig: kubeConfig, + contextName: nil, + timeout: timeout, + redirectConfiguration: redirectConfiguration, + logger: logger + ) } - - return try from( - kubeConfig: kubeConfig, - contextName: nil, - timeout: timeout, - redirectConfiguration: redirectConfiguration, - logger: logger - ) - } #endif /// Initializes a client configuration from a given KubeConfig using the specified `current-context`. diff --git a/Sources/SwiftkubeClient/Config/KubernetesClientConfig.swift b/Sources/SwiftkubeClient/Config/KubernetesClientConfig.swift index 189c1fb..66f394e 100644 --- a/Sources/SwiftkubeClient/Config/KubernetesClientConfig.swift +++ b/Sources/SwiftkubeClient/Config/KubernetesClientConfig.swift @@ -17,6 +17,7 @@ import AsyncHTTPClient import Foundation import Logging +import NIOCore import NIOSSL import Yams @@ -68,6 +69,62 @@ public struct KubernetesClientConfig: Sendable { } } +// MARK: - CachedFileTokenSource + +/// A thread-safe, file-backed token source that caches the token with a synthetic TTL. +/// +/// The token file is re-read from disk only when the cached value has expired. +/// This mirrors the caching strategy used by client-go's `fileTokenSource` / +/// `cachingTokenSource`, which stamps each read with a synthetic 1-minute expiry +/// so the file is re-read roughly every minute rather than on every request. +public final class CachedFileTokenSource: @unchecked Sendable { + + /// The path to the token file on disk. + public var path: String { + lock.lock() + defer { lock.unlock() } + return state.path + } + + private struct State { + var path: String + var cachedToken: String? + var expiry: NIODeadline = .distantPast + var cacheDuration: TimeAmount + } + + private let lock = NSLock() + private var state: State + + /// Creates a new cached file token source. + /// - Parameters: + /// - path: The filesystem path to the token file. + /// - cacheDuration: How long to cache a token before re-reading from disk. Defaults to 60 seconds. + public init(path: String, cacheDuration: TimeAmount = .seconds(60)) { + self.state = State(path: path, cacheDuration: cacheDuration) + } + + /// Returns the current token, re-reading from disk if the cache has expired. + public func token() -> String? { + lock.lock() + defer { lock.unlock() } + + let now = NIODeadline.now() + if let cachedToken = state.cachedToken, now < state.expiry { + return cachedToken + } + + guard let newToken = try? String(contentsOfFile: state.path, encoding: .utf8) else { + return nil + } + + let trimmed = newToken.trimmingCharacters(in: .whitespacesAndNewlines) + state.cachedToken = trimmed + state.expiry = now + state.cacheDuration + return trimmed + } +} + // MARK: - KubernetesClientAuthentication /// Supported client authentication schemes. @@ -76,6 +133,8 @@ public enum KubernetesClientAuthentication: Sendable { case basicAuth(username: String, password: String) /// Bearer token authentication scheme via a valid API token. case bearer(token: String) + /// File-backed bearer token with a cached token source that re-reads from disk periodically. + case tokenFile(source: CachedFileTokenSource) /// Certificate-based authenticaiton scheme with valid client certificate-key pair. case x509(clientCertificate: NIOSSLCertificate, clientKey: NIOSSLPrivateKey) @@ -85,6 +144,11 @@ public enum KubernetesClientAuthentication: Sendable { return HTTPClient.Authorization.basic(username: username, password: password).headerValue case let .bearer(token: token): return HTTPClient.Authorization.bearer(tokens: token).headerValue + case let .tokenFile(source: source): + guard let token = source.token() else { + return nil + } + return HTTPClient.Authorization.bearer(tokens: token).headerValue default: return nil } diff --git a/Sources/SwiftkubeClient/Discovery/KubernetesClient+Discovery.swift b/Sources/SwiftkubeClient/Discovery/KubernetesClient+Discovery.swift index 2fc6475..f48d7cd 100644 --- a/Sources/SwiftkubeClient/Discovery/KubernetesClient+Discovery.swift +++ b/Sources/SwiftkubeClient/Discovery/KubernetesClient+Discovery.swift @@ -126,11 +126,9 @@ internal class DiscoveryClient: DiscoveryAPI, RequestHandlerType { allResourceLists.append(it) } - let merged = allResourceLists.reduce(into: [meta.v1.APIResourceList]()) { (acc, other: meta.v1.APIResourceList) in + return allResourceLists.reduce(into: [meta.v1.APIResourceList]()) { (acc, other: meta.v1.APIResourceList) in acc.append(other) } - - return merged } func serverResources(forGroupVersion groupVersion: String) async throws -> meta.v1.APIResourceList {