Skip to content

Commit

Permalink
Merge pull request #79 from vst/70-revisit-parallel-runs
Browse files Browse the repository at this point in the history
Revisit Parallel Runs, Report Errors, Improve Overview Report Page
  • Loading branch information
vst authored Apr 16, 2024
2 parents a822dfa + 2de9b13 commit 33f71c3
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 58 deletions.
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,16 @@ You can pass hosts via CLI arguments:
hostpatrol compile --host my-host-1 --host my-host-2 > /tmp/hostpatrol-report.json
```

This command connects to hosts sequentially and ignores problematic
hosts in the output.
This command connects to hosts in parallel and ignores all failed
hosts by reporting errors in the output.

To use parallel mode, use `--parallel` flag. In this case, if any of
the hosts cause an error, entire operation will fail:

```sh
hostpatrol compile --parallel --host my-host-1 --host my-host-2 > /tmp/hostpatrol-report.json
```
> If you want to change the number of concurrent tasks, do so with
> `--parallel` option:
>
>
> ```sh
> hostpatrol compile --parallel 10 --host my-host-1 --host my-host-2 ... > /tmp/hostpatrol-report.json
> ```
Alternatively, you can use a configuration file which has additional
benefit of attaching static information to your hosts such as external
Expand Down
2 changes: 1 addition & 1 deletion package.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ library:
dependencies:
- aeson
- aeson-combinators
- async-pool
- autodocodec
- autodocodec-schema
- bytestring
- file-embed
- githash
- monad-parallel
- mtl
- optparse-applicative
- path
Expand Down
4 changes: 2 additions & 2 deletions src/HostPatrol/Cli.hs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ commandCompile = OA.hsubparser (OA.command "compile" (OA.info parser infomod) <>
doCompile
<$> OA.optional (OA.strOption (OA.short 'c' <> OA.long "config" <> OA.action "file" <> OA.help "Path to the configuration file."))
<*> OA.many (OA.strOption (OA.short 'h' <> OA.long "host" <> OA.help "Remote host (in SSH destination format)."))
<*> OA.switch (OA.short 'p' <> OA.long "parallel" <> OA.help "Hit remote hosts in parallel.")
<*> OA.option OA.auto (OA.short 'p' <> OA.long "parallel" <> OA.value 4 <> OA.showDefault <> OA.help "Number of hosts to hit concurrently.")


-- | @compile@ CLI command program.
doCompile :: Maybe FilePath -> [T.Text] -> Bool -> IO ExitCode
doCompile :: Maybe FilePath -> [T.Text] -> Int -> IO ExitCode
doCompile cpath dests par = do
baseConfig <- maybe (pure (Config.Config [] [])) Config.readConfigFile cpath
let config =
Expand Down
59 changes: 43 additions & 16 deletions src/HostPatrol/Remote.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,24 @@
-- host information and produce host report.
module HostPatrol.Remote where

import qualified Control.Concurrent.Async.Pool as Async.Pool
import Control.Monad.Except (ExceptT, MonadError (..), runExceptT)
import Control.Monad.IO.Class (MonadIO (liftIO))
import qualified Control.Monad.Parallel as MP
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Combinators.Decode as ACD
import Data.Bool (bool)
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import Data.Either (partitionEithers)
import Data.FileEmbed (embedStringFile)
import qualified Data.List as List
import Data.Maybe (catMaybes, fromMaybe)
import Data.Maybe (fromMaybe)
import qualified Data.Scientific as S
import qualified Data.Text as T
import qualified Data.Time as Time
import qualified HostPatrol.Config as Config
import qualified HostPatrol.Meta as Meta
import qualified HostPatrol.Types as Types
import qualified Path as P
import System.Exit (ExitCode (..))
import System.IO (hPutStrLn, stderr)
import qualified System.Process.Typed as TP
import Text.Read (readEither)
import qualified Zamazingo.Ssh as Z.Ssh
Expand All @@ -36,23 +37,37 @@ import qualified Zamazingo.Text as Z.Text
-- | Attempts to compile host patrol report for a given configuration.
compileReport
:: MonadError HostPatrolError m
=> MP.MonadParallel m
=> MonadIO m
=> Bool
=> Int
-> Config.Config
-> m Types.Report
compileReport par Config.Config {..} = do
_reportHosts <- reporter _configHosts
now <- liftIO Time.getCurrentTime
(errs, _reportHosts) <- liftIO (compileHostReportsIO par _configHosts)
_reportKnownSshKeys <- concat <$> mapM parseSshPublicKeys _configKnownSshKeys
let _reportMeta =
Types.ReportMeta
{ _reportMetaVersion = Meta._buildInfoVersion Meta.buildInfo
, _reportMetaBuildTag = Meta._buildInfoGitTag Meta.buildInfo
, _reportMetaBuildHash = Meta._buildInfoGitHash Meta.buildInfo
, _reportMetaTimestamp = now
}
_reportErrors = fmap toReportError errs
pure Types.Report {..}
where
reporter = bool (fmap catMaybes . mapM go) (MP.mapM compileHostReport) par
go h@Config.HostSpec {..} = do
liftIO (hPutStrLn stderr ("Patrolling " <> T.unpack _hostSpecName))
res <- runExceptT (compileHostReport h)
case res of
Left err -> liftIO (BLC.hPutStrLn stderr (Aeson.encode err) >> pure Nothing)
Right sr -> pure (Just sr)


-- | Attempts to compile host reports for a given list of host
-- specifications and parallelism.
--
-- If any host report fails to compile, it will be returned in the
-- error list, otherwise in the host report list.
compileHostReportsIO
:: Int
-> [Config.HostSpec]
-> IO ([HostPatrolError], [Types.HostReport])
compileHostReportsIO par hs =
Async.Pool.withTaskGroup par $
\tg -> partitionEithers <$> Async.Pool.mapConcurrently tg (runExceptT . compileHostReport) hs


-- | Attempts to retrieve remote host information and produce a host
Expand Down Expand Up @@ -116,6 +131,18 @@ instance Aeson.ToJSON HostPatrolError where
toJSON (HostPatrolErrorUnknown err) = Aeson.object [("type", "unknown"), "error" Aeson..= err]


-- | Converts a 'HostPatrolError' to 'Types.ReportError'.
toReportError :: HostPatrolError -> Types.ReportError
toReportError (HostPatrolErrorSsh h ssherror) = Types.ReportError (Just h) $ case ssherror of
Z.Ssh.SshErrorConnection _ err -> "SSH connection error: " <> err
Z.Ssh.SshErrorCommandTimeout _ cmd -> "SSH command timeout: " <> T.unwords cmd
Z.Ssh.SshErrorCommand _ cmd -> "SSH command error: " <> T.unwords cmd
Z.Ssh.SshErrorFileRead _ p -> "SSH file read error: " <> T.pack (P.toFilePath p)
Z.Ssh.SshErrorMissingFile _ p -> "SSH missing file error: " <> T.pack (P.toFilePath p)
toReportError (HostPatrolErrorParse h err) = Types.ReportError (Just h) err
toReportError (HostPatrolErrorUnknown err) = Types.ReportError Nothing err


-- * Internal


Expand Down
54 changes: 54 additions & 0 deletions src/HostPatrol/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import Zamazingo.Ssh (SshConfig)
data Report = Report
{ _reportHosts :: ![HostReport]
, _reportKnownSshKeys :: ![SshPublicKey]
, _reportMeta :: !ReportMeta
, _reportErrors :: ![ReportError]
}
deriving (Eq, Generic, Show)
deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec Report)
Expand All @@ -37,6 +39,58 @@ instance ADC.HasCodec Report where
Report
<$> ADC.requiredField "hosts" "List of host reports." ADC..= _reportHosts
<*> ADC.requiredField "knownSshKeys" "List of known SSH public keys." ADC..= _reportKnownSshKeys
<*> ADC.requiredField "meta" "Meta information of the report." ADC..= _reportMeta
<*> ADC.requiredField "errors" "List of errors encountered during the report generation." ADC..= _reportErrors


-- * Meta Information


-- | Data definition for the meta-information of the report.
data ReportMeta = ReportMeta
{ _reportMetaVersion :: !T.Text
, _reportMetaBuildTag :: !(Maybe T.Text)
, _reportMetaBuildHash :: !(Maybe T.Text)
, _reportMetaTimestamp :: !Time.UTCTime
}
deriving (Eq, Generic, Show)
deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec ReportMeta)


instance ADC.HasCodec ReportMeta where
codec =
_codec ADC.<?> "Report Meta Information"
where
_codec =
ADC.object "ReportMeta" $
ReportMeta
<$> ADC.requiredField "version" "Version of the application." ADC..= _reportMetaVersion
<*> ADC.optionalField "buildTag" "Build tag of the application." ADC..= _reportMetaBuildTag
<*> ADC.optionalField "buildHash" "Build hash of the application." ADC..= _reportMetaBuildHash
<*> ADC.requiredField "timestamp" "Timestamp of the report." ADC..= _reportMetaTimestamp


-- * Meta Information


-- | Data definition for errors.
data ReportError = ReportError
{ _reportErrorHost :: !(Maybe T.Text)
, _reportErrorMessage :: !T.Text
}
deriving (Eq, Generic, Show)
deriving (Aeson.FromJSON, Aeson.ToJSON) via (ADC.Autodocodec ReportError)


instance ADC.HasCodec ReportError where
codec =
_codec ADC.<?> "Report Error"
where
_codec =
ADC.object "ReportError" $
ReportError
<$> ADC.optionalField "host" "Host of the error if applicable." ADC..= _reportErrorHost
<*> ADC.requiredField "message" "Error message." ADC..= _reportErrorMessage


-- * Host
Expand Down
15 changes: 15 additions & 0 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
"dependencies": {
"@nextui-org/react": "^2.2.10",
"ajv": "^8.12.0",
"dayjs": "^1.11.10",
"framer-motion": "^11.0.22",
"json-schema-to-ts": "^3.0.1",
"next": "14.1.4",
"purify-ts": "^2.0.3",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.1.0",
"react-toastify": "^10.0.5",
"recharts": "^2.12.3"
},
Expand Down
9 changes: 4 additions & 5 deletions website/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,14 @@ hosts:
<pre>{`hostpatrol compile --host my-host-1 --host my-host-2 > /tmp/hostpatrol-report.json`}</pre>
</Code>

<p>This command connects to hosts sequentially and ignores problematic hosts in the output.</p>

<p>
To use parallel mode, use `--parallel` flag. In this case, if any of the hosts cause an error, entire
operation will fail:
This command connects to hosts in parallel and ignores all failed hosts by reporting errors in the output.
</p>

<p>If you want to change the number of concurrent tasks, do so with `--parallel` option:</p>

<Code>
<pre>{`hostpatrol compile --parallel --host my-host-1 --host my-host-2 > /tmp/hostpatrol-report.json`}</pre>
<pre>{`hostpatrol compile --parallel 10 --host my-host-1 --host my-host-2 ... > /tmp/hostpatrol-report.json`}</pre>
</Code>

<p>
Expand Down
88 changes: 63 additions & 25 deletions website/src/components/report/TabOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,78 @@
import { HostPatrolReport, buildSshKeysTable } from '@/lib/data';
import dayjs from 'dayjs';
import LocalizedFormat from 'dayjs/plugin/localizedFormat';
import { FaCircleInfo } from 'react-icons/fa6';
import { SimpleBarChart, histogram } from '../helpers';
dayjs.extend(LocalizedFormat);

export function TabOverview({ data }: { data: HostPatrolReport }) {
const sshkeys = Object.values(buildSshKeysTable(data));

return (
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="col-span-4 text-center font-bold text-indigo-500">
Showing summary of {data.hosts.length} host{data.hosts.length === 1 ? '' : 's'}, {data.knownSshKeys.length} SSH
public key{sshkeys.length === 1 ? '' : 's'} known, and total of {sshkeys.length} SSH public key
{sshkeys.length === 1 ? '' : 's'} seen.
</div>
<div className="flex flex-col space-y-12">
<div className="mt-4 grid grid-cols-4 gap-4">
<div>
<SimpleBarChart
data={histogram((x) => (x.isKnown ? 'Known SSH Keys' : 'Unknown SSH Keys'), sshkeys, {
'Known SSH Keys': 0,
'Unknown SSH Keys': 0,
})}
size={[480, 320]}
/>
</div>

<div>
<SimpleBarChart
data={histogram((x) => (x.isKnown ? 'Known SSH Keys' : 'Unknown SSH Keys'), sshkeys, {
'Known SSH Keys': 0,
'Unknown SSH Keys': 0,
})}
size={[480, 320]}
/>
</div>
<div>
<SimpleBarChart
data={histogram((x) => x.timezone.split(' ', 1)[0] || 'UNKNOWN', data.hosts)}
size={[480, 320]}
/>
</div>

<div>
<SimpleBarChart
data={histogram((x) => x.timezone.split(' ', 1)[0] || 'UNKNOWN', data.hosts)}
size={[480, 320]}
/>
</div>
<div>
<SimpleBarChart data={histogram((x) => x.cloud.name, data.hosts)} size={[480, 320]} />
</div>

<div>
<SimpleBarChart data={histogram((x) => x.cloud.name, data.hosts)} size={[480, 320]} />
<div>
<SimpleBarChart data={histogram((x) => x.distribution.name, data.hosts)} size={[480, 320]} />
</div>
</div>

<div>
<SimpleBarChart data={histogram((x) => x.distribution.name, data.hosts)} size={[480, 320]} />
<div className="flex flex-col items-center">
<div className="w-full max-w-4xl space-y-8 rounded border border-indigo-200 bg-indigo-100 p-6 shadow">
<p className="text-center text-lg">
{data.errors.length > 0 ? (
<span className="text-red-600">
<span className="font-bold">
{data.errors.length} error{data.errors.length === 1 ? '' : 's'}
</span>{' '}
were encountered during patrol.
</span>
) : (
<span className="text-green-600">No errors were encountered during the patrol.</span>
)}
</p>

{data.errors.map((error, i) => (
<div key={i} className="flex space-x-4 rounded border border-red-300 bg-red-200 p-4 text-red-600 shadow">
<span className="font-bold ">{error.host}</span> <span className="font-light">{error.message}</span>
</div>
))}

<div className="text-center text-indigo-600">
<FaCircleInfo className="inline" /> This report is generated by{' '}
<abbr
title={`${data.meta.buildTag} - ${data.meta.buildHash}`}
className="cursor-help font-bold underline decoration-dashed"
>
hostpatrol v{data.meta.version}
</abbr>{' '}
on{' '}
<abbr title={data.meta.timestamp} className="cursor-help font-bold underline decoration-dashed">
{dayjs(data.meta.timestamp).format('LLLL')}
</abbr>
.
</div>
</div>
</div>
</div>
);
Expand Down
Loading

0 comments on commit 33f71c3

Please sign in to comment.