From e473bc005655a70aa288603ef67e3deb2729eed7 Mon Sep 17 00:00:00 2001 From: Vehbi Sinan Tunalioglu Date: Sat, 16 Mar 2024 17:17:20 +0800 Subject: [PATCH 1/5] chore: add data definitions for lhp report and its components --- .hlint.yaml | 2 + package.yaml | 4 + src/Lhp/Types.hs | 200 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 src/Lhp/Types.hs diff --git a/.hlint.yaml b/.hlint.yaml index 9f2012e..e11a1fb 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -29,6 +29,8 @@ - extensions: - default: false # All extension are banned by default. - name: + - DeriveGeneric + - DerivingVia - OverloadedStrings ################ diff --git a/package.yaml b/package.yaml index 01d38f2..cce59fa 100644 --- a/package.yaml +++ b/package.yaml @@ -22,8 +22,12 @@ library: - -Werror - -Wunused-packages dependencies: + - aeson + - autodocodec - optparse-applicative + - scientific - text + - time executables: lhp: diff --git a/src/Lhp/Types.hs b/src/Lhp/Types.hs new file mode 100644 index 0000000..5b63851 --- /dev/null +++ b/src/Lhp/Types.hs @@ -0,0 +1,200 @@ +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | This module defines public data and type definitions to represent +-- a complete lhp report. +module Lhp.Types where + +import qualified Autodocodec as ADC +import qualified Data.Aeson as Aeson +import Data.Scientific (Scientific) +import qualified Data.Text as T +import qualified Data.Time as Time +import GHC.Generics (Generic) + + +-- * Report + + +-- | Data definition for host patrol report. +data Report = Report + { _reportHost :: !T.Text + , _reportCloud :: !Cloud + , _reportHardware :: !Hardware + , _reportKernel :: !Kernel + , _reportDistribution :: !Distribution + , _reportDockerContainers :: !(Maybe [DockerContainer]) + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Report) + + +instance ADC.HasCodec Report where + codec = + _codec ADC. "Host Patrol Report" + where + _codec = + ADC.object "Report" $ + Report + <$> ADC.requiredField "host" "Name of the host." ADC..= _reportHost + <*> ADC.requiredField "cloud" "Cloud information." ADC..= _reportCloud + <*> ADC.requiredField "hardware" "Hardware information." ADC..= _reportHardware + <*> ADC.requiredField "kernel" "Kernel information." ADC..= _reportKernel + <*> ADC.requiredField "distribution" "Distribution information." ADC..= _reportDistribution + <*> ADC.requiredField "dockerContainers" "List of Docker containers if the host is a Docker host." ADC..= _reportDockerContainers + + +-- * Cloud Information + + +-- | Data definition for host's cloud information. +data Cloud = Cloud + { _cloudName :: !T.Text + , _cloudHostId :: !(Maybe T.Text) + , _cloudHostType :: !(Maybe T.Text) + , _cloudHostRegion :: !(Maybe T.Text) + , _cloudHostAvailabilityZone :: !(Maybe T.Text) + , _cloudHostLocalHostname :: !(Maybe T.Text) + , _cloudHostLocalAddress :: !(Maybe T.Text) + , _cloudHostRemoteHostname :: !(Maybe T.Text) + , _cloudHostRemoteAddress :: !(Maybe T.Text) + , _cloudHostReservedAddress :: !(Maybe T.Text) + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Cloud) + + +instance ADC.HasCodec Cloud where + codec = + _codec ADC. "Cloud Information" + where + _codec = + ADC.object "Cloud" $ + Cloud + <$> ADC.requiredField "name" "Cloud name." ADC..= _cloudName + <*> ADC.requiredField "id" "Host identifier." ADC..= _cloudHostId + <*> ADC.requiredField "hostType" "Host type." ADC..= _cloudHostType + <*> ADC.requiredField "hostRegion" "Host region." ADC..= _cloudHostRegion + <*> ADC.requiredField "hostAvailabilityZone" "Host availability zone." ADC..= _cloudHostAvailabilityZone + <*> ADC.requiredField "hostLocalHostname" "Local hostname of the host." ADC..= _cloudHostLocalHostname + <*> ADC.requiredField "hostLocalAddress" "Local address of the host." ADC..= _cloudHostLocalAddress + <*> ADC.requiredField "hostRemoteHostname" "Remote hostname of the host." ADC..= _cloudHostRemoteHostname + <*> ADC.requiredField "hostRemoteAddress" "Remote address of the host." ADC..= _cloudHostRemoteAddress + <*> ADC.requiredField "hostReservedAddress" "Reserved address of the host." ADC..= _cloudHostReservedAddress + + +-- * Hardware Information + + +-- | Data definition for host's rudimentary hardware information. +data Hardware = Hardware + { _hardwareCpuCount :: !Int + , _hardwareRamTotal :: !Scientific + , _hardwareDiskRoot :: !Scientific + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Hardware) + + +instance ADC.HasCodec Hardware where + codec = + _codec ADC. "Rudimentary Hardware Information" + where + _codec = + ADC.object "Hardware" $ + Hardware + <$> ADC.requiredField "cpuCount" "Number of (v)CPU cores." ADC..= _hardwareCpuCount + <*> ADC.requiredField "ramTotal" "Total RAM (in GB)." ADC..= _hardwareRamTotal + <*> ADC.requiredField "diskRoot" "Total disk space of root (`/`) filesystem (in GB)." ADC..= _hardwareDiskRoot + + +-- * Kernel Information + + +-- | Data definition for host's kernel information. +data Kernel = Kernel + { _kernelNode :: !T.Text + , _kernelName :: !T.Text + , _kernelRelease :: !T.Text + , _kernelVersion :: !T.Text + , _kernelMachine :: !T.Text + , _kernelOs :: !T.Text + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Kernel) + + +instance ADC.HasCodec Kernel where + codec = + _codec ADC. "Kernel Information" + where + _codec = + ADC.object "Kernel" $ + Kernel + <$> ADC.requiredField "node" "Name of the node kernel is running on (uname -n)." ADC..= _kernelNode + <*> ADC.requiredField "name" "Kernel name (uname -s)." ADC..= _kernelName + <*> ADC.requiredField "release" "Kernel release (uname -r)." ADC..= _kernelRelease + <*> ADC.requiredField "version" "Kernel version (uname -v)." ADC..= _kernelVersion + <*> ADC.requiredField "machine" "Architecture the kernel is running on (uname -m)." ADC..= _kernelMachine + <*> ADC.requiredField "os" "Operating system the kernel is driving (uname -o)." ADC..= _kernelOs + + +-- * Distribution Information + + +-- | Data definition for host's distribution information. +data Distribution = Distribution + { _distributionId :: !T.Text + , _distributionName :: !T.Text + , _distributionVersion :: !T.Text + , _distributionRelease :: !T.Text + , _distributionCodename :: !(Maybe T.Text) + , _distributionDescription :: !T.Text + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Distribution) + + +instance ADC.HasCodec Distribution where + codec = + _codec ADC. "Distribution Information" + where + _codec = + ADC.object "Distribution" $ + Distribution + <$> ADC.requiredField "id" "Distribution ID (cat /etc/os-release | grep 'ID=')." ADC..= _distributionId + <*> ADC.requiredField "name" "Distribution name (cat /etc/os-release | grep 'NAME='))." ADC..= _distributionName + <*> ADC.requiredField "version" "Distribution version (cat /etc/os-release | grep 'VERSION=')." ADC..= _distributionVersion + <*> ADC.requiredField "release" "Distribution release (cat /etc/os-release | grep 'VERSION_ID=')." ADC..= _distributionRelease + <*> ADC.requiredField "codename" "Distribution codename (cat /etc/os-release | grep 'VERSION_CODENAME=')." ADC..= _distributionCodename + <*> ADC.requiredField "Description" "Distribution description (cat /etc/os-release | grep 'PRETTY_NAME=')." ADC..= _distributionDescription + + +-- * Docker Container Information + + +-- | Data definition for Docker container information. +data DockerContainer = DockerContainer + { _dockerContainerId :: !T.Text + , _dockerContainerName :: !T.Text + , _dockerContainerImage :: !T.Text + , _dockerContainerCreated :: !Time.UTCTime + , _dockerContainerRunning :: !Bool + } + deriving (Eq, Generic, Show) + deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec DockerContainer) + + +instance ADC.HasCodec DockerContainer where + codec = + _codec ADC. "Docker Container Information" + where + _codec = + ADC.object "DockerContainer" $ + DockerContainer + <$> ADC.requiredField "id" "ID of the container.." ADC..= _dockerContainerId + <*> ADC.requiredField "name" "Name of the container." ADC..= _dockerContainerName + <*> ADC.requiredField "image" "Image the container is created from." ADC..= _dockerContainerImage + <*> ADC.requiredField "created" "Date/time when the container is created at." ADC..= _dockerContainerCreated + <*> ADC.requiredField "running" "Indicates if the container is running." ADC..= _dockerContainerRunning From 30abd0538999039cee96e4bd3b036b3a96755155 Mon Sep 17 00:00:00 2001 From: Vehbi Sinan Tunalioglu Date: Sat, 16 Mar 2024 17:42:58 +0800 Subject: [PATCH 2/5] chore: add SSH helper module --- .hlint.yaml | 1 + package.yaml | 4 ++ src/Zamazingo/Ssh.hs | 158 ++++++++++++++++++++++++++++++++++++++++++ src/Zamazingo/Text.hs | 10 +++ 4 files changed, 173 insertions(+) create mode 100644 src/Zamazingo/Ssh.hs diff --git a/.hlint.yaml b/.hlint.yaml index e11a1fb..955b4c3 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -31,6 +31,7 @@ - name: - DeriveGeneric - DerivingVia + - FlexibleContexts - OverloadedStrings ################ diff --git a/package.yaml b/package.yaml index cce59fa..27838e0 100644 --- a/package.yaml +++ b/package.yaml @@ -24,10 +24,14 @@ library: dependencies: - aeson - autodocodec + - bytestring + - mtl - optparse-applicative + - path - scientific - text - time + - typed-process executables: lhp: diff --git a/src/Zamazingo/Ssh.hs b/src/Zamazingo/Ssh.hs new file mode 100644 index 0000000..8a40d72 --- /dev/null +++ b/src/Zamazingo/Ssh.hs @@ -0,0 +1,158 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | This module provides definitions to work with remote hosts over +-- SSH. +module Zamazingo.Ssh where + +import Control.Monad (unless) +import Control.Monad.Except (MonadError (throwError)) +import Control.Monad.IO.Class (MonadIO) +import Data.Aeson ((.=)) +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy as BL +import qualified Data.Text as T +import qualified Path as P +import System.Exit (ExitCode (..)) +import qualified System.Process.Typed as TP +import qualified Zamazingo.Text as Z.Text + + +-- | Type definition for remote SSH host. +type Destination = T.Text + + +-- | Data definition for errors this module can throw. +data SshError + = SshErrorConnection Destination T.Text + | SshErrorCommandTimeout Destination [T.Text] + | SshErrorCommand Destination [T.Text] + | SshErrorFileRead Destination (P.Path P.Abs P.File) + | SshErrorMissingFile Destination (P.Path P.Abs P.File) + deriving (Eq, Show) + + +instance Aeson.ToJSON SshError where + toJSON (SshErrorConnection d err) = + Aeson.object [("type", "connection"), "destination" .= d, "error" .= err] + toJSON (SshErrorCommandTimeout d cmd) = + Aeson.object [("type", "command-timeout"), "destination" .= d, "command" .= cmd] + toJSON (SshErrorCommand d cmd) = + Aeson.object [("type", "command"), "destination" .= d, "command" .= cmd] + toJSON (SshErrorFileRead d p) = + Aeson.object [("type", "file-read"), "destination" .= d, "path" .= p] + toJSON (SshErrorMissingFile d p) = + Aeson.object [("type", "missing-file"), "destination" .= d, "path" .= p] + + +-- | Attempts to run a command on the remote and return its stdout. +runCommand + :: MonadIO m + => MonadError SshError m + => Destination + -> [T.Text] + -> m BL.ByteString +runCommand h cmd = do + (ec, out, _err) <- _runCommand h Nothing _sshConfig cmd + case ec of + ExitFailure _ -> throwError (SshErrorCommand h cmd) + ExitSuccess -> pure out + + +-- | Attempts to run a script on the remote and return its stdout. +runScript + :: MonadIO m + => MonadError SshError m + => Destination + -> BL.ByteString + -> [T.Text] + -> m BL.ByteString +runScript h scr cmd = do + (ec, out, _err) <- _runCommand h (Just scr) _sshConfig cmd + case ec of + ExitFailure _ -> throwError (SshErrorCommand h cmd) + ExitSuccess -> pure out + + +-- | Attempts to read the remote file and return its contents. +readFile + :: MonadIO m + => MonadError SshError m + => Destination + -> P.Path P.Abs P.File + -> m BL.ByteString +readFile h p = do + exists <- doesFileExist h p + unless exists (throwError (SshErrorMissingFile h p)) + (ec, out, _err) <- _runCommand h Nothing _sshConfig _cmd + case ec of + ExitFailure _ -> throwError (SshErrorFileRead h p) + ExitSuccess -> pure out + where + _cmd = ["cat", T.pack (P.toFilePath p)] + + +-- | Checks if the command exists on remote. +hasCommand + :: MonadIO m + => MonadError SshError m + => Destination + -> T.Text + -> m Bool +hasCommand h cmd = do + (ec, _out, _err) <- _runCommand h Nothing _sshConfig _cmd + case ec of + ExitFailure 1 -> pure False + ExitFailure _ -> throwError (SshErrorCommand h _cmd) + ExitSuccess -> pure True + where + _cmd = ["which", cmd] + + +-- | Checks if the file exists on remote. +doesFileExist + :: MonadIO m + => MonadError SshError m + => Destination + -> P.Path P.Abs P.File + -> m Bool +doesFileExist h p = do + (ec, _out, _err) <- _runCommand h Nothing _sshConfig _cmd + case ec of + ExitFailure 2 -> pure False + ExitFailure _ -> throwError (SshErrorCommand h _cmd) + ExitSuccess -> pure True + where + _cmd = ["[ -f \"" <> T.pack (P.toFilePath p) <> "\" ] && exit 0 || exit 2"] + + +-- | Attempts to run a command on the remote and return its exit code, +-- stdout and stderr. +-- +-- If @ssh@ fails (with exit code 255), throws 'SshErrorConnection' +-- error. +-- +-- If command is timed-out, throws 'SshErrorCommandTimeout' error. +_runCommand + :: MonadIO m + => MonadError SshError m + => Destination + -> Maybe BL.ByteString + -> [T.Text] + -> [T.Text] + -> m (ExitCode, BL.ByteString, BL.ByteString) +_runCommand h mi cfg cmd = do + let stdin = maybe TP.nullStream TP.byteStringInput mi + (ec, out, err) <- TP.readProcess (TP.setStdin stdin $ TP.proc "timeout" ("10" : "ssh" : _args)) + case ec of + ExitFailure 124 -> throwError (SshErrorCommandTimeout h cmd) + ExitFailure 255 -> throwError (SshErrorConnection h ("Connection failed: " <> Z.Text.unsafeTextFromBL err)) + ExitFailure _ -> pure (ec, out, err) + ExitSuccess -> pure (ec, out, err) + where + _args = fmap T.unpack (cfg <> [h] <> cmd) + + +-- | SSH config to pass on all our SSH connections. +_sshConfig :: [T.Text] +_sshConfig = ["-o", "ConnectTimeout=5"] diff --git a/src/Zamazingo/Text.hs b/src/Zamazingo/Text.hs index c097b7d..c39ffad 100644 --- a/src/Zamazingo/Text.hs +++ b/src/Zamazingo/Text.hs @@ -3,7 +3,10 @@ -- | This module provides auxiliary definitions for textual values. module Zamazingo.Text where +import qualified Data.ByteString.Lazy as BL import qualified Data.Text as T +import qualified Data.Text.Lazy as TL +import qualified Data.Text.Lazy.Encoding as TLE -- $setup @@ -38,3 +41,10 @@ sanitize = T.unwords . T.words nonempty :: T.Text -> Maybe T.Text nonempty "" = Nothing nonempty x = Just x + + +-- | Attempts to convert a given lazy bytestring to strict text. +-- +-- May throw encoding error, hence the @unsafe@. +unsafeTextFromBL :: BL.ByteString -> T.Text +unsafeTextFromBL = TL.toStrict . TLE.decodeUtf8 From b2ac2d531be1679f1df67208f709276d31a27afc Mon Sep 17 00:00:00 2001 From: Vehbi Sinan Tunalioglu Date: Sun, 17 Mar 2024 15:47:45 +0800 Subject: [PATCH 3/5] chore: add workhorse module to produce host reports --- .hlint.yaml | 1 + package.yaml | 2 + src/Lhp/Remote.hs | 311 +++++++++++++++++++++++++++++++ src/scripts/cloud.sh | 64 +++++++ src/scripts/docker-containers.sh | 47 +++++ src/scripts/info.sh | 68 +++++++ 6 files changed, 493 insertions(+) create mode 100644 src/Lhp/Remote.hs create mode 100644 src/scripts/cloud.sh create mode 100644 src/scripts/docker-containers.sh create mode 100644 src/scripts/info.sh diff --git a/.hlint.yaml b/.hlint.yaml index 955b4c3..f6f6f39 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -33,6 +33,7 @@ - DerivingVia - FlexibleContexts - OverloadedStrings + - TemplateHaskell ################ # CUSTOM RULES # diff --git a/package.yaml b/package.yaml index 27838e0..d21cefa 100644 --- a/package.yaml +++ b/package.yaml @@ -23,8 +23,10 @@ library: - -Wunused-packages dependencies: - aeson + - aeson-combinators - autodocodec - bytestring + - file-embed - mtl - optparse-applicative - path diff --git a/src/Lhp/Remote.hs b/src/Lhp/Remote.hs new file mode 100644 index 0000000..d3917bf --- /dev/null +++ b/src/Lhp/Remote.hs @@ -0,0 +1,311 @@ +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +-- | This module provides definitions to retrieve and parse remote +-- host information and produce host report. +module Lhp.Remote where + +import Control.Monad.Except (ExceptT, MonadError (..), runExceptT) +import Control.Monad.IO.Class (MonadIO) +import qualified Data.Aeson as Aeson +import qualified Data.Aeson.Combinators.Decode as ACD +import qualified Data.ByteString.Lazy as BL +import Data.FileEmbed (embedStringFile) +import qualified Data.List as List +import Data.Maybe (fromMaybe) +import qualified Data.Scientific as S +import qualified Data.Text as T +import qualified Lhp.Types as Types +import Text.Read (readEither) +import qualified Zamazingo.Ssh as Z.Ssh +import qualified Zamazingo.Text as Z.Text + + +-- * Report + + +-- | Attempts to retrieve remote host information and produce a host +-- report. +compileReport + :: MonadIO m + => MonadError LhpError m + => Z.Ssh.Destination + -> m Types.Report +compileReport h = do + kvs <- (++) <$> _fetchHostInfo h <*> _fetchHostCloudInfo h + Types.Report h + <$> _mkCloud h kvs + <*> _mkHardware h kvs + <*> _mkKernel h kvs + <*> _mkDistribution h kvs + <*> _fetchHostDockerContainers h + + +-- * Errors + + +-- | Data definition for error(s) which can be thrown while retrieving +-- remote host information and producing host report. +data LhpError + = LhpErrorSsh Z.Ssh.Destination Z.Ssh.SshError + | LhpErrorParse Z.Ssh.Destination T.Text + deriving (Eq, Show) + + +instance Aeson.ToJSON LhpError where + toJSON (LhpErrorSsh h err) = Aeson.object [("type", "ssh"), "host" Aeson..= h, "error" Aeson..= err] + toJSON (LhpErrorParse h err) = Aeson.object [("type", "parse"), "host" Aeson..= h, "error" Aeson..= err] + + +-- * Internal + + +-- | Attempts to retrieve remote host information and return it as a +-- list of key/value tuples. +_fetchHostInfo + :: MonadIO m + => MonadError LhpError m + => Z.Ssh.Destination + -> m [(T.Text, T.Text)] +_fetchHostInfo h = + parseKVs <$> _toSshError h (Z.Ssh.runScript h $(embedStringFile "src/scripts/info.sh") ["bash"]) + + +-- | Attempts to retrieve remote host cloud information and return it +-- as a list of key/value tuples. +_fetchHostCloudInfo + :: MonadIO m + => MonadError LhpError m + => Z.Ssh.Destination + -> m [(T.Text, T.Text)] +_fetchHostCloudInfo h = + parseKVs <$> _toSshError h (Z.Ssh.runScript h $(embedStringFile "src/scripts/cloud.sh") ["bash"]) + + +-- | Attempts to retrieve remote host docker containers information and return it. +-- +-- Returns 'Nothing' if remote host is not identified as a Docker +-- host, a list of rudimentary Docker container information otherwise. +_fetchHostDockerContainers + :: MonadIO m + => MonadError LhpError m + => Z.Ssh.Destination + -> m (Maybe [Types.DockerContainer]) +_fetchHostDockerContainers h = + (Just <$> (prog >>= _parseDockerContainers)) `catchError` const (pure Nothing) + where + prog = _toSshError h (Z.Ssh.runScript h $(embedStringFile "src/scripts/docker-containers.sh") ["bash"]) + _parseDockerContainers b = + case ACD.eitherDecode (ACD.list _jsonDecoderDockerContainer) b of + Left err -> throwError (LhpErrorParse h ("Error while parsing containers information: " <> T.pack err)) + Right sv -> pure sv + + +-- | Smart constructor for remote host cloud information. +_mkCloud + :: MonadError LhpError m + => Z.Ssh.Destination + -> [(T.Text, T.Text)] + -> m Types.Cloud +_mkCloud h kvs = + _toParseError h $ + Types.Cloud + <$> (fromMaybe "UNKNOWN" <$> _findParse pure "LHP_CLOUD_NAME" kvs) + <*> _findParse pure "LHP_CLOUD_ID" kvs + <*> _findParse pure "LHP_CLOUD_TYPE" kvs + <*> _findParse pure "LHP_CLOUD_REGION" kvs + <*> _findParse pure "LHP_CLOUD_AVAILABILITY_ZONE" kvs + <*> _findParse pure "LHP_CLOUD_LOCAL_HOSTNAME" kvs + <*> _findParse pure "LHP_CLOUD_LOCAL_ADDRESS" kvs + <*> _findParse pure "LHP_CLOUD_PUBLIC_HOSTNAME" kvs + <*> _findParse pure "LHP_CLOUD_PUBLIC_ADDRESS" kvs + <*> _findParse pure "LHP_CLOUD_RESERVED_ADDRESS" kvs + + +-- | Smart constructor for remote host rudimentary hardware +-- information. +_mkHardware + :: MonadError LhpError m + => Z.Ssh.Destination + -> [(T.Text, T.Text)] + -> m Types.Hardware +_mkHardware h kvs = + _toParseError h $ + Types.Hardware + <$> _getParse _parseRead "LHP_HW_CPU" kvs + <*> _getParse (fmap (_roundS 2 . _toGB) . _parseRead) "LHP_HW_RAM" kvs + <*> _getParse (fmap (_roundS 2 . _toGB) . _parseRead) "LHP_HW_DISK" kvs + + +-- | Smart constructor for remote host kernel information. +_mkKernel + :: MonadError LhpError m + => Z.Ssh.Destination + -> [(T.Text, T.Text)] + -> m Types.Kernel +_mkKernel h kvs = + _toParseError h $ + Types.Kernel + <$> _getParse pure "LHP_KERNEL_NAME" kvs + <*> _getParse pure "LHP_KERNEL_NODE" kvs + <*> _getParse pure "LHP_KERNEL_RELEASE" kvs + <*> _getParse pure "LHP_KERNEL_VERSION" kvs + <*> _getParse pure "LHP_KERNEL_MACHINE" kvs + <*> _getParse pure "LHP_KERNEL_OS" kvs + + +-- | Smart constructor for remote host distribution information. +_mkDistribution + :: MonadError LhpError m + => Z.Ssh.Destination + -> [(T.Text, T.Text)] + -> m Types.Distribution +_mkDistribution h kvs = + _toParseError h $ + Types.Distribution + <$> _getParse pure "LHP_DISTRO_ID" kvs + <*> _getParse pure "LHP_DISTRO_NAME" kvs + <*> _getParse pure "LHP_DISTRO_VERSION" kvs + <*> _getParse pure "LHP_DISTRO_VERSION_ID" kvs + <*> _findParse pure "LHP_DISTRO_VERSION_CODENAME" kvs + <*> _getParse pure "LHP_DISTRO_PRETTY_NAME" kvs + + +-- | Attempts to parse a list of key/value tuples formatted in the +-- @key=value@ format. +parseKVs :: BL.ByteString -> [(T.Text, T.Text)] +parseKVs = + fmap _parseKV . T.lines . Z.Text.unsafeTextFromBL + + +-- | Attempts to parse and return key/value tuple formatted in the +-- key=value format. +-- +-- Value can be unqouted, single-quoted or double-quoted. +_parseKV :: T.Text -> (T.Text, T.Text) +_parseKV = + fmap (_parseValue . T.drop 1) . T.break (== '=') + + +-- | Parses a quoted or unquoted value. +-- +-- >>> :set -XOverloadedStrings +-- >>> _parseValue "" +-- "" +-- >>> _parseValue "''" +-- "" +-- >>> _parseValue "\"\"" +-- "" +-- >>> _parseValue "a" +-- "a" +-- >>> _parseValue "'a'" +-- "a" +-- >>> _parseValue "\"a\"" +-- "a" +_parseValue :: T.Text -> T.Text +_parseValue v = + case T.uncons v of -- TODO: Check closing quotation mark, too. + Nothing -> v + Just ('\'', _) -> T.replace "\\'" "'" . T.dropEnd 1 . T.drop 1 $ v + Just ('\"', _) -> T.replace "\\\"" "\"" . T.dropEnd 1 . T.drop 1 $ v + _ -> v + + +-- | 'ACD.Decoder' for 'Types.DockerContainertainer'. +-- +-- This is used to parse `docker inspect` result collection elements. +_jsonDecoderDockerContainer :: ACD.Decoder Types.DockerContainer +_jsonDecoderDockerContainer = + Types.DockerContainer + <$> ACD.key "Id" ACD.text + <*> (T.dropWhile (== '/') <$> ACD.key "Name" ACD.text) + <*> ACD.at ["Config", "Image"] ACD.text + <*> ACD.key "Created" ACD.utcTime + <*> ((==) True <$> ACD.at ["State", "Running"] ACD.bool) + + +-- | Attempts to find a given key and returns its value parsed with +-- the given parser within a given list of key/value pairs. +_findParse + :: MonadError T.Text m + => (T.Text -> m a) + -> T.Text + -> [(T.Text, T.Text)] + -> m (Maybe a) +_findParse vp k kvs = + case snd <$> List.find ((==) k . fst) kvs of + Nothing -> pure Nothing + Just sv -> Just <$> vp sv + + +-- | Attempts to find a given key and returns its value parsed with +-- the given parser within a given list of key/value pairs. +-- +-- Similar to '_findParse' but throws error if key is not found. +_getParse + :: MonadError T.Text m + => (T.Text -> m a) + -> T.Text + -> [(T.Text, T.Text)] + -> m a +_getParse vp k kvs = + _findParse vp k kvs >>= maybe err pure + where + err = throwError ("Variable " <> k <> " is not found.") + + +-- | Rounds the given number to given number of decimal points. +-- +-- >>> _roundS 2 2.105 +-- 2.1 +-- >>> _roundS 2 2.115 +-- 2.12 +_roundS :: Int -> S.Scientific -> S.Scientific +_roundS n = + read . S.formatScientific S.Generic (Just n) + + +-- | Converts the value to GBs. +_toGB :: S.Scientific -> S.Scientific +_toGB s = + s / (1024 * 1024) + + +-- | (Borrowed from mtl v2.3.1) +_modifyError :: MonadError e' m => (e -> e') -> ExceptT e m a -> m a +_modifyError f m = + runExceptT m >>= either (throwError . f) pure + + +-- | 'readEither' that works with 'T.Text' in a @'MonadError' +-- 'T.Text'@ context +_parseRead + :: MonadError T.Text m + => Read a + => T.Text + -> m a +_parseRead = + either (throwError . T.pack) pure . readEither . T.unpack + + +-- | Lifts @'ExceptT' 'T.Text'@ to @'MonadError' 'LhpError'@ with +-- parse error. +_toParseError + :: MonadError LhpError m + => Z.Ssh.Destination + -> ExceptT T.Text m a + -> m a +_toParseError h = + _modifyError (LhpErrorParse h) + + +-- | Lifts @'ExceptT' 'Z.Ssh.SshError'@ to @'MonadError' 'LhpError'@ +-- with SSH error. +_toSshError + :: MonadError LhpError m + => Z.Ssh.Destination + -> ExceptT Z.Ssh.SshError m a + -> m a +_toSshError h = + _modifyError (LhpErrorSsh h) diff --git a/src/scripts/cloud.sh b/src/scripts/cloud.sh new file mode 100644 index 0000000..888f7c0 --- /dev/null +++ b/src/scripts/cloud.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env sh + +################### +# SHELL BEHAVIOUR # +################### + +# Stop on errors: +set -e + +############### +# DEFINITIONS # +############### + +# Checks if a given command exist on the host (is on $PATH), and exits +# this script with exit code 80 if not. +_check_command() { + if ! which "${1}" >/dev/null; then + echo >&2 "Command not found: ${1}" + exit 80 + fi +} + +# Prints a key/value pair in SHELL variable format. Value is printed +# within double-quotes, and double-quotes in the variable are escaped. +_print_var() { + printf '%s="%s"\n' "${1}" "$(echo "${2}" | sed 's/"/\\"/g')" +} + +########## +# CHECKS # +########## + +_check_command curl + +############# +# PROCEDURE # +############# + +if curl --silent --fail --max-time 3 http://169.254.169.254/latest/meta-data/ami-id >/dev/null; then + _print_var "LHP_CLOUD_NAME" "AWS" + _print_var "LHP_CLOUD_ID" "$(curl -s http://169.254.169.254/latest/meta-data/instance-id)" + _print_var "LHP_CLOUD_TYPE" "$(curl -s http://169.254.169.254/latest/meta-data/instance-type)" + _print_var "LHP_CLOUD_REGION" "$(curl -s http://169.254.169.254/latest/meta-data/placement/region)" + _print_var "LHP_CLOUD_AVAILABILITY_ZONE" "$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone)" + _print_var "LHP_CLOUD_LOCAL_HOSTNAME" "$(curl -s http://169.254.169.254/latest/meta-data/local-hostname)" + _print_var "LHP_CLOUD_LOCAL_ADDRESS" "$(curl -s http://169.254.169.254/latest/meta-data/local-ipv4)" + _print_var "LHP_CLOUD_PUBLIC_HOSTNAME" "$(curl -s http://169.254.169.254/latest/meta-data/public-hostname)" + _print_var "LHP_CLOUD_PUBLIC_ADDRESS" "$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)" +elif curl --silent --fail --max-time 3 http://169.254.169.254/metadata/v1/ >/dev/null; then + _print_var "LHP_CLOUD_NAME" "DO" + _print_var "LHP_CLOUD_ID" "$(curl -s http://169.254.169.254/metadata/v1/id)" + _print_var "LHP_CLOUD_REGION" "$(curl -s http://169.254.169.254/metadata/v1/region)" + _print_var "LHP_CLOUD_LOCAL_ADDRESS" "$(curl -s http://169.254.169.254/metadata/v1/interfaces/private/0/ipv4/address)" + _print_var "LHP_CLOUD_PUBLIC_ADDRESS" "$(curl -s http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address)" + _print_var "LHP_CLOUD_RESERVED_ADDRESS" "$(curl -s http://169.254.169.254/metadata/v1/reserved_ip/ipv4/ip_address)" +elif curl --silent --fail --max-time 3 http://169.254.169.254/latest/meta-data/network-config >/dev/null; then + _print_var "LHP_CLOUD_NAME" "HETZNER" + _print_var "LHP_CLOUD_ID" "$(curl -s http://169.254.169.254/latest/meta-data/instance-id)" + _print_var "LHP_CLOUD_REGION" "$(curl -s http://169.254.169.254/latest/meta-data/region)" + _print_var "LHP_CLOUD_AVAILABILITY_ZONE" "$(curl -s http://169.254.169.254/latest/meta-data/availability-zone)" + _print_var "LHP_CLOUD_PUBLIC_ADDRESS" "$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)" +else + _print_var "LHP_CLOUD_NAME" "UNKNOWN" +fi diff --git a/src/scripts/docker-containers.sh b/src/scripts/docker-containers.sh new file mode 100644 index 0000000..11e787b --- /dev/null +++ b/src/scripts/docker-containers.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env sh + +################### +# SHELL BEHAVIOUR # +################### + +# Stop on errors: +set -e + +############### +# DEFINITIONS # +############### + +# Checks if a given command exist on the host (is on $PATH), and exits +# this script with exit code 80 if not. +_check_command() { + if ! which "${1}" >/dev/null; then + echo >&2 "Command not found: ${1}" + exit 80 + fi +} + +# Prints the name of the given command if it is on the PATH, returns +# with 1 otherwise. +_which_command() { + which "${1}" >/dev/null 2>&1 && echo "${1}" || return 1 +} + +# Returns the Docker command: +_which_docker() { + _which_command "docker" || _which_command "podman" || { + echo >&2 "No docker/podman command found." + exit 80 + } +} + +########## +# CHECKS # +########## + +_docker="$(_which_docker)" + +############# +# PROCEDURE # +############# + +"${_docker}" ps --all --quiet | xargs "${_docker}" inspect diff --git a/src/scripts/info.sh b/src/scripts/info.sh new file mode 100644 index 0000000..d6933f0 --- /dev/null +++ b/src/scripts/info.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env sh + +################### +# SHELL BEHAVIOUR # +################### + +# Stop on errors: +set -e + +############### +# DEFINITIONS # +############### + +# Checks if a given command exist on the host (is on $PATH), and exits +# this script with exit code 80 if not. +_check_command() { + if ! which "${1}" >/dev/null; then + echo >&2 "Command not found: ${1}" + exit 80 + fi +} + +# Checks if a given file exist on the host, and exits +# this script with exit code 81 if not. +_check_file() { + if [ ! -f "${1}" ]; then + echo >&2 "File not found: ${1}" + exit 81 + fi +} + +# Prints a key/value pair in SHELL variable format. Value is printed +# within double-quotes, and double-quotes in the variable are escaped. +_print_var() { + printf '%s="%s"\n' "${1}" "$(echo "${2}" | sed 's/"/\\"/g')" +} + +# Prints the name of the given command if it is on the PATH, returns +# with 1 otherwise. +_which_command() { + which "${1}" >/dev/null 2>&1 && echo "${1}" || return 1 +} + +########## +# CHECKS # +########## + +_check_command uname +_check_command nproc +_check_file /etc/os-release + +############# +# PROCEDURE # +############# + +_print_var "LHP_HW_CPU" "$(nproc)" +_print_var "LHP_HW_RAM" "$(grep -oP 'MemTotal:\s+\K\d+' /proc/meminfo)" +_print_var "LHP_HW_DISK" "$(df -k --output=size / | tail -n +2 | grep -o '[[:digit:]]*')" +_print_var "LHP_KERNEL_NAME" "$(uname -s)" +_print_var "LHP_KERNEL_NODE" "$(uname -n)" +_print_var "LHP_KERNEL_RELEASE" "$(uname -r)" +_print_var "LHP_KERNEL_VERSION" "$(uname -v)" +_print_var "LHP_KERNEL_MACHINE" "$(uname -m)" +_print_var "LHP_KERNEL_OS" "$(uname -o)" +_print_var "LHP_APP_DOCKER" "$(_which_command "docker" || _which_command "podman" || echo "")" +_print_var "LHP_APP_SCREEN" "$(_which_command "screen" || echo "")" +_print_var "LHP_APP_TMUX" "$(_which_command "tmux" || echo "")" +sed "s/^/LHP_DISTRO_/g" /etc/os-release From 888f2b11d9d3f686cfad5e6d69b71e02e22f8737 Mon Sep 17 00:00:00 2001 From: Vehbi Sinan Tunalioglu Date: Sun, 17 Mar 2024 16:03:08 +0800 Subject: [PATCH 4/5] feat(cli): add report and schema CLI subcommands --- .hlint.yaml | 1 + package.yaml | 2 ++ src/Lhp/Cli.hs | 77 +++++++++++++++++++++++++++++--------------------- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/.hlint.yaml b/.hlint.yaml index f6f6f39..f8b4ad1 100644 --- a/.hlint.yaml +++ b/.hlint.yaml @@ -34,6 +34,7 @@ - FlexibleContexts - OverloadedStrings - TemplateHaskell + - TypeApplications ################ # CUSTOM RULES # diff --git a/package.yaml b/package.yaml index d21cefa..4bb1051 100644 --- a/package.yaml +++ b/package.yaml @@ -25,8 +25,10 @@ library: - aeson - aeson-combinators - autodocodec + - autodocodec-schema - bytestring - file-embed + - monad-parallel - mtl - optparse-applicative - path diff --git a/src/Lhp/Cli.hs b/src/Lhp/Cli.hs index 7a4c5cb..42bfdb0 100644 --- a/src/Lhp/Cli.hs +++ b/src/Lhp/Cli.hs @@ -1,15 +1,24 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TypeApplications #-} -- | This module provides top-level definitions for the CLI program. module Lhp.Cli where -import Control.Applicative ((<**>), (<|>)) +import qualified Autodocodec.Schema as ADC.Schema +import Control.Applicative ((<**>)) import Control.Monad (join) +import Control.Monad.Except (runExceptT) +import qualified Control.Monad.Parallel as MP +import qualified Data.Aeson as Aeson +import qualified Data.ByteString.Lazy.Char8 as BLC import qualified Data.Text as T -import qualified Data.Text.IO as TIO import qualified Lhp.Meta as Meta +import Lhp.Remote (compileReport) +import Lhp.Types (Report) +import Options.Applicative ((<|>)) import qualified Options.Applicative as OA import System.Exit (ExitCode (..)) +import System.IO (hPutStrLn, stderr) -- * Entrypoint @@ -34,51 +43,55 @@ cli = -- | Option parser for top-level commands. optProgram :: OA.Parser (IO ExitCode) optProgram = - commandGreet - <|> commandFarewell + commandCompile + <|> commandSchema -- * Commands --- ** greet +-- ** compile --- | Definition for @greet@ CLI command. -commandGreet :: OA.Parser (IO ExitCode) -commandGreet = OA.hsubparser (OA.command "greet" (OA.info parser infomod) <> OA.metavar "greet") +-- | Definition for @compile@ CLI command. +commandCompile :: OA.Parser (IO ExitCode) +commandCompile = OA.hsubparser (OA.command "compile" (OA.info parser infomod) <> OA.metavar "compile") where - infomod = OA.fullDesc <> infoModHeader <> OA.progDesc "Greet user." <> OA.footer "This command prints a greeting message to the console." + infomod = OA.fullDesc <> infoModHeader <> OA.progDesc "Compile remote host information." <> OA.footer "This command fetches and compiles remote host information." parser = - doGreet - <$> OA.strOption (OA.short 'n' <> OA.long "name" <> OA.value "World" <> OA.showDefault <> OA.help "Whom to greet.") - - --- | @greet@ CLI command program. -doGreet :: T.Text -> IO ExitCode -doGreet n = do - TIO.putStrLn ("Hello " <> n <> "!") + doCompile + <$> OA.many (OA.strOption (OA.short 'h' <> OA.long "host" <> OA.help "Remote host (in SSH destination format).")) + <*> OA.switch (OA.short 's' <> OA.long "stream" <> OA.help "Streaming results.") + + +-- | @compile@ CLI command program. +doCompile :: [T.Text] -> Bool -> IO ExitCode +doCompile hosts False = do + res <- runExceptT (MP.mapM compileReport hosts) + case res of + Left err -> BLC.hPutStrLn stderr (Aeson.encode err) >> pure (ExitFailure 1) + Right sr -> BLC.putStrLn (Aeson.encode sr) >> pure ExitSuccess +doCompile hosts True = do + mapM_ go hosts pure ExitSuccess + where + go h = do + hPutStrLn stderr ("Patrolling " <> T.unpack h) + res <- runExceptT (compileReport h) + case res of + Left err -> BLC.hPutStrLn stderr (Aeson.encode err) + Right sr -> BLC.putStrLn (Aeson.encode sr) --- ** farewell +-- ** schema --- | Definition for @farewell@ CLI command. -commandFarewell :: OA.Parser (IO ExitCode) -commandFarewell = OA.hsubparser (OA.command "farewell" (OA.info parser infomod) <> OA.metavar "farewell") +-- | Definition for @schema@ CLI command. +commandSchema :: OA.Parser (IO ExitCode) +commandSchema = OA.hsubparser (OA.command "schema" (OA.info parser infomod) <> OA.metavar "schema") where - infomod = OA.fullDesc <> infoModHeader <> OA.progDesc "Say farewell to user." <> OA.footer "This command prints a farewell message to the console." - parser = - doFarewell - <$> OA.strOption (OA.short 'n' <> OA.long "name" <> OA.value "World" <> OA.showDefault <> OA.help "Whom to say farewell to.") - - --- | @farewell@ CLI command program. -doFarewell :: T.Text -> IO ExitCode -doFarewell n = do - TIO.putStrLn ("Thanks for all the fish, " <> n <> "!") - pure ExitSuccess + infomod = OA.fullDesc <> infoModHeader <> OA.progDesc "Produce JSON schema for report." <> OA.footer "This command produces JSON schema for report data type." + parser = pure (BLC.putStrLn (Aeson.encode (ADC.Schema.jsonSchemaViaCodec @Report)) >> pure ExitSuccess) -- * Helpers From ca8e2bdd0a7551c579a2c03f578cd4bbfdcaab5f Mon Sep 17 00:00:00 2001 From: Vehbi Sinan Tunalioglu Date: Sun, 17 Mar 2024 16:32:26 +0800 Subject: [PATCH 5/5] chore: integrate Release Please and its GitHub Action workflow --- .github/workflows/release.yml | 18 ++++++++++++++++++ .release-please-manifest.json | 3 +++ release-please-config.json | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..07770e8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,18 @@ +name: "Release" + +on: + push: + branches: + - "main" + +permissions: + contents: "write" + pull-requests: "write" + +jobs: + build: + runs-on: "ubuntu-22.04" + + steps: + - name: "Release" + uses: "google-github-actions/release-please-action@v4" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..ab63ce0 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "draft": false, + "prerelease": false, + "initial-version": "0.0.1", + "extra-files": [ + "package.yaml" + ] + } + } +}