packages feed

hs-opentelemetry-sdk-1.0.0.0: src/OpenTelemetry/Resource/Detector/AWS/EKS.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      :  OpenTelemetry.Resource.Detector.AWS.EKS
Copyright   :  (c) Ian Duncan, 2024
License     :  BSD-3
Description :  Detect AWS EKS resource attributes via the Kubernetes API
Maintainer  :  Ian Duncan
Stability   :  experimental

Detects whether the process is running on Amazon EKS by querying the
in-cluster Kubernetes API for the @aws-auth@ ConfigMap in the
@kube-system@ namespace. If present, sets @cloud.provider=aws@ and
@cloud.platform=aws_eks@.

Also attempts to read the cluster name from the @cluster-info@
ConfigMap in the @amazon-cloudwatch@ namespace (populated by the
CloudWatch agent or Fluentd DaemonSet).

TLS validation uses the in-cluster CA certificate at
@\/var\/run\/secrets\/kubernetes.io\/serviceaccount\/ca.crt@.
Authentication uses the service account bearer token at
@\/var\/run\/secrets\/kubernetes.io\/serviceaccount\/token@.

Returns an empty 'Resource' if not running on EKS or if the
Kubernetes API is unreachable.

Populates: @cloud.provider@, @cloud.platform@, @k8s.cluster.name@.

@since 0.1.0.2
-}
module OpenTelemetry.Resource.Detector.AWS.EKS (
  detectEKS,
  detectEKSSelf,
) where

import Data.Aeson (FromJSON (..), withObject, (.:?))
import qualified Data.HashMap.Strict as H
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import OpenTelemetry.Attributes.Key (unkey)
import OpenTelemetry.Resource (Resource, mkResource, (.=), (.=?))
import OpenTelemetry.Resource.Detector.Metadata
import qualified OpenTelemetry.SemanticConventions as SC
import System.Environment (lookupEnv)
import System.IO.Error (tryIOError)


k8sTokenPath :: FilePath
k8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"


k8sCertPath :: FilePath
k8sCertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"


mkK8sApiUrl :: String -> String -> String -> String
mkK8sApiUrl host port path
  | ':' `elem` host = "https://[" ++ host ++ "]:" ++ port ++ path
  | otherwise = "https://" ++ host ++ ":" ++ port ++ path


authConfigmapPath :: String
authConfigmapPath = "/api/v1/namespaces/kube-system/configmaps/aws-auth"


clusterInfoPath :: String
clusterInfoPath = "/api/v1/namespaces/amazon-cloudwatch/configmaps/cluster-info"


{- | Self-contained EKS detector suitable for the resource detector registry.

Checks for Kubernetes service account files and environment, then queries
the k8s API to confirm EKS. Skips if ECS or Lambda are detected (those
have more specific detectors). Returns an empty resource if not on EKS.
-}
detectEKSSelf :: IO Resource
detectEKSSelf = do
  mHost <- lookupEnv "KUBERNETES_SERVICE_HOST"
  case mHost of
    Nothing -> pure $ mkResource []
    Just host -> do
      mEcs <- lookupEnv "ECS_CONTAINER_METADATA_URI_V4"
      mLambda <- lookupEnv "AWS_LAMBDA_FUNCTION_NAME"
      if mEcs /= Nothing || mLambda /= Nothing
        then pure $ mkResource []
        else do
          mToken <- readFileStripped k8sTokenPath
          case mToken of
            Nothing -> pure $ mkResource []
            Just token -> do
              port <- fromMaybe "443" <$> lookupEnv "KUBERNETES_SERVICE_PORT"
              mClient <- newTlsMetadataClient k8sCertPath host
              case mClient of
                Nothing -> pure $ mkResource []
                Just client -> detectEKS client host port token


{- | Detect EKS attributes by querying the in-cluster Kubernetes API.

Checks for the @aws-auth@ ConfigMap (EKS-specific) and reads the cluster
name from @cluster-info@ if available. Returns an empty resource if the
API is unreachable or the @aws-auth@ ConfigMap is absent.
-}
detectEKS :: MetadataClient -> String -> String -> Text -> IO Resource
detectEKS client host port token = do
  let bearerHeaders = [("Authorization", "Bearer " <> token)]
      authUrl = mkK8sApiUrl host port authConfigmapPath
  mAuthCm <- fetchJSONWithHeaders client authUrl bearerHeaders
  case (mAuthCm :: Maybe ConfigMapResponse) of
    Nothing -> pure $ mkResource []
    Just _ -> do
      let infoUrl = mkK8sApiUrl host port clusterInfoPath
      mClusterInfo <- fetchJSONWithHeaders client infoUrl bearerHeaders
      let mClusterName = mClusterInfo >>= cmLookupData "cluster.name"
      pure $
        mkResource
          [ unkey SC.cloud_provider .= ("aws" :: Text)
          , unkey SC.cloud_platform .= ("aws_eks" :: Text)
          , unkey SC.k8s_cluster_name .=? mClusterName
          ]


readFileStripped :: FilePath -> IO (Maybe Text)
readFileStripped path = do
  result <- tryIOError (T.readFile path)
  pure $ case result of
    Right content
      | not (T.null (T.strip content)) -> Just (T.strip content)
    _ -> Nothing


data ConfigMapResponse = ConfigMapResponse
  { cmData :: !(Maybe (H.HashMap Text Text))
  }


instance FromJSON ConfigMapResponse where
  parseJSON = withObject "ConfigMapResponse" $ \o ->
    ConfigMapResponse <$> o .:? "data"


cmLookupData :: Text -> ConfigMapResponse -> Maybe Text
cmLookupData key cm = cmData cm >>= H.lookup key