packages feed

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

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      :  OpenTelemetry.Resource.Container.Detector
Copyright   :  (c) Ian Duncan, 2024
License     :  BSD-3
Description :  Auto-detect container resource attributes
Maintainer  :  Ian Duncan
Stability   :  experimental
Portability :  non-portable (GHC extensions)

Detects whether the process is running inside a container and extracts
the container ID and runtime. Works on Linux via the @\/proc@ filesystem;
returns an empty 'Container' on other platforms.

Container ID extraction supports both cgroup v1 and cgroup v2.

@since 0.1.0.2
-}
module OpenTelemetry.Resource.Container.Detector (
  detectContainer,
) where

import Data.Maybe (listToMaybe, mapMaybe)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import OpenTelemetry.Resource.Container (Container (..))
import System.IO.Error (tryIOError)


-- | @since 0.0.1.0
detectContainer :: IO Container
detectContainer = do
  cid <- detectContainerId
  rt <- detectRuntime
  pure
    Container
      { containerName = Nothing
      , containerId = cid
      , containerRuntime = rt
      , containerImageName = Nothing
      , containerImageTag = Nothing
      , containerImageId = Nothing
      , ociManifestDigest = Nothing
      , containerImageRepoDigests = Nothing
      }


detectContainerId :: IO (Maybe T.Text)
detectContainerId = do
  v1 <- tryReadFile "/proc/self/cgroup"
  case v1 >>= parseCgroupV1Id of
    Just cid -> pure (Just cid)
    Nothing -> do
      mi <- tryReadFile "/proc/self/mountinfo"
      pure (mi >>= parseMountInfoId)


parseCgroupV1Id :: T.Text -> Maybe T.Text
parseCgroupV1Id contents =
  listToMaybe $ mapMaybe extractFromLine (T.lines contents)
  where
    extractFromLine line =
      case T.splitOn ":" line of
        [_, _, path] -> extractIdFromPath path
        _ -> Nothing

    extractIdFromPath path
      | T.null path = Nothing
      | path == "/" = Nothing
      | otherwise =
          let seg = T.takeWhileEnd (/= '/') path
          in if isHexId seg then Just seg else Nothing


parseMountInfoId :: T.Text -> Maybe T.Text
parseMountInfoId contents =
  listToMaybe $ mapMaybe extractFromLine (T.lines contents)
  where
    extractFromLine line =
      let parts = T.words line
      in case filter containsContainerId parts of
           (p : _) -> extractLastHexSegment p
           [] -> Nothing

    containsContainerId part =
      T.isInfixOf "/docker/containers/" part
        || T.isInfixOf "/containerd/" part
        || T.isInfixOf "/cri-o/" part
        || T.isInfixOf "/pods/" part

    extractLastHexSegment path =
      let seg = T.takeWhileEnd (/= '/') path
      in if isHexId seg then Just seg else Nothing


isHexId :: T.Text -> Bool
isHexId t =
  T.length t >= 12 && T.all isHexChar t
  where
    isHexChar c = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')


detectRuntime :: IO (Maybe T.Text)
detectRuntime = do
  dockerEnv <- tryReadFile "/.dockerenv"
  containerEnv <- tryReadFile "/run/.containerenv"
  cgroupContent <- tryReadFile "/proc/self/cgroup"
  pure $ case (dockerEnv, containerEnv, cgroupContent) of
    (Just _, _, _) -> Just "docker"
    (_, Just _, _) -> Just "podman"
    (_, _, Just cg)
      | T.isInfixOf "containerd" cg -> Just "containerd"
      | T.isInfixOf "cri-o" cg -> Just "cri-o"
      | T.isInfixOf "/docker/" cg -> Just "docker"
      | T.isInfixOf "/lxc/" cg -> Just "lxc"
    _ -> Nothing


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