diff --git a/default.nix b/default.nix index 5b94275..e27d26d 100644 --- a/default.nix +++ b/default.nix @@ -35,7 +35,7 @@ let } ]; - index-state = "2021-07-02T00:00:00Z"; + index-state = "2021-08-04T00:00:00Z"; compiler-nix-name = "ghc8105"; }; in diff --git a/migrations/deploy/migrate_2.0.sql b/migrations/deploy/migrate_2.0.sql new file mode 100644 index 0000000..9b7c36a --- /dev/null +++ b/migrations/deploy/migrate_2.0.sql @@ -0,0 +1,62 @@ +-- Deploy octopod:migrate_2.0 to pg + +BEGIN; + +-- XXX Add DDLs here. + +ALTER TABLE deployments ADD COLUMN app_overrides jsonb; +UPDATE deployments AS d SET app_overrides = + ( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array()) + FROM deployment_overrides as "do" + WHERE d.id = "do".deployment_id AND scope = 'ApplicationScope' + ); +ALTER TABLE deployments ALTER COLUMN app_overrides SET NOT NULL; + +ALTER TABLE deployments ADD COLUMN deployment_overrides jsonb; +UPDATE deployments AS d SET deployment_overrides = + ( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array()) + FROM deployment_overrides as "do" + WHERE d.id = "do".deployment_id AND scope = 'DeploymentScope' + ); +ALTER TABLE deployments ALTER COLUMN deployment_overrides SET NOT NULL; + +ALTER TABLE deployments ADD COLUMN links jsonb; +UPDATE deployments AS d SET links = + ( SELECT COALESCE(jsonb_agg(jsonb_build_object('name', "dm".key, 'link', "dm".value)), jsonb_build_array()) + FROM deployment_metadata as "dm" + WHERE d.id = "dm".deployment_id + ); +ALTER TABLE deployments ALTER COLUMN links SET NOT NULL; + +ALTER TABLE deployments ALTER COLUMN "status" DROP DEFAULT; +ALTER TABLE deployments ALTER COLUMN "status" TYPE text; + +DROP TABLE deployment_overrides; +DROP TABLE deployment_metadata; + + +ALTER TABLE deployment_logs ALTER COLUMN "action" TYPE text; + +ALTER TABLE deployment_logs ALTER COLUMN "duration" DROP DEFAULT; +ALTER TABLE deployment_logs ALTER COLUMN "duration" TYPE interval USING (duration || ' seconds') :: interval; + +ALTER TABLE deployment_logs ADD COLUMN deployment_overrides jsonb; +UPDATE deployment_logs AS d SET deployment_overrides = + ( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array()) + FROM deployment_log_overrides as "do" + WHERE d.id = "do".deployment_log_id AND scope = 'DeploymentScope' + ); +ALTER TABLE deployment_logs ALTER COLUMN deployment_overrides SET NOT NULL; + +ALTER TABLE deployment_logs ADD COLUMN app_overrides jsonb; +UPDATE deployment_logs AS d SET app_overrides = + ( SELECT COALESCE(jsonb_agg(jsonb_build_array("do".key, jsonb_build_object('tag', 'ValueAdded', 'contents', "do".value))), jsonb_build_array()) + FROM deployment_log_overrides as "do" + WHERE d.id = "do".deployment_log_id AND scope = 'ApplicationScope' + ); +ALTER TABLE deployment_logs ALTER COLUMN app_overrides SET NOT NULL; + +DROP TABLE deployment_log_overrides; + + +COMMIT; diff --git a/migrations/revert/migrate_2.0.sql b/migrations/revert/migrate_2.0.sql new file mode 100644 index 0000000..ce99b00 --- /dev/null +++ b/migrations/revert/migrate_2.0.sql @@ -0,0 +1,12 @@ +-- Revert octopod:migrate_2.0 from pg + +BEGIN; + +DO LANGUAGE 'plpgsql' +$$ +BEGIN +RAISE EXCEPTION 'Revert not supported for this migration'; +END +$$; + +COMMIT; diff --git a/migrations/sqitch.plan b/migrations/sqitch.plan index c9b3358..dce54e4 100644 --- a/migrations/sqitch.plan +++ b/migrations/sqitch.plan @@ -15,3 +15,4 @@ rename-delete-to-archive 2020-11-23T10:53:10Z Ilya # Renam rename_delete_to_archive_2 2020-11-26T08:28:58Z Typeable LLC # Renamed delete to archive add_detailed_failures 2021-02-04T11:01:15Z Typeable LLC # Added more failure states remove_archived_column 2021-01-28T18:44:54Z Typeable LLC # Removed 'archived' column +migrate_2.0 2021-08-11T17:09:37Z Typeable LLC # Migrate to 2.0-style tables. (Less tables) diff --git a/migrations/verify/migrate_2.0.sql b/migrations/verify/migrate_2.0.sql new file mode 100644 index 0000000..15744d7 --- /dev/null +++ b/migrations/verify/migrate_2.0.sql @@ -0,0 +1,37 @@ +-- Verify octopod:migrate_2.0 on pg + +BEGIN; + +SELECT + id, + name, + tag, + app_overrides, + deployment_overrides, + created_at, + updated_at, + archived_at, + status, + status_updated_at, + checked_at, + links +FROM deployments +WHERE false; + +SELECT + id, + deployment_id, + action, + tag, + exit_code, + created_at, + archived, + duration, + stdout, + stderr, + app_overrides, + deployment_overrides +FROM deployment_logs +WHERE false; + +ROLLBACK; diff --git a/octo-cli/octo-cli.cabal b/octo-cli/octo-cli.cabal index 387c475..1556a11 100644 --- a/octo-cli/octo-cli.cabal +++ b/octo-cli/octo-cli.cabal @@ -44,7 +44,7 @@ executable octo aeson, base, bytestring, - chronos, + time, data-default-class, generic-lens, http-client-tls, @@ -59,6 +59,7 @@ executable octo transformers, table-layout ^>= 0.9.0.0, servant-auth, + ordered-containers, hs-source-dirs: src default-language: Haskell2010 ghc-options: diff --git a/octo-cli/src/Octopod/CLI.hs b/octo-cli/src/Octopod/CLI.hs index a310090..b6f1237 100644 --- a/octo-cli/src/Octopod/CLI.hs +++ b/octo-cli/src/Octopod/CLI.hs @@ -2,9 +2,9 @@ module Octopod.CLI (runOcto) where -import Chronos import Common.Types import Common.Utils (dfiName) +import Control.Exception import Control.Lens hiding (List) import Control.Monad import Control.Monad.IO.Class @@ -14,11 +14,14 @@ import qualified Data.ByteString.Lazy.Char8 as LBSC import Data.Coerce import Data.Generics.Labels () import Data.Generics.Product +import qualified Data.Map.Ordered.Strict as OM +import Data.Maybe import Data.Proxy import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T import Data.Text.Lens +import Data.Time import GHC.IO.Encoding import Network.HTTP.Client.TLS import Octopod.CLI.Args @@ -45,6 +48,9 @@ import Text.Layout.Table import Text.Layout.Table.Extras () import Prelude as P +dieT :: MonadIO m => Text -> m a +dieT = liftIO . die . T.unpack + -- | Runs the octo CLI. runOcto :: IO () runOcto = do @@ -58,27 +64,17 @@ runOcto = do let clientEnv = mkClientEnv manager env flip runReaderT clientEnv $ case args of - Create tName tTag tSetAp tSetDep tSetPAp tSetPDep -> do - setApp <- liftIO $ parseSetApplicationOverrides Public tSetAp - setDep <- liftIO $ parseSetDeploymentOverrides Public tSetDep - setPApp <- liftIO $ parseSetApplicationOverrides Private tSetPAp - setPDep <- liftIO $ parseSetDeploymentOverrides Private tSetPDep - let appOvs = setApp ++ setPApp - depOvs = setDep ++ setPDep + Create tName tTag tSetAp tSetDep -> do + appOvs <- either dieT pure $ parseSetOverrides tSetAp + depOvs <- either dieT pure $ parseSetOverrides tSetDep handleCreate auth $ Deployment (coerce tName) (coerce tTag) appOvs depOvs List -> handleList auth Archive tName -> handleArchive auth . coerce $ tName - Update tName tTag tSetAp tUnsAp tSetD tUnsD tSetPAp tSetPD -> do - setApp <- liftIO $ parseSetApplicationOverrides Public tSetAp - setDep <- liftIO $ parseSetDeploymentOverrides Public tSetD - unsetApp <- liftIO $ parseUnsetApplicationOverrides Public tUnsAp - unsetDep <- liftIO $ parseUnsetDeploymentOverrides Public tUnsD - setPApp <- liftIO $ parseSetApplicationOverrides Private tSetPAp - setPDep <- liftIO $ parseSetDeploymentOverrides Private tSetPD - let appOvs = setApp ++ setPApp - depOvs = setDep ++ setPDep - tName' = coerce tName - tTag' = coerce tTag + Update tName tTag tSetAp unsetApp tSetD unsetDep -> do + appOvs <- either dieT pure $ parseSetOverrides tSetAp + depOvs <- either dieT pure $ parseSetOverrides tSetD + let tName' = coerce tName + tTag' = coerce <$> tTag handleUpdate auth tName' tTag' appOvs unsetApp depOvs unsetDep Info tName -> handleInfo auth . coerce $ tName @@ -130,26 +126,46 @@ handleArchive auth dName = do handleUpdate :: AuthContext AuthHeaderAuth -> DeploymentName -> - DeploymentTag -> - ApplicationOverrides -> - ApplicationOverrides -> - DeploymentOverrides -> - DeploymentOverrides -> + Maybe DeploymentTag -> + Overrides 'ApplicationLevel -> + [Text] -> + Overrides 'DeploymentLevel -> + [Text] -> ReaderT ClientEnv IO () -handleUpdate auth dName dTag dNewAppOvs dOldAppOvs dNewDepOvs dOldDepOvs = do +handleUpdate auth dName dTag dNewAppOvs removedAppOvs dNewDepOvs removedDepOvs = do clientEnv <- ask + dep <- runClientM' (_fullInfoH auth dName) clientEnv + let removeAll :: Ord k => [k] -> OM.OMap k v -> Either k (OM.OMap k v) + removeAll [] m = Right m + removeAll (k : kk) m = + if k `OM.member` m + then removeAll kk $ OM.delete k m + else Left k + removeAllM :: MonadIO m => [Text] -> Overrides l -> m (Overrides l) + removeAllM ks (Overrides m) = + either + (\k -> dieT $ "Override " <> k <> " not present in deployment.") + (pure . Overrides) + $ removeAll ks m + appOverrides' <- + fmap (<> dNewAppOvs) $ + removeAllM removedAppOvs $ dep ^. #deployment . #appOverrides + deploymentOverrides' <- + fmap (<> dNewDepOvs) $ + removeAllM removedDepOvs $ dep ^. #deployment . #deploymentOverrides liftIO $ do let dUpdate = DeploymentUpdate - { newTag = dTag - , newAppOverrides = dNewAppOvs - , oldAppOverrides = dOldAppOvs - , newDeploymentOverrides = dNewDepOvs - , oldDeploymentOverrides = dOldDepOvs + { newTag = fromMaybe (dep ^. #deployment . #tag) dTag + , appOverrides = appOverrides' + , deploymentOverrides = deploymentOverrides' } response <- runClientM (updateH auth dName dUpdate) clientEnv handleResponse (const $ pure ()) response +runClientM' :: MonadIO m => ClientM a -> ClientEnv -> m a +runClientM' req env = liftIO $ runClientM req env >>= either (die . displayException) pure + -- | Handles the 'info' subcommand. handleInfo :: AuthContext AuthHeaderAuth -> DeploymentName -> ReaderT ClientEnv IO () handleInfo auth dName = do @@ -192,13 +208,13 @@ handleGetActionInfo auth aId l = do runClientM (getActionInfoH auth aId) clientEnv >>= \case Left err -> print err Right x -> case l of - Out -> T.putStrLn $ x ^. #stdout - Err -> T.putStrLn $ x ^. #stderr + Out -> T.putStrLn . unStdout $ x ^. #stdout + Err -> T.putStrLn . unStderr $ x ^. #stderr ErrOut -> do T.putStrLn "\t\tstdout:\n" - T.putStrLn $ x ^. #stdout + T.putStrLn . unStdout $ x ^. #stdout T.putStrLn "\t\tstderr:\n" - T.putStrLn $ x ^. #stderr + T.putStrLn . unStderr $ x ^. #stderr listH :: AuthContext AuthHeaderAuth -> ClientM [DeploymentFullInfo] createH :: AuthContext AuthHeaderAuth -> Deployment -> ClientM CommandResponse @@ -260,19 +276,19 @@ decodeError body = -- | Pretty-prints the 'info' subcommand result. printInfo :: DeploymentInfo -> IO () -printInfo (DeploymentInfo (Deployment _ dTag dAppOvs dStOvs) dMeta dLogs) = do +printInfo (DeploymentInfo (Deployment _ dTag dAppOvs dStOvs) (DeploymentMetadata dMeta) dLogs) = do T.putStrLn "Current settings:" T.putStrLn $ "tag: " <> coerce dTag T.putStrLn $ "application overrides: " - <> (formatOverrides $ coerce <$> dAppOvs) + <> formatOverrides dAppOvs T.putStrLn $ "deployment overrides: " - <> (formatOverrides $ coerce <$> dStOvs) + <> formatOverrides dStOvs T.putStrLn $ "metadata: " forM_ dMeta $ \m -> T.putStrLn $ - " " <> deploymentMetadataKey m <> ": " <> deploymentMetadataValue m + " " <> m ^. #name <> ": " <> m ^. #link T.putStrLn "" T.putStrLn "Last logs:" ppDeploymentLogs dLogs @@ -308,21 +324,13 @@ ppDeploymentLogRow dLog = colsAllG top [ - [ encode_YmdHMS - SubsecondPrecisionAuto - w3c - ( timeToDatetime . Time . fromIntegral $ - dLog ^. field @"createdAt" * 10 ^ (9 :: Int) - ) + [ T.pack . formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S")) $ + dLog ^. field @"createdAt" ] , [dLog ^. field @"actionId" . to unActionId . re _Show . packed] - , [dLog ^. field @"action" . coerced] + , [dLog ^. field @"action" . to actionToText] , [dLog ^. field @"deploymentTag" . coerced] - , dLog - ^. field @"deploymentAppOverrides" - . to (fmap $ formatOverride . coerce) - , dLog - ^. field @"deploymentDepOverrides" - . to (fmap $ formatOverride . coerce) + , dLog ^. field @"deploymentAppOverrides" . to formatOverrides' + , dLog ^. field @"deploymentDepOverrides" . to formatOverrides' , [dLog ^. field @"exitCode" . re _Show . packed] ] diff --git a/octo-cli/src/Octopod/CLI/Args.hs b/octo-cli/src/Octopod/CLI/Args.hs index f240435..0836909 100644 --- a/octo-cli/src/Octopod/CLI/Args.hs +++ b/octo-cli/src/Octopod/CLI/Args.hs @@ -20,10 +20,6 @@ data Args setAppOverrides :: [Text] , -- | deployment-level overrides to set setDeploymentOverrides :: [Text] - , -- | application-level private overrides to set - setAppPrivateOverrides :: [Text] - , -- | deployment-level private overrides to set - setDeploymentPrivateOverrides :: [Text] } | List | Archive @@ -34,7 +30,7 @@ data Args { -- | deployment name name :: Text , -- | deployment tag - tag :: Text + newTag :: Maybe Text , -- | application-level overrides to set setAppOverrides :: [Text] , -- | application-level overrides to unset @@ -43,10 +39,6 @@ data Args setDeploymentOverrides :: [Text] , -- | deployment-level overrides to unset unsetDeploymentOverrides :: [Text] - , -- | application-level private overrides to set - setAppPrivateOverrides :: [Text] - , -- | deployment-level private overrides to set - setDeploymentPrivateOverrides :: [Text] } | Info { -- | deployment name @@ -119,22 +111,6 @@ createArgs = <> help "set deployment level override" ) ) - <*> many - ( strOption - ( long "set-app-env-private-override" - <> short 'a' - <> help "set application level private override" - <> internal - ) - ) - <*> many - ( strOption - ( long "set-deployment-private-override" - <> short 's' - <> help "set deployment level private override" - <> internal - ) - ) -- | Parses arguments of 'list' subcommand. listArgs :: Parser Args @@ -152,7 +128,7 @@ updateArgs :: Parser Args updateArgs = Update <$> strOption (long "name" <> short 'n' <> help "deployment name") - <*> strOption (long "tag" <> short 't' <> help "deployment tag") + <*> optional (strOption (long "tag" <> short 't' <> help "deployment tag")) <*> many ( strOption ( long "set-app-env-override" @@ -181,22 +157,6 @@ updateArgs = <> help "unset a deployment level override" ) ) - <*> many - ( strOption - ( long "set-app-env-private-override" - <> short 'a' - <> help "set application level private override" - <> internal - ) - ) - <*> many - ( strOption - ( long "set-deployment-private-override" - <> short 's' - <> help "set deployment level private override" - <> internal - ) - ) -- | Parses arguments of 'info' subcommand. infoArgs :: Parser Args diff --git a/octopod-backend/octopod-backend.cabal b/octopod-backend/octopod-backend.cabal index df8364b..d9556b7 100644 --- a/octopod-backend/octopod-backend.cabal +++ b/octopod-backend/octopod-backend.cabal @@ -63,9 +63,9 @@ library Octopod.Server.Posix Orphans Types - Database.PostgreSQL.Simple.Instances Control.Octopod.DeploymentLock Octopod.PowerAPI.Auth.Server + Octopod.Schema hs-source-dirs: src build-depends: @@ -73,7 +73,6 @@ library , async , base >=4.7 && <5 , bytestring - , chronos , conduit , deriving-aeson , octopod-common @@ -87,7 +86,6 @@ library , optparse-applicative , optparse-generic , postgresql-error-codes - , postgresql-simple , resource-pool , servant , servant-server @@ -107,6 +105,11 @@ library , servant-auth-server , wai , jose + , rel8 + , time + , hasql + , hasql-transaction + , ordered-containers default-language: Haskell2010 executable octopod-exe diff --git a/octopod-backend/src/Database/PostgreSQL/Simple/Instances.hs b/octopod-backend/src/Database/PostgreSQL/Simple/Instances.hs deleted file mode 100644 index fa96d77..0000000 --- a/octopod-backend/src/Database/PostgreSQL/Simple/Instances.hs +++ /dev/null @@ -1,33 +0,0 @@ -{-# OPTIONS_GHC -Wno-orphans #-} - -module Database.PostgreSQL.Simple.Instances - ( - ) where - -import Common.Types -import Control.Applicative -import Control.Arrow -import qualified Data.Map as M -import Data.Text (Text) -import Database.PostgreSQL.Simple.FromField -import Database.PostgreSQL.Simple.ToField -import qualified Data.Text.Encoding as T - -instance ToField DeploymentStatus where - toField = toField @Text . deploymentStatusText - -instance FromField DeploymentStatus where - fromField _ (Just b) = - (either (const empty) pure . T.decodeUtf8' $ b) >>= maybe empty return . flip M.lookup m - where - m = M.fromList . fmap (deploymentStatusText &&& id) $ - [ Running - , Failure GenericFailure - , Failure TagMismatch - , Failure PartialAvailability - , CreatePending - , UpdatePending - , ArchivePending - , Archived - ] - fromField _ Nothing = empty diff --git a/octopod-backend/src/Octopod/Schema.hs b/octopod-backend/src/Octopod/Schema.hs new file mode 100644 index 0000000..ac4ba08 --- /dev/null +++ b/octopod-backend/src/Octopod/Schema.hs @@ -0,0 +1,101 @@ +module Octopod.Schema + ( DeploymentSchema (..), + deploymentSchema, + extractDeployment, + DeploymentLogSchema (..), + deploymentLogSchema, + extractDeploymentLog, + ) +where + +import Data.Generics.Product +import Data.Int +import Data.Time +import GHC.Generics (Generic) +import Orphans () +import Rel8 +import Types + +deploymentSchema :: TableSchema (DeploymentSchema Name) +deploymentSchema = + TableSchema + { name = "deployments" + , schema = Nothing + , columns = + DeploymentSchema + { id_ = "id" + , name = "name" + , tag = "tag" + , appOverrides = "app_overrides" + , deploymentOverrides = "deployment_overrides" + , createdAt = "created_at" + , updatedAt = "updated_at" + , archivedAt = "archived_at" + , status = "status" + , statusUpdatedAt = "status_updated_at" + , checkedAt = "checked_at" + , metadata = "links" + } + } + +data DeploymentSchema f = DeploymentSchema + { id_ :: Column f DeploymentId + , name :: Column f DeploymentName + , tag :: Column f DeploymentTag + , appOverrides :: Column f (Overrides 'ApplicationLevel) + , deploymentOverrides :: Column f (Overrides 'DeploymentLevel) + , createdAt :: Column f UTCTime + , updatedAt :: Column f UTCTime + , archivedAt :: Column f (Maybe UTCTime) + , status :: Column f DeploymentStatus + , statusUpdatedAt :: Column f UTCTime + , checkedAt :: Column f UTCTime + , metadata :: Column f DeploymentMetadata + } + deriving stock (Generic) + deriving anyclass (Rel8able) + +extractDeployment :: DeploymentSchema Result -> Deployment +extractDeployment = upcast + +data DeploymentLogSchema f = DeploymentLogSchema + { actionId :: Column f ActionId + , deploymentId :: Column f DeploymentId + , action :: Column f Action + , deploymentTag :: Column f DeploymentTag + , exitCode :: Column f Int64 + , createdAt :: Column f UTCTime + , archived :: Column f Bool + , duration :: Column f Duration + , stdout :: Column f Stdout + , stderr :: Column f Stderr + , deploymentAppOverrides :: Column f (Overrides 'ApplicationLevel) + , deploymentDepOverrides :: Column f (Overrides 'DeploymentLevel) + } + deriving stock (Generic) + deriving anyclass (Rel8able) + +deploymentLogSchema :: TableSchema (DeploymentLogSchema Name) +deploymentLogSchema = + TableSchema + { name = "deployment_logs" + , schema = Nothing + , columns = + DeploymentLogSchema + { actionId = "id" + , deploymentId = "deployment_id" + , action = "action" + , deploymentTag = "tag" + , exitCode = "exit_code" + , createdAt = "created_at" + , archived = "archived" + , duration = "duration" + , stdout = "stdout" + , stderr = "stderr" + , deploymentAppOverrides = "app_overrides" + , deploymentDepOverrides = "deployment_overrides" + } + } + +extractDeploymentLog :: DeploymentLogSchema Result -> DeploymentLog +extractDeploymentLog = upcast diff --git a/octopod-backend/src/Octopod/Server.hs b/octopod-backend/src/Octopod/Server.hs index 5dc6269..de610e9 100644 --- a/octopod-backend/src/Octopod/Server.hs +++ b/octopod-backend/src/Octopod/Server.hs @@ -1,16 +1,15 @@ module Octopod.Server (runOctopodServer) where -import Chronos (Time, getTime, now) -import Common.Validation (isNameValid) +import Common.Validation import Control.Applicative import Control.Concurrent (threadDelay) import Control.Concurrent.Async (race_) import qualified Control.Concurrent.Lifted as L import Control.Concurrent.MVar import Control.Concurrent.STM -import Control.Exception (Exception, displayException, throwIO, try) +import Control.Exception (Exception (displayException), throwIO) import qualified Control.Exception.Lifted as L -import Control.Lens hiding (Context, pre) +import Control.Lens hiding (Context, each, pre, (<.)) import Control.Lens.Extras import Control.Monad import Control.Monad.Base @@ -20,37 +19,44 @@ import Control.Monad.Trans.Control import Control.Octopod.DeploymentLock import Crypto.JOSE hiding (Context) import Data.Aeson (Value (..), encode, toJSON) -import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as BSC import qualified Data.ByteString.Lazy as BSL import qualified Data.ByteString.Lazy.Char8 as BSLC import Data.Coerce import Data.Conduit (ConduitT, yield) -import Data.Foldable (foldrM) +import Data.Foldable import Data.Functor import Data.Generics.Labels () +import Data.Generics.Product import Data.IORef -import Data.Int (Int64) +import Data.Int +import qualified Data.Map.Ordered.Strict as OM import Data.Maybe import Data.Pool import Data.Text (lines, pack, unpack, unwords) import qualified Data.Text as T import qualified Data.Text.Encoding as T +import Data.Time import Data.Traversable -import Database.PostgreSQL.Simple hiding ((:.)) -import Database.PostgreSQL.Simple.Instances () -import Database.PostgreSQL.Simple.Transaction +import Hasql.Connection +import qualified Hasql.Session as HasQL +import Hasql.Statement +import Hasql.Transaction +import Hasql.Transaction.Sessions import Network.Wai.Handler.Warp import Octopod.API import Octopod.PowerAPI import Octopod.PowerAPI.Auth.Server +import Octopod.Schema import Octopod.Server.Args import Octopod.Server.ControlScriptUtils import Octopod.Server.Logger import Octopod.Server.Posix import Options.Generic import Orphans () -import PostgreSQL.ErrorCodes (unique_violation) +import PostgreSQL.ErrorCodes +import Rel8 hiding (encode) +import Rel8.Expr.Time import Servant import Servant.Auth.Server import System.Environment (lookupEnv) @@ -60,14 +66,12 @@ import System.Posix.Signals (sigTERM) import Types import Prelude hiding (lines, log, unlines, unwords) -type PgPool = Pool Connection - type AppM = ReaderT AppState Handler -- | Octopod Server state definition. data AppState = AppState { -- | postgres pool - pool :: PgPool + dbPool :: Pool Connection , -- | logger logger :: TimedFastLogger , -- | channel for WS events for the frontend @@ -147,8 +151,8 @@ runOctopodServer = do projName <- coerce . pack <$> getEnvOrDie "PROJECT_NAME" domain <- coerce . pack <$> getEnvOrDie "BASE_DOMAIN" ns <- coerce . pack <$> getEnvOrDie "NAMESPACE" - archRetention <- coerce . read @Int <$> getEnvOrDie "ARCHIVE_RETENTION" - stUpdateTimeout <- coerce . read @Int <$> getEnvOrDie "STATUS_UPDATE_TIMEOUT" + archRetention <- ArchiveRetention . fromIntegral . read @Int <$> getEnvOrDie "ARCHIVE_RETENTION" + stUpdateTimeout <- Timeout . CalendarDiffTime 0 . fromIntegral . read @Int <$> getEnvOrDie "STATUS_UPDATE_TIMEOUT" creationCmd <- coerce . pack <$> getEnvOrDie "CREATION_COMMAND" updateCmd <- coerce . pack <$> getEnvOrDie "UPDATE_COMMAND" archiveCmd <- coerce . pack <$> getEnvOrDie "ARCHIVE_COMMAND" @@ -163,15 +167,18 @@ runOctopodServer = do lookupEnv "NOTIFICATION_COMMAND" <&> \case Just "" -> Nothing x -> x - pgPool <- - initConnectionPool - (unDBConnectionString $ octopodDB opts) + dbPool' <- + createPool + (fmap (either (error . show) id) . acquire $ unDBConnectionString $ octopodDB opts) + release + 1 + 30 (unDBPoolSize $ octopodDBPoolSize opts) channel <- liftIO . atomically $ newBroadcastTChan lockedDs <- initLockedDeployments let appSt = AppState - pgPool + dbPool' logger' channel bgWorkersC @@ -205,10 +212,33 @@ runOctopodServer = do `race_` (runStatusUpdater appSt) `race_` (runShutdownHandler appSt) --- | Initializes the connection pool. -initConnectionPool :: ByteString -> Int -> IO PgPool -initConnectionPool dbConnStr = - createPool (connectPostgreSQL dbConnStr) close 1 30 +runTransaction :: + (MonadReader AppState m, MonadBaseControl IO m) => + Transaction a -> + m a +runTransaction q = do + p <- asks dbPool + log <- asks logger + let logErr = logWarning log + handleException e = do + liftBase $ logErr . T.pack $ displayException e + throwIO e + withResource p $ \conn -> + liftBase $ HasQL.run (transaction Serializable Write q) conn >>= either handleException pure + +runStatement :: + (MonadReader AppState m, MonadBaseControl IO m) => + Statement () a -> + m a +runStatement q = do + p <- asks dbPool + log <- asks logger + let logErr = logWarning log + handleException e = do + liftBase $ logErr . T.pack $ displayException e + throwIO e + withResource p $ \conn -> + liftBase $ HasQL.run (HasQL.statement () q) conn >>= either handleException pure -- | Helper to run the server. nt :: AppState -> AppM a -> Handler a @@ -236,7 +266,7 @@ server = powerApp :: AuthHeader -> AppState -> IO Application powerApp h s = do jwk' <- liftIO $ genJWK (RSAGenParam (4096 `div` 8)) - let ctx :: Context Ctx + let ctx :: Servant.Context Ctx ctx = h :. (defaultJWTSettings jwk' :: JWTSettings) @@ -285,11 +315,11 @@ eventS channel = do -- | Handles the 'list' request of the Web UI API. listH :: AppM [DeploymentFullInfo] -listH = hidePrivateOverridesInFullInfos <$> getFullInfo FullInfoForAll +listH = getFullInfo -- | Handles the 'list' request of the octo CLI API. powerListH :: AppM [DeploymentFullInfo] -powerListH = getFullInfo FullInfoForAll +powerListH = getFullInfo -- | Handles the 'full_info' request of the Web UI API. fullInfoH :: @@ -297,12 +327,9 @@ fullInfoH :: DeploymentName -> m DeploymentFullInfo fullInfoH dName = do - fullInfoList <- - hidePrivateOverridesInFullInfos - <$> getFullInfo (FullInfoOnlyForOne dName) - case fullInfoList of - fullInfo : _ -> pure fullInfo - [] -> + getSingleFullInfo dName >>= \case + Just fullInfo -> pure fullInfo + Nothing -> throwError err404 { errBody = validationError ["Name not found"] [] @@ -310,78 +337,37 @@ fullInfoH dName = do -- | Handles the 'full_info' request of the octo CLI API. powerFullInfoH :: DeploymentName -> AppM DeploymentFullInfo -powerFullInfoH dName = do - fullInfoList <- getFullInfo $ FullInfoOnlyForOne dName - case fullInfoList of - fullInfo : _ -> pure fullInfo - [] -> - throwError - err404 - { errBody = validationError ["Name not found"] [] - } +powerFullInfoH = fullInfoH --- | Hides private overrides in 'full_info' response. -hidePrivateOverridesInFullInfos :: [DeploymentFullInfo] -> [DeploymentFullInfo] -hidePrivateOverridesInFullInfos dFullInfos = do - dFullInfos <&> \(DeploymentFullInfo dep s ct ut u) -> - let hidePrivate (Deployment n t appOvs depOvs) = - Deployment n t (hideP appOvs) (hideP depOvs) - hideP o = - coerce o <&> \(Override k v vis) -> - let v' = case vis of - Private -> "*" - Public -> v - in coerce $ Override k v' vis - in DeploymentFullInfo (hidePrivate dep) s ct ut u +getSingleFullInfo :: + (MonadReader AppState m, MonadBaseControl IO m) => + DeploymentName -> + m (Maybe DeploymentFullInfo) +getSingleFullInfo dName = do + AppState {logger = l} <- ask + deploymentsSchema <- runStatement . select $ do + d <- each deploymentSchema + where_ $ d ^. #name ==. litExpr dName + pure d + deployments <- forM deploymentsSchema extractDeploymentFullInfo + liftBase . logInfo l $ "get deployments: " <> (pack . show $ deployments) + return $ listToMaybe deployments getFullInfo :: (MonadReader AppState m, MonadBaseControl IO m) => - FullInfoListType -> m [DeploymentFullInfo] -getFullInfo lType = do - p <- asks pool - liftBaseOp (withResource p) $ \conn -> getFullInfo' conn lType - --- | Helper to get full_info from the database. -getFullInfo' :: - (MonadReader AppState m, MonadBase IO m) => - Connection -> - FullInfoListType -> - m [DeploymentFullInfo] -getFullInfo' conn listType = do +getFullInfo = do AppState {logger = l} <- ask - deployments <- do - rows <- case listType of - FullInfoForAll -> liftBase $ query_ conn qAll - FullInfoOnlyForOne dName -> liftBase $ query conn qOne (Only dName) - for rows $ \(n, t, ct, ut, st) -> do - (appOvs, depOvs) <- liftBase $ selectOverrides conn n - dMeta <- liftBase $ selectDeploymentMetadata conn n - st' <- - isDeploymentLocked n <&> \case - True -> DeploymentPending st - False -> DeploymentNotPending st - pure $ do - let dep = Deployment n t appOvs depOvs - DeploymentFullInfo dep st' dMeta ct ut + deploymentsSchema <- runStatement . select $ each deploymentSchema + deployments <- forM deploymentsSchema extractDeploymentFullInfo liftBase . logInfo l $ "get deployments: " <> (pack . show $ deployments) return deployments - where - qAll = - "SELECT name, tag, extract(epoch from created_at)::int, \ - \extract(epoch from updated_at)::int, status \ - \FROM deployments ORDER BY name" - qOne = - "SELECT name, tag, extract(epoch from created_at)::int, \ - \extract(epoch from updated_at)::int, status \ - \FROM deployments \ - \WHERE name = ?" -- | Handles the 'create' request. createH :: Deployment -> AppM CommandResponse createH dep = do failIfGracefulShutdownActivated - unless (isNameValid $ name dep) $ do + unless (isNameValid $ dep ^. #name) $ do let badNameText = "Deployment name length should be longer than 2 characters, \ \under 17 characters and begin with a letter." @@ -389,51 +375,59 @@ createH dep = do err400 { errBody = validationError [badNameText] [] } - t1 <- liftIO $ now - st <- ask - let pgPool = pool st + t1 <- liftBase getCurrentTime failIfImageNotFound dep failIfGracefulShutdownActivated runDeploymentBgWorker Nothing - (name dep) + (dep ^. #name) ( do - let q = - "INSERT INTO deployments (name, tag, status) \ - \VALUES (?, ?, ?) RETURNING id" - createDep :: PgPool -> Deployment -> IO [Only Int] - createDep p Deployment {name = n, tag = t} = - withResource p $ \conn -> - query conn q (n, t, CreatePending) - res :: Either SqlError [Only Int] <- liftIO . try $ createDep pgPool dep + res <- + L.try $ + runStatement $ + insert + Insert + { into = deploymentSchema + , rows = + values + [ DeploymentSchema + { id_ = unsafeDefault + , name = litExpr $ dep ^. #name + , tag = litExpr $ dep ^. #tag + , appOverrides = litExpr $ dep ^. #appOverrides + , deploymentOverrides = litExpr $ dep ^. #deploymentOverrides + , createdAt = now + , updatedAt = now + , archivedAt = litExpr Nothing + , status = litExpr Running + , statusUpdatedAt = now + , checkedAt = now + , metadata = litExpr (DeploymentMetadata []) + } + ] + , onConflict = Abort + , returning = pure () + } case res of - Right ((Only depId) : _) -> - pure . DeploymentId $ depId - Right [] -> - throwError - err404 - { errBody = validationError ["Name not found"] [] - } - Left (SqlError code _ _ _ _) + Right () -> pure () + Left (HasQL.QueryError _ _ (HasQL.ResultError (HasQL.ServerError code _ _ _))) | code == unique_violation -> throwError err400 { errBody = validationError ["Deployment already exists"] [] } - Left (SqlError _ _ _ _ _) -> + Left _ -> throwError err409 {errBody = appError "Some database error"} ) - $ \dId -> do - liftIO . withResource pgPool $ \conn -> - upsertNewOverrides conn dId (appOverrides dep) (deploymentOverrides dep) + $ \() -> do + st <- ask liftBase $ sendReloadEvent st - updateDeploymentInfo (name dep) - (ec, out, err) <- liftBase $ createDeployment dep st - t2 <- liftBase $ now + updateDeploymentInfo (dep ^. #name) + (ec, out, err) <- createDeployment dep + t2 <- liftBase getCurrentTime let elTime = elapsedTime t2 t1 - withResource pgPool $ \conn -> - -- calling it directly now is fine since there is no previous status. - createDeploymentLog conn dep "create" ec elTime out err + -- calling it directly now is fine since there is no previous status. + createDeploymentLog dep CreateAction ec elTime out err liftBase $ sendReloadEvent st liftBase $ handleExitCode ec pure Success @@ -445,16 +439,15 @@ updateDeploymentInfo :: m () updateDeploymentInfo dName = do log <- asks (logWarning . logger) - pgPool <- asks pool - DeploymentFullInfo {deployment = dep} <- - withResource pgPool $ \conn -> getDeploymentS conn dName - (ec, out, err) <- runCommandArgs infoCommand =<< infoCommandArgs dep - liftBase $ - case ec of - ExitSuccess -> do - dMeta <- parseDeploymentMetadata (lines . unStdout $ out) - upsertDeploymentMetadata pgPool dName dMeta - ExitFailure _ -> + DeploymentFullInfo {deployment = dep} <- getDeploymentS dName + dc <- getDefaultConfig dName + (ec, out, err) <- runCommandArgs infoCommand =<< infoCommandArgs dc dep + case ec of + ExitSuccess -> do + dMeta <- liftBase $ parseDeploymentMetadata (lines . unStdout $ out) + upsertDeploymentMetadatum dName dMeta + ExitFailure _ -> + liftBase $ log $ "could not get deployment info, exit code: " <> (pack . show $ ec) <> ", stdout: " @@ -462,75 +455,53 @@ updateDeploymentInfo dName = do <> "stderr: " <> coerce err +getDefaultConfig :: Monad m => DeploymentName -> m FullDefaultConfig +getDefaultConfig _ = + pure + FullDefaultConfig + { appDefaultConfig = DefaultConfig OM.empty + , depDefaultConfig = DefaultConfig OM.empty + } + -- | Helper to create a new deployment. -createDeployment :: Deployment -> AppState -> IO (ExitCode, Stdout, Stderr) -createDeployment dep st = do +createDeployment :: + (MonadBaseControl IO m, MonadReader AppState m) => + Deployment -> + m (ExitCode, Stdout, Stderr) +createDeployment dep = do + st <- ask + dCfg <- getDefaultConfig $ dep ^. #name let log :: Text -> IO () log = logInfo (logger st) args = - [ "--project-name" - , coerce $ projectName st - , "--base-domain" - , coerce $ baseDomain st - , "--namespace" - , coerce $ namespace st - , "--name" - , coerce $ name dep - , "--tag" - , coerce $ tag dep - ] - ++ applicationOverridesToArgs (appOverrides dep) - ++ deploymentOverridesToArgs (deploymentOverrides dep) - cmd = coerce $ creationCommand st - - liftIO $ do - log $ "call " <> unwords (cmd : args) - (ec, out, err) <- runCommand (unpack cmd) (unpack <$> args) - log $ "deployment created, deployment: " <> (pack . show $ dep) - pure (ec, out, err) + ControlScriptArgs + [ "--project-name" + , T.unpack . coerce $ projectName st + , "--base-domain" + , T.unpack . coerce $ baseDomain st + , "--namespace" + , T.unpack . coerce $ namespace st + , "--name" + , T.unpack . coerce $ dep ^. #name + , "--tag" + , T.unpack . coerce $ dep ^. #tag + ] + <> fullConfigArgs dCfg dep + (ec, out, err) <- runCommandArgs creationCommand args + liftBase . log $ "deployment created, deployment: " <> (pack . show $ dep) + pure (ec, out, err) -- | Helper to get deployment logs. selectDeploymentLogs :: - PgPool -> - DeploymentId -> - IO [DeploymentLog] -selectDeploymentLogs pgPool dId = do - let q = - "SELECT id, action::text, tag, exit_code, \ - \duration, extract(epoch from created_at)::int \ - \FROM deployment_logs \ - \WHERE deployment_id = ? \ - \ORDER BY created_at DESC \ - \LIMIT 20" - withResource pgPool $ \conn -> do - rows <- query conn q (Only . unDeploymentId $ dId) - -- FIXME: use FromRow instance instead - for rows $ \(ai, a, t, ec, d, ts) -> do - (appOvs, depOvs) <- selectLogOverrides conn (ActionId ai) - pure $ DeploymentLog (ActionId ai) a t appOvs depOvs ec (Duration d) ts - --- | Helper to get a deployment. -selectDeployment :: + (MonadBaseControl IO m, MonadReader AppState m) => DeploymentName -> - AppM Deployment -selectDeployment dName = do - pgPool <- asks pool - liftBaseOp (withResource pgPool) $ \conn -> - liftIO (selectDeploymentIO conn dName) >>= either throwError pure - -selectDeploymentIO :: - Connection -> - DeploymentName -> - IO (Either ServerError Deployment) -selectDeploymentIO conn dName = do - let q = "SELECT name, tag FROM deployments WHERE name = ?" - retrieved <- liftBase $ query conn q (Only dName) - case retrieved of - [(n, t)] -> do - (appOvs, stOvs) <- liftBase $ selectOverrides conn n - pure . Right $ Deployment n t appOvs stOvs - [] -> pure . Left $ err404 {errBody = "Deployment not found."} - _ -> pure . Left $ err500 + m [DeploymentLog] +selectDeploymentLogs dName = (fmap . fmap) extractDeploymentLog . runStatement . select $ do + dls <- each deploymentLogSchema + ds <- each deploymentSchema + where_ $ ds ^. #name ==. litExpr dName + where_ $ dls ^. #deploymentId ==. ds ^. #id_ + pure dls data StatusTransitionProcessOutput = StatusTransitionProcessOutput { exitCode :: ExitCode @@ -565,14 +536,14 @@ processOutput :: DeploymentStatusTransition -> Maybe (StatusTransitionProcessOut processOutput TransitionArchived = Nothing processOutput TransitionCreate = Nothing processOutput TransitionUpdate = Nothing -processOutput (TransitionRestore x) = Just (x, Action "restore") -processOutput (TransitionArchivePending x) = Just (x, Action "archive") -processOutput (TransitionUpdatePending x) = Just (x, Action "update") -processOutput (TransitionCreatePending x) = Just (x, Action "create") +processOutput (TransitionRestore x) = Just (x, RestoreAction) +processOutput (TransitionArchivePending x) = Just (x, ArchiveAction) +processOutput (TransitionUpdatePending x) = Just (x, UpdateAction) +processOutput (TransitionCreatePending x) = Just (x, CreateAction) processOutput TransitionFailure {} = Nothing transitionToStatusS :: - (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m, MonadIO m) => + (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m) => DeploymentName -> DeploymentStatusTransition -> m () @@ -606,52 +577,69 @@ transitionErrorToServerError = \case show'' = BSLC.pack . show getDeploymentS :: - (MonadReader AppState m, MonadBase IO m, MonadError ServerError m) => - Connection -> + (MonadReader AppState m, MonadBaseControl IO m, MonadError ServerError m) => DeploymentName -> m DeploymentFullInfo -getDeploymentS conn dName = - (getFullInfo' conn (FullInfoOnlyForOne dName)) >>= \case - [x] -> return x - _ -> throwError err404 {errBody = "Deployment not found."} +getDeploymentS dName = + getSingleFullInfo dName >>= \case + Just x -> return x + Nothing -> throwError err404 {errBody = "Deployment not found."} transitionToStatus :: - (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m, MonadIO m) => + (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m) => DeploymentName -> DeploymentStatusTransition -> ExceptT StatusTransitionError m () transitionToStatus dName s = do - p <- asks pool st <- ask let log = liftBase . logInfo (logger st) - (oldS, newS, dep :: DeploymentFullInfo) <- withResource p $ \conn -> liftBaseOp_ (withTransaction conn) $ do - dep <- lift $ getDeploymentS conn dName - let oldS = recordedStatus $ dep ^. #status - newS = transitionStatus s + newS = transitionStatus s + log $ "Transitioning deployment " <> (show' . unDeploymentName) dName <> " " <> show' s + res <- runTransaction . runExceptT $ do + oldS <- + (lift . statement () . select) + ( do + d <- each deploymentSchema + where_ $ d ^. #name ==. litExpr dName + pure $ (d ^. #status) + ) + >>= \case + [x] -> pure x + _ -> throwError $ DeploymentNotFound dName assertStatusTransitionPossible dName oldS newS - log $ "Transitioning deployment " <> (show' . unDeploymentName) dName <> " " <> show' s - let q = - "UPDATE deployments \ - \SET status = ?, status_updated_at = now() " - <> (if newS == ArchivePending then ", archived_at = now()" else mempty) - <> " WHERE name = ?" - void . liftBase $ execute conn q (newS, dName) - lift $ - forM_ (processOutput s) $ \(output, act) -> - createDeploymentLog - conn - (dep ^. #deployment) - act - (output ^. #exitCode) - (output ^. #duration) - (output ^. #stdout) - (output ^. #stderr) - return (oldS, newS, dep) + dep <- + (fmap . fmap) extractDeployment + . lift + . statement () + . update + $ Update + { target = deploymentSchema + , from = pure () + , set = \() dep -> + dep + & #status .~ litExpr newS + & #updatedAt .~ now + & if newS == Archived then #archivedAt .~ nullify now else id + , updateWhere = \() dep -> dep ^. #name ==. litExpr dName + , returning = Projection id + } + pure (dep, oldS) + (deps, oldS) <- either throwError pure res + dep <- lift $ ensureOne deps + lift $ + forM_ (processOutput s) $ \(output, act) -> + createDeploymentLog + dep + act + (output ^. #exitCode) + (output ^. #duration) + (output ^. #stdout) + (output ^. #stderr) notificationCmd <- asks notificationCommand forM_ notificationCmd $ \nCmd -> runBgWorker . void $ runCommandArgs' nCmd - =<< notificationCommandArgs dName (dep ^. #deployment . #tag) oldS newS + =<< notificationCommandArgs dName (dep ^. #tag) oldS newS liftBase $ sendReloadEvent st assertStatusTransitionPossible :: @@ -671,11 +659,10 @@ assertDeploymentTransitionPossible :: DeploymentStatus -> m () assertDeploymentTransitionPossible dName new = do - p <- asks pool - dep <- withResource p $ \conn -> - (getFullInfo' conn (FullInfoOnlyForOne dName)) >>= \case - [x] -> return x - _ -> throwError $ DeploymentNotFound dName + dep <- + getSingleFullInfo dName >>= \case + Just x -> return x + Nothing -> throwError $ DeploymentNotFound dName assertStatusTransitionPossible dName (recordedStatus $ dep ^. #status) new assertDeploymentTransitionPossibleS :: @@ -706,7 +693,7 @@ anyPred preds x = any ($ x) preds archiveH :: DeploymentName -> AppM CommandResponse archiveH dName = do failIfGracefulShutdownActivated - t1 <- liftBase now + t1 <- liftBase getCurrentTime st <- ask let log = liftBase . logInfo (logger st) args = @@ -723,7 +710,7 @@ archiveH dName = do runDeploymentBgWorker (Just ArchivePending) dName (pure ()) $ \() -> do log $ "call " <> unwords (cmd : args) (ec, out, err) <- runCommand (unpack cmd) (unpack <$> args) - t2 <- liftBase now + t2 <- liftBase getCurrentTime let elTime = elapsedTime t2 t1 transitionToStatusS dName $ TransitionArchivePending @@ -740,53 +727,58 @@ archiveH dName = do updateH :: DeploymentName -> DeploymentUpdate -> AppM CommandResponse updateH dName dUpdate = do failIfGracefulShutdownActivated - t1 <- liftIO $ now + t1 <- liftIO getCurrentTime st <- ask - let DeploymentUpdate - { newTag = dTag - , newAppOverrides = newAppOvs - , oldAppOverrides = oldAppOvs - , newDeploymentOverrides = newDepOvs - , oldDeploymentOverrides = oldDepOvs - } = dUpdate - pgPool = pool st - log = logInfo (logger st) - dId <- selectDeploymentId pgPool dName - olDep <- selectDeployment dName - failIfImageNotFound (applyDeploymentUpdate dUpdate olDep) + let log = logInfo (logger st) + olDep <- getDeploymentS dName <&> (^. #deployment) + failIfImageNotFound + ( olDep + & field' @"appOverrides" <>~ dUpdate ^. #appOverrides + & field' @"deploymentOverrides" <>~ dUpdate ^. #deploymentOverrides + ) failIfGracefulShutdownActivated runDeploymentBgWorker (Just UpdatePending) dName (pure ()) $ \() -> do - (appOvs, depOvs) <- liftBase . withResource pgPool $ \conn -> - withTransaction conn $ do - deleteOldOverrides conn dId oldAppOvs oldDepOvs - upsertNewOverrides conn dId newAppOvs newDepOvs - updateTag conn dId dTag - selectOverrides conn dName + dep <- + fmap extractDeployment $ + runStatement + ( update + Update + { target = deploymentSchema + , from = pure () + , set = \() ds -> + ds & #appOverrides .~ litExpr (dUpdate ^. #appOverrides) + & #deploymentOverrides .~ litExpr (dUpdate ^. #deploymentOverrides) + & #tag .~ litExpr (dUpdate ^. #newTag) + , updateWhere = \() ds -> ds ^. #name ==. litExpr dName + , returning = Projection id + } + ) + >>= ensureOne + updateDeploymentInfo dName liftBase $ sendReloadEvent st + dCfg <- getDefaultConfig dName let args = - [ "--project-name" - , coerce $ projectName st - , "--base-domain" - , coerce $ baseDomain st - , "--namespace" - , coerce $ namespace st - , "--name" - , coerce $ dName - , "--tag" - , coerce $ dTag - ] - ++ applicationOverridesToArgs appOvs - ++ deploymentOverridesToArgs depOvs - cmd = coerce $ updateCommand st - liftBase . log $ "call " <> unwords (cmd : args) - (ec, out, err) <- liftBase $ runCommand (unpack cmd) (unpack <$> args) + ControlScriptArgs + [ "--project-name" + , T.unpack . coerce $ projectName st + , "--base-domain" + , T.unpack . coerce $ baseDomain st + , "--namespace" + , T.unpack . coerce $ namespace st + , "--name" + , T.unpack . coerce $ dName + , "--tag" + , T.unpack . coerce $ dep ^. #tag + ] + <> fullConfigArgs dCfg dep + (ec, out, err) <- runCommandArgs updateCommand args liftBase . log $ "deployment updated, name: " <> coerce dName <> ", tag: " - <> coerce dTag - t2 <- liftBase now + <> coerce (dep ^. #tag) + t2 <- liftBase getCurrentTime let elTime = elapsedTime t2 t1 transitionToStatusS dName $ TransitionUpdatePending @@ -799,136 +791,6 @@ updateH dName dUpdate = do handleExitCode ec return Success --- | Helper to get overrides from the database. -selectOverrides :: - Connection -> - DeploymentName -> - IO (ApplicationOverrides, DeploymentOverrides) -selectOverrides conn dName = do - let q = - "SELECT key, value, scope::text, visibility::text \ - \FROM deployment_overrides \ - \WHERE deployment_id = ( \ - \SELECT id FROM deployments WHERE name = ? \ - \)" - parseVis :: Text -> OverrideVisibility - parseVis = read . unpack - parseScope :: Text -> OverrideScope - parseScope = read . unpack - toOverrides (k, v, s, vis) (appOvs, depOvs) = - pure $ case parseScope s of - ApplicationScope -> - (ApplicationOverride (Override k v $ parseVis vis) : appOvs, depOvs) - DeploymentScope -> - (appOvs, DeploymentOverride (Override k v $ parseVis vis) : depOvs) - rows <- query conn q (Only dName) - foldrM toOverrides ([], []) rows - --- | Helper to get override logs from the database. -selectLogOverrides :: - Connection -> - ActionId -> - IO (ApplicationOverrides, DeploymentOverrides) -selectLogOverrides conn aId = do - let q = - "SELECT key, value, scope::text, visibility::text \ - \FROM deployment_log_overrides \ - \WHERE deployment_log_id = ?" - parseVis :: Text -> OverrideVisibility - parseVis = read . unpack - parseScope :: Text -> OverrideScope - parseScope = read . unpack - toOverrides (k, v, s, vis) (appOvs, depOvs) = - pure $ case parseScope s of - ApplicationScope -> - (ApplicationOverride (Override k v $ parseVis vis) : appOvs, depOvs) - DeploymentScope -> - (appOvs, DeploymentOverride (Override k v $ parseVis vis) : depOvs) - rows <- query conn q (Only . unActionId $ aId) - foldrM toOverrides ([], []) rows - --- | Helper to delete overrides from the database. -deleteOldOverrides :: - Connection -> - DeploymentId -> - ApplicationOverrides -> - DeploymentOverrides -> - IO () -deleteOldOverrides conn dId appOvs depOvs = do - let q = - "DELETE FROM deployment_overrides \ - \WHERE deployment_id = ? AND key = ? AND scope = ?" - dId' = unDeploymentId dId - void $ - for appOvs $ \o -> do - let oKey = overrideKey . unApplicationOverride $ o - oScope = show ApplicationScope - execute conn q (dId', oKey, oScope) - void $ - for depOvs $ \o -> do - let oKey = overrideKey . unDeploymentOverride $ o - oScope = show DeploymentScope - execute conn q (dId', oKey, oScope) - --- | Helper to get the deployment id from the database. -selectDeploymentId :: PgPool -> DeploymentName -> AppM DeploymentId -selectDeploymentId pgPool dName = do - dIds :: [(Only Int)] <- liftIO $ - withResource pgPool $ \conn -> - query conn "SELECT id FROM deployments WHERE name = ?" (Only dName) - case dIds of - [(Only dId)] -> pure . DeploymentId $ dId - [] -> - throwError - err404 - { errBody = validationError ["Name not found"] [] - } - _ -> - throwError - err406 - { errBody = validationError ["More than one name found"] [] - } - --- | Helper to insert or update overrides. -upsertNewOverrides :: - Connection -> - DeploymentId -> - ApplicationOverrides -> - DeploymentOverrides -> - IO () -upsertNewOverrides conn dId appOvs depOvs = do - let q = - "INSERT INTO deployment_overrides \ - \(key, value, deployment_id, scope, visibility) \ - \VALUES (?, ?, ?, ?, ?) \ - \ON CONFLICT (key, deployment_id, scope) \ - \DO \ - \UPDATE SET value = ?, visibility = ?, updated_at = now()" - dId' = unDeploymentId dId - void $ - for appOvs $ \o -> do - let oKey = overrideKey . unApplicationOverride $ o - oValue = overrideValue . unApplicationOverride $ o - oScope = show ApplicationScope - oVis = show . overrideVisibility . unApplicationOverride $ o - execute conn q (oKey, oValue, dId', oScope, oVis, oValue, oVis) - void $ - for depOvs $ \o -> do - let oKey = overrideKey . unDeploymentOverride $ o - oValue = overrideValue . unDeploymentOverride $ o - oScope = show DeploymentScope - oVis = show . overrideVisibility . unDeploymentOverride $ o - execute conn q (oKey, oValue, dId', oScope, oVis, oValue, oVis) - -updateTag :: - Connection -> - DeploymentId -> - DeploymentTag -> - IO () -updateTag conn (DeploymentId dId) (DeploymentTag dTag) = do - let q = "UPDATE deployments SET tag=? WHERE id=?;" - void $ execute conn q (dTag, dId) - -- | Handles the 'info' request of the Web UI API. infoH :: DeploymentName -> AppM [DeploymentInfo] infoH dName = do @@ -936,7 +798,7 @@ infoH dName = do dInfo <- getInfo dName liftIO . logInfo (logger st) $ "get deployment info: " <> (pack . show $ dInfo) - pure [hidePrivateOverridesInInfo dInfo] + pure [dInfo] -- | Handles the 'info' request of the octo CLI API. powerInfoH :: DeploymentName -> AppM [DeploymentInfo] @@ -947,56 +809,34 @@ powerInfoH dName = do "get deployment info: " <> (pack . show $ dInfo) pure [dInfo] --- | Hides private overrides of 'info' response. -hidePrivateOverridesInInfo :: DeploymentInfo -> DeploymentInfo -hidePrivateOverridesInInfo (DeploymentInfo dep dMeta dLogs) = - let dep' = - let (Deployment n t ao so) = dep - in Deployment n t (hideP ao) (hideP so) - dLogs' = - dLogs <&> \(DeploymentLog ai a t ao so ec d ct) -> - DeploymentLog ai a t (hideP ao) (hideP so) ec d ct - hideP o = - coerce o <&> \(Override k v vis) -> - let v' = case vis of - Private -> "*" - Public -> v - in coerce $ Override k v' vis - in DeploymentInfo dep' dMeta dLogs' - -- | Helper to get deployment info from the database. getInfo :: DeploymentName -> AppM DeploymentInfo getInfo dName = do - st <- ask - let pgPool = pool st - dep <- selectDeployment dName - dId <- selectDeploymentId pgPool dName - liftIO $ do - depLogs <- selectDeploymentLogs pgPool dId - dMeta <- withResource pgPool $ \conn -> - selectDeploymentMetadata conn dName - let depInfo = DeploymentInfo dep dMeta $ reverse depLogs - pure depInfo + dep <- getDeploymentS dName + dLogs <- selectDeploymentLogs dName + pure $ + DeploymentInfo + { deployment = dep ^. #deployment + , metadata = dep ^. #metadata + , logs = dLogs + } -- | Handles the 'ping' request. pingH :: AppM NoContent pingH = do - pgPool <- pool <$> ask - _ :: [Only Int] <- liftIO $ - withResource pgPool $ \conn -> - query_ conn "SELECT 1" + _ <- runStatement $ select $ pure $ litExpr True pure NoContent -- | Handles the 'project_name' request. projectNameH :: AppM ProjectName -projectNameH = projectName <$> ask +projectNameH = asks projectName -- | Handles the 'status' request. statusH :: DeploymentName -> AppM CurrentDeploymentStatus statusH dName = do - pgPool <- asks pool - dep <- withResource pgPool $ \conn -> getDeploymentS conn dName - (ec, _, _) <- runCommandArgs checkingCommand =<< checkCommandArgs (dep ^. #deployment) + dep <- getDeploymentS dName + dCfg <- getDefaultConfig dName + (ec, _, _) <- runCommandArgs checkingCommand =<< checkCommandArgs dCfg (dep ^. #deployment) pure . CurrentDeploymentStatus $ case ec of ExitSuccess -> Ok @@ -1006,15 +846,17 @@ statusH dName = do cleanupH :: DeploymentName -> AppM CommandResponse cleanupH dName = do failIfGracefulShutdownActivated - st <- ask - runDeploymentBgWorker Nothing dName (pure ()) $ \() -> liftBase $ cleanupDeployment dName st + runDeploymentBgWorker Nothing dName (pure ()) $ \() -> cleanupDeployment dName pure Success -- | Helper to cleanup deployment. -cleanupDeployment :: DeploymentName -> AppState -> IO () -cleanupDeployment dName st = do +cleanupDeployment :: + (MonadBaseControl IO m, MonadReader AppState m) => + DeploymentName -> + m () +cleanupDeployment dName = do + st <- ask let log = logInfo (logger st) - pgPool = pool st args = [ "--project-name" , coerce $ projectName st @@ -1026,68 +868,61 @@ cleanupDeployment dName st = do , coerce dName ] cmd = coerce $ cleanupCommand st - log $ "call " <> unwords (cmd : args) + liftBase . log $ "call " <> unwords (cmd : args) (ec, out, err) <- runCommand (unpack cmd) (unpack <$> args) - print out >> print err - void $ deleteDeploymentLogOverrides pgPool dName - void $ deleteDeploymentLogs pgPool dName - void $ deleteDeploymentMetadata pgPool dName - void $ deleteDeploymentOverrides pgPool dName - void $ deleteDeployment pgPool dName - log $ "deployment destroyed, name: " <> coerce dName - sendReloadEvent st + liftBase $ print out >> print err + deleteDeploymentLogs dName + deleteDeployment dName + liftBase $ log $ "deployment destroyed, name: " <> coerce dName + liftBase $ sendReloadEvent st handleExitCode ec --- | Helper to delete deployment log overrides. -deleteDeploymentLogOverrides :: PgPool -> DeploymentName -> IO Int64 -deleteDeploymentLogOverrides p n = withResource p $ \conn -> - execute - conn - "DELETE FROM deployment_log_overrides WHERE deployment_log_id in ( \ - \SELECT id FROM deployment_logs WHERE deployment_id in ( \ - \SELECT id FROM deployments where name = ? \ - \) \ - \)" - (Only n) - -- | Helper to delete deployment logs. -deleteDeploymentLogs :: PgPool -> DeploymentName -> IO Int64 -deleteDeploymentLogs p n = withResource p $ \conn -> - execute - conn - "DELETE FROM deployment_logs WHERE deployment_id in\ - \ (SELECT id FROM deployments where name = ?)" - (Only n) - --- | Helper to delete deployment overrides. -deleteDeploymentOverrides :: PgPool -> DeploymentName -> IO Int64 -deleteDeploymentOverrides p n = withResource p $ \conn -> - execute - conn - "DELETE FROM deployment_overrides WHERE deployment_id in\ - \ (SELECT id FROM deployments where name = ?)" - (Only n) +deleteDeploymentLogs :: + (MonadBaseControl IO m, MonadReader AppState m) => + DeploymentName -> + m () +deleteDeploymentLogs dName = + runStatement $ + delete + Delete + { from = deploymentLogSchema + , using = each deploymentSchema + , deleteWhere = \ds dls -> ds ^. #name ==. litExpr dName &&. dls ^. #deploymentId ==. ds ^. #id_ + , returning = pure () + } -- | Helper to delete a deployment. -deleteDeployment :: PgPool -> DeploymentName -> IO Int64 -deleteDeployment p n = withResource p $ \conn -> - execute conn "DELETE FROM deployments WHERE name = ?" (Only n) +deleteDeployment :: + (MonadBaseControl IO m, MonadReader AppState m) => + DeploymentName -> + m () +deleteDeployment dName = + runStatement $ + delete + Delete + { from = deploymentSchema + , using = pure () + , deleteWhere = \() ds -> ds ^. #name ==. litExpr dName + , returning = pure () + } -- | Handles the 'clean-archive' request. cleanArchiveH :: AppM CommandResponse cleanArchiveH = do failIfGracefulShutdownActivated st <- ask - let pgPool = pool st - archRetention = unArchiveRetention . archiveRetention $ st - q = - "SELECT name FROM deployments \ - \WHERE status in ? AND archived_at + interval '?' second < now()" - retrieved :: [Only DeploymentName] <- liftIO $ - withResource pgPool $ \conn -> query conn q (In archivedStatuses, archRetention) + let archRetention = unArchiveRetention . archiveRetention $ st + cutoff <- liftBase getCurrentTime <&> addUTCTime (negate archRetention) + dNames <- runStatement $ + select $ do + ds <- each deploymentSchema + where_ $ (ds ^. #status) `in_` (litExpr <$> archivedStatuses) + where_ $ ds ^. #archivedAt <. litExpr (Just cutoff) + pure $ ds ^. #name runBgWorker . void $ - for retrieved $ \(Only dName) -> - runDeploymentBgWorker Nothing dName (pure ()) $ \() -> liftBase $ cleanupDeployment dName st + for dNames $ \dName -> + runDeploymentBgWorker Nothing dName (pure ()) $ \() -> cleanupDeployment dName pure Success @@ -1095,16 +930,15 @@ cleanArchiveH = do restoreH :: DeploymentName -> AppM CommandResponse restoreH dName = do failIfGracefulShutdownActivated - t1 <- liftIO $ now - st <- ask - dep <- selectDeployment dName - failIfImageNotFound dep + t1 <- liftBase getCurrentTime + dep <- getDeploymentS dName + failIfImageNotFound $ dep ^. #deployment failIfGracefulShutdownActivated runDeploymentBgWorker (Just CreatePending) dName (pure ()) $ \() -> do - dep' <- selectDeployment dName + dep' <- getDeploymentS dName updateDeploymentInfo dName - (ec, out, err) <- liftBase $ createDeployment dep' st - t2 <- liftBase now + (ec, out, err) <- createDeployment $ dep' ^. #deployment + t2 <- liftBase getCurrentTime let elTime = elapsedTime t2 t1 transitionToStatusS dName $ TransitionCreatePending @@ -1120,13 +954,12 @@ restoreH dName = do -- | Helper to get action info. getActionInfoH :: ActionId -> AppM ActionInfo getActionInfoH aId = do - st <- ask - let pgPool = pool st - aId' = Only . unActionId $ aId - q = "SELECT stdout, stderr FROM deployment_logs WHERE id = ?" - rows :: [(Text, Text)] <- liftIO $ - withResource pgPool $ \conn -> query conn q aId' - case rows of + rows' <- runStatement $ + select $ do + dls <- each deploymentLogSchema + where_ $ dls ^. #actionId ==. litExpr aId + pure (dls ^. #stdout, dls ^. #stderr) + case rows' of (out, err) : _ -> pure $ ActionInfo out err _ -> throwError err400 {errBody = appError "Action not found"} @@ -1138,8 +971,7 @@ handleExitCode (ExitFailure c) = liftBase . throwIO $ DeploymentFailed c -- | Helper to log a deployment action. createDeploymentLog :: - (MonadError ServerError m, MonadIO m) => - Connection -> + (MonadBaseControl IO m, MonadReader AppState m) => Deployment -> Action -> ExitCode -> @@ -1147,106 +979,67 @@ createDeploymentLog :: Stdout -> Stderr -> m () -createDeploymentLog conn dep act ec dur out err = do - let (Deployment dName dTag appOvs depOvs) = dep - exitCode' = - case ec of - ExitSuccess -> 0 - ExitFailure errCode -> errCode - dur' = unDuration dur - out' = unStdout out - err' = unStderr err - qInsertLog = - "INSERT INTO deployment_logs \ - \(deployment_id, action, tag, exit_code, \ - \duration, stdout, stderr) \ - \(\ - \SELECT id, ?, ?, ?, ?, ?, ? \ - \FROM deployments \ - \WHERE name = ? \ - \) RETURNING id" - qInsertLogOverride = - "INSERT INTO deployment_log_overrides \ - \(key, value, deployment_log_id, scope, visibility) \ - \VALUES (?, ?, ?, ?, ?)" - (Only aId) :: Only Int <- - liftIO - ( query - conn - qInsertLog - (act, dTag, exitCode', dur', out', err', dName) - ) - >>= ensureOne - void $ - for appOvs $ \o -> do - let oKey = overrideKey . unApplicationOverride $ o - oValue = overrideValue . unApplicationOverride $ o - oScope = show ApplicationScope - oVis = show . overrideVisibility . unApplicationOverride $ o - liftIO $ execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis) - void $ - for depOvs $ \o -> do - let oKey = overrideKey . unDeploymentOverride $ o - oValue = overrideValue . unDeploymentOverride $ o - oScope = show DeploymentScope - oVis = show . overrideVisibility . unDeploymentOverride $ o - liftIO $ execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis) +createDeploymentLog dep act ec dur out err = do + dId' <- runStatement $ + select $ do + ds <- each deploymentSchema + where_ $ ds ^. #name ==. litExpr (dep ^. #name) + pure $ ds ^. #id_ + for_ dId' $ \dId -> + runStatement $ + insert + Insert + { into = deploymentLogSchema + , rows = + values + [ DeploymentLogSchema + { actionId = unsafeDefault + , deploymentId = litExpr dId + , action = litExpr act + , deploymentTag = litExpr $ dep ^. #tag + , exitCode = litExpr $ case ec of + ExitSuccess -> 0 + ExitFailure errCode -> fromIntegral errCode + , createdAt = now + , archived = false + , duration = litExpr dur + , stdout = litExpr out + , stderr = litExpr err + , deploymentAppOverrides = litExpr $ dep ^. #appOverrides + , deploymentDepOverrides = litExpr $ dep ^. #deploymentOverrides + } + ] + , onConflict = Abort + , returning = pure () + } ensureOne :: MonadError ServerError m => [a] -> m a ensureOne [x] = return x ensureOne _ = throwError err500 --- | Helper to get deployment metadata from the database. -selectDeploymentMetadata :: - Connection -> - DeploymentName -> - IO [DeploymentMetadata] -selectDeploymentMetadata conn dName = do - let q = - "SELECT key, value FROM deployment_metadata \ - \WHERE deployment_id = (SELECT id FROM deployments WHERE name = ?) \ - \ORDER BY id ASC" - rows <- query conn q (Only dName) - for rows $ \(k, v) -> pure $ DeploymentMetadata k v - --- | Helper to delete deployment metadata. -deleteDeploymentMetadata :: - PgPool -> - DeploymentName -> - IO () -deleteDeploymentMetadata pgPool dName = do - let q = - "DELETE FROM deployment_metadata \ - \WHERE deployment_id = (SELECT id FROM deployments WHERE name = ?)" - void $ withResource pgPool $ \conn -> execute conn q (Only dName) - -- | Helper to insert or update deployment metadata. -upsertDeploymentMetadata :: - PgPool -> +upsertDeploymentMetadatum :: + (MonadBaseControl IO m, MonadReader AppState m) => DeploymentName -> - [DeploymentMetadata] -> - IO () -upsertDeploymentMetadata pgPool dName dMetadatas = do - withResource pgPool $ \conn -> withTransactionSerializable conn $ do - void $ - execute - conn - "DELETE FROM deployment_metadata \ - \WHERE deployment_id = (SELECT id FROM deployments WHERE name = ?)" - (Only dName) - forM_ dMetadatas $ \dMeta -> - execute - conn - "INSERT INTO deployment_metadata \ - \(deployment_id, key, value, created_at, updated_at) \ - \(SELECT id, ?, ?, now(), now() FROM deployments WHERE name = ?)" - (deploymentMetadataKey dMeta, deploymentMetadataValue dMeta, dName) + DeploymentMetadata -> + m () +upsertDeploymentMetadatum dName dMetadata = + runStatement $ + update + Update + { target = deploymentSchema + , from = pure () + , set = \() ds -> ds & #metadata .~ litExpr dMetadata + , updateWhere = \() ds -> ds ^. #name ==. litExpr dName + , returning = pure () + } -- | Checks the existence of a deployment tag. -- Returns 404 'Tag not found' response if the deployment tag doesn't exist. failIfImageNotFound :: Deployment -> AppM () failIfImageNotFound dep = do - (ec, _, _) <- runCommandArgs tagCheckingCommand =<< tagCheckCommandArgs dep + dCfg <- getDefaultConfig $ dep ^. #name + (ec, _, _) <- runCommandArgs tagCheckingCommand =<< tagCheckCommandArgs dCfg dep case ec of ExitSuccess -> pure () ExitFailure _ -> @@ -1267,59 +1060,61 @@ sendReloadEvent state = atomically $ writeTChan (eventSink state) FrontendPleaseUpdateEverything -- | Returns time delta between 2 timestamps. -elapsedTime :: Time -> Time -> Duration -elapsedTime t1 t2 = - Duration . fromIntegral . (`div` 1000000) . abs $ getTime t2 - getTime t1 +elapsedTime :: UTCTime -> UTCTime -> Duration +elapsedTime t1 t2 = Duration . calendarTimeTime $ t2 `diffUTCTime` t1 -- | Runs the status updater. runStatusUpdater :: AppState -> IO () runStatusUpdater state = do - let pgPool = pool state - interval = 30 :: Int - selectDeps = - "SELECT name, status::text, \ - \extract(epoch from now())::int - \ - \extract(epoch from status_updated_at)::int \ - \, tag \ - \FROM deployments \ - \WHERE checked_at < now() - interval '?' second AND status != 'Archived'" - updateCheckedAt = - "UPDATE deployments SET checked_at = now() WHERE name = ? AND status = ?" - logErr :: Text -> IO () - logErr = logWarning (logger state) + let interval = 15 :: NominalDiffTime + logErr :: MonadBase IO m => Text -> m () + logErr = liftBase . logWarning (logger state) forever $ do - rows :: [(DeploymentName, DeploymentStatus, Int, DeploymentTag)] <- liftIO $ - withResource pgPool $ \conn -> query conn selectDeps (Only interval) - let checkList :: [(DeploymentName, DeploymentStatus, Timestamp, DeploymentTag)] = - (\(n, s, t, dTag) -> (n, s, coerce t, dTag)) <$> rows - checkResult <- for checkList $ \(dName, dStatus, ts, _) -> do - let timeout = statusUpdateTimeout state - (ec, _, _) <- flip runReaderT state case dStatus of - ArchivePending -> runCommandArgs archiveCheckingCommand =<< archiveCheckArgs dName - _ -> do - liftBase (withResource pgPool $ \conn -> selectDeploymentIO conn dName) >>= \case - Right dep -> runCommandArgs checkingCommand =<< checkCommandArgs dep - Left err -> do - log <- asks logger - let err' = T.pack $ displayException $ err - liftIO $ logWarning log err' - pure (ExitFailure 1, Stdout "Didn't call script", Stderr err') - - pure (dName, statusTransition ec dStatus ts timeout, dStatus) - updated <- - for checkResult $ \(dName, transitionM, dStatus) -> - withResource pgPool $ \conn -> - case transitionM of - Nothing -> execute conn updateCheckedAt (dName, dStatus) $> False - Just transition -> - ($> True) $ - (flip runReaderT state . runExceptT . runExceptT) (transitionToStatus dName transition) >>= \case - Right (Right ()) -> void $ execute conn updateCheckedAt (dName, transitionStatus transition) - Left e -> logErr $ show' e - Right (Left e) -> logErr $ show' e - when (or updated) $ sendReloadEvent state - threadDelay 5000000 + currentTime <- liftBase getCurrentTime + let cutoff = addUTCTime (negate interval) currentTime + flip runReaderT state $ do + rows' <- runStatement . select $ do + ds <- each deploymentSchema + where_ $ ds ^. #checkedAt <. litExpr cutoff + where_ $ ds ^. #status /=. litExpr Archived + pure (ds ^. #name, ds ^. #status, now `diffTime` (ds ^. #statusUpdatedAt), ds ^. #tag) + checkResult <- for rows' $ \(dName, dStatus, Timestamp -> ts, _) -> do + let timeout = statusUpdateTimeout state + mEc <- case dStatus of + ArchivePending -> do + (ec, _, _) <- runCommandArgs archiveCheckingCommand =<< archiveCheckArgs dName + pure $ Just ec + _ -> do + getSingleFullInfo dName >>= \case + Nothing -> liftBase (logErr $ "Couldn't find deployment: " <> coerce dName) $> Nothing + Just dep -> do + dCfg <- getDefaultConfig dName + (ec, _, _) <- runCommandArgs checkingCommand =<< checkCommandArgs dCfg (dep ^. #deployment) + pure $ Just ec + pure $ mEc <&> \ec -> (dName, statusTransition ec dStatus ts timeout, dStatus) + updated <- + for (catMaybes checkResult) $ \(dName, transitionM, dStatus) -> + let updateCheckedAt s = + runStatement $ + update + Update + { target = deploymentSchema + , from = pure () + , set = \() ds -> ds & #checkedAt .~ now + , updateWhere = \() ds -> ds ^. #name ==. litExpr dName &&. ds ^. #status ==. litExpr s + , returning = pure () + } + in case transitionM of + Nothing -> updateCheckedAt dStatus $> False + Just transition -> + ($> True) $ + (runExceptT . runExceptT) (transitionToStatus dName transition) >>= \case + Right (Right ()) -> updateCheckedAt (transitionStatus transition) + Left e -> logErr $ show' e + Right (Left e) -> logErr $ show' e + when (Prelude.or updated) $ liftBase $ sendReloadEvent state + liftBase $ threadDelay 2000000 -- | Returns the new deployment status. statusTransition :: @@ -1332,10 +1127,10 @@ statusTransition ExitSuccess ArchivePending _ _ = Just TransitionArchived statusTransition ExitSuccess Running _ _ = Nothing statusTransition ExitSuccess _ _ _ = Just TransitionCreate statusTransition (ExitFailure n) Running _ _ = Just . TransitionFailure $ failureStatusType n -statusTransition (ExitFailure n) CreatePending ts timeout - | ts > coerce timeout = +statusTransition (ExitFailure n) CreatePending (Timestamp (CalendarDiffTime _ ts)) (Timeout (CalendarDiffTime _ timeout)) + | ts > timeout = Just . TransitionFailure $ failureStatusType n -statusTransition (ExitFailure n) UpdatePending ts timeout +statusTransition (ExitFailure n) UpdatePending (Timestamp (CalendarDiffTime _ ts)) (Timeout (CalendarDiffTime _ timeout)) | ts > coerce timeout = Just . TransitionFailure $ failureStatusType n statusTransition (ExitFailure _) _ _ _ = Nothing @@ -1433,3 +1228,24 @@ runDeploymentBgWorker newS dName pre post = do -- it should be fine. _ :: a <- restoreM stm return () + +extractDeploymentFullInfo :: + (MonadReader AppState m, MonadBase IO m) => + DeploymentSchema Result -> + m DeploymentFullInfo +extractDeploymentFullInfo d = do + let dName = d ^. #name + locked <- isDeploymentLocked dName + dCfg <- getDefaultConfig dName + pure + DeploymentFullInfo + { deployment = extractDeployment d + , status = + if locked + then DeploymentPending $ d ^. #status + else DeploymentNotPending $ d ^. #status + , metadata = d ^. #metadata + , createdAt = d ^. #createdAt + , updatedAt = d ^. #updatedAt + , deploymentDefaultConfig = dCfg + } diff --git a/octopod-backend/src/Octopod/Server/ControlScriptUtils.hs b/octopod-backend/src/Octopod/Server/ControlScriptUtils.hs index cbb7d23..c49b2ba 100644 --- a/octopod-backend/src/Octopod/Server/ControlScriptUtils.hs +++ b/octopod-backend/src/Octopod/Server/ControlScriptUtils.hs @@ -15,19 +15,18 @@ module Octopod.Server.ControlScriptUtils tagCheckCommandArgs, -- * Helpers - applicationOverrideToArg, - applicationOverridesToArgs, - deploymentOverrideToArg, - deploymentOverridesToArgs, + fullConfigArgs, + overridesArgs, ) where +import Control.Lens import Control.Monad.Base import Control.Monad.Reader import qualified Data.ByteString.Lazy as TL import Data.Coerce import Data.Generics.Product.Typed -import Data.Text (Text) +import qualified Data.Map.Ordered.Strict as MO import qualified Data.Text as T import qualified Data.Text.Encoding as T import Octopod.Server.Logger @@ -43,9 +42,10 @@ infoCommandArgs :: , HasType ProjectName r , HasType Domain r ) => + FullDefaultConfig -> Deployment -> m ControlScriptArgs -infoCommandArgs dep = do +infoCommandArgs dCfg dep = do (Namespace namespace) <- asks getTyped (ProjectName projectName) <- asks getTyped (Domain domain) <- asks getTyped @@ -58,12 +58,11 @@ infoCommandArgs dep = do , "--namespace" , T.unpack . coerce $ namespace , "--name" - , T.unpack . coerce $ name dep + , T.unpack . coerce $ dep ^. #name , "--tag" , T.unpack . coerce $ tag dep ] - <> getApplicationOverrideArgs dep - <> getDeploymentOverrideArgs dep + <> fullConfigArgs dCfg dep notificationCommandArgs :: ( MonadReader r m @@ -95,9 +94,9 @@ notificationCommandArgs dName dTag old new = do , "--tag" , T.unpack . coerce $ dTag , "--old-status" - , T.unpack $ deploymentStatusText old + , T.unpack $ deploymentStatusToText old , "--new-status" - , T.unpack $ deploymentStatusText new + , T.unpack $ deploymentStatusToText new ] checkCommandArgs :: @@ -106,9 +105,10 @@ checkCommandArgs :: , HasType ProjectName r , HasType Domain r ) => + FullDefaultConfig -> Deployment -> m ControlScriptArgs -checkCommandArgs dep = do +checkCommandArgs dCfg dep = do (Namespace namespace) <- asks getTyped (ProjectName projectName) <- asks getTyped (Domain domain) <- asks getTyped @@ -121,12 +121,11 @@ checkCommandArgs dep = do , "--namespace" , T.unpack . coerce $ namespace , "--name" - , T.unpack . coerce $ name dep + , T.unpack . coerce $ dep ^. #name , "--tag" , T.unpack . coerce $ tag dep ] - <> getApplicationOverrideArgs dep - <> getDeploymentOverrideArgs dep + <> fullConfigArgs dCfg dep tagCheckCommandArgs :: ( MonadReader r m @@ -134,9 +133,10 @@ tagCheckCommandArgs :: , HasType ProjectName r , HasType Domain r ) => + FullDefaultConfig -> Deployment -> m ControlScriptArgs -tagCheckCommandArgs dep = do +tagCheckCommandArgs dCfg dep = do (Namespace namespace) <- asks getTyped (ProjectName projectName) <- asks getTyped (Domain domain) <- asks getTyped @@ -149,12 +149,11 @@ tagCheckCommandArgs dep = do , "--namespace" , T.unpack . coerce $ namespace , "--name" - , T.unpack . coerce $ name dep + , T.unpack . coerce $ dep ^. #name , "--tag" , T.unpack . coerce $ tag dep ] - <> getApplicationOverrideArgs dep - <> getDeploymentOverrideArgs dep + <> fullConfigArgs dCfg dep archiveCheckArgs :: ( MonadReader r m @@ -219,27 +218,18 @@ runCommandWithoutPipes :: FilePath -> [String] -> IO ExitCode runCommandWithoutPipes cmd args = withProcessWait (proc cmd args) waitExitCode --- | Converts an application-level override list to command arguments. -applicationOverrideToArg :: ApplicationOverride -> [Text] -applicationOverrideToArg o = ["--app-env-override", overrideToArg . coerce $ o] +fullConfigArgs :: FullDefaultConfig -> Deployment -> ControlScriptArgs +fullConfigArgs defCfg dep = + overridesArgs (applyOverrides (dep ^. #appOverrides) (appDefaultConfig defCfg)) + <> overridesArgs (applyOverrides (dep ^. #deploymentOverrides) (depDefaultConfig defCfg)) --- | Helper to convert an application-level override to command arguments. -applicationOverridesToArgs :: ApplicationOverrides -> [Text] -applicationOverridesToArgs ovs = concat [applicationOverrideToArg o | o <- ovs] - -getApplicationOverrideArgs :: Deployment -> ControlScriptArgs -getApplicationOverrideArgs = - ControlScriptArgs . map T.unpack . applicationOverridesToArgs . appOverrides - --- | Converts a deployment-level override list to command arguments. -deploymentOverrideToArg :: DeploymentOverride -> [Text] -deploymentOverrideToArg o = - ["--deployment-override", overrideToArg . coerce $ o] - --- | Helper to convert a deployment-level override to command arguments. -deploymentOverridesToArgs :: DeploymentOverrides -> [Text] -deploymentOverridesToArgs ovs = concat [deploymentOverrideToArg o | o <- ovs] - -getDeploymentOverrideArgs :: Deployment -> ControlScriptArgs -getDeploymentOverrideArgs = - ControlScriptArgs . map T.unpack . deploymentOverridesToArgs . deploymentOverrides +overridesArgs :: forall l. KnownOverrideLevel l => Config l -> ControlScriptArgs +overridesArgs (Config cc) = + ControlScriptArgs + . concatMap (\(T.unpack -> k, T.unpack -> v) -> [argumentName, k <> "=" <> v]) + . MO.assocs + $ cc + where + argumentName = case knownOverrideLevel @l of + ApplicationLevel -> "--app-env-override" + DeploymentLevel -> "--deployment-override" diff --git a/octopod-backend/src/Orphans.hs b/octopod-backend/src/Orphans.hs index 264eef6..7b1e8fe 100644 --- a/octopod-backend/src/Orphans.hs +++ b/octopod-backend/src/Orphans.hs @@ -3,17 +3,48 @@ module Orphans () where import Common.Types -import Database.PostgreSQL.Simple.FromField (FromField) -import Database.PostgreSQL.Simple.ToField (ToField) +import Data.Maybe +import Rel8 -deriving newtype instance FromField Action +parseTypeInformationFromMapping :: (Eq a, Eq b, DBType b, Show b, Show a) => [(a, b)] -> TypeInformation a +parseTypeInformationFromMapping m = + parseTypeInformation + (\v -> maybe (Left $ "unknown value: " <> show v) Right . flip lookup reversedM $ v) + (\v -> fromMaybe (error $ "forgot case: " <> show v) . flip lookup m $ v) + typeInformation + where + reversedM = (\(x, y) -> (y, x)) <$> m -deriving newtype instance ToField Action +deriving via JSONBEncoded (Overrides l) instance (DBType (Overrides l)) -deriving newtype instance FromField DeploymentTag +deriving newtype instance DBType DeploymentId +deriving newtype instance DBEq DeploymentId -deriving newtype instance ToField DeploymentTag +deriving newtype instance DBType DeploymentName +deriving newtype instance DBEq DeploymentName -deriving newtype instance FromField DeploymentName +deriving newtype instance DBType DeploymentTag +deriving newtype instance DBEq DeploymentTag -deriving newtype instance ToField DeploymentName +instance DBType Action where + typeInformation = parseTypeInformationFromMapping actionText + +deriving newtype instance DBType ArchivedFlag + +deriving newtype instance DBType Duration + +deriving newtype instance DBType Timestamp + +deriving newtype instance DBType ProjectName + +deriving anyclass instance DBEq DeploymentStatus +instance DBType DeploymentStatus where + typeInformation = parseTypeInformationFromMapping deploymentStatusText + +deriving via JSONBEncoded DeploymentMetadata instance DBType DeploymentMetadata + +deriving newtype instance DBType Stdout +deriving newtype instance DBType Stderr + +deriving newtype instance DBType ActionId +deriving newtype instance DBEq ActionId diff --git a/octopod-backend/src/Types.hs b/octopod-backend/src/Types.hs index dddcc40..c8e6229 100644 --- a/octopod-backend/src/Types.hs +++ b/octopod-backend/src/Types.hs @@ -25,15 +25,16 @@ import Data.Text as T import Data.Traversable import Common.Types +import Data.Time -- | Parses deployment metadata. -parseDeploymentMetadata :: [Text] -> IO [DeploymentMetadata] -parseDeploymentMetadata texts = +parseDeploymentMetadata :: [Text] -> IO DeploymentMetadata +parseDeploymentMetadata texts = fmap DeploymentMetadata $ for texts $ \t -> case T.findIndex (== ',') t of Just i -> do let (key, value) = bimap strip (T.tail . strip) $ T.splitAt i t - pure $ DeploymentMetadata key value + pure $ DeploymentMetadatum key value Nothing -> error $ "Malformed metadata key-value pair " <> T.unpack t @@ -60,11 +61,11 @@ newtype Namespace = Namespace {unNamespace :: Text} deriving stock (Show) -- | Archive retention. -newtype ArchiveRetention = ArchiveRetention {unArchiveRetention :: Int} +newtype ArchiveRetention = ArchiveRetention {unArchiveRetention :: NominalDiffTime} deriving stock (Show) -- | Timeout. -newtype Timeout = Timeout {unTimeout :: Int} +newtype Timeout = Timeout {unTimeout :: CalendarDiffTime} deriving stock (Show) -- | Path to a deployment control script. diff --git a/octopod-common/octopod-common.cabal b/octopod-common/octopod-common.cabal index b01e763..e875cc0 100644 --- a/octopod-common/octopod-common.cabal +++ b/octopod-common/octopod-common.cabal @@ -18,6 +18,7 @@ library exposed-modules: Common.Types , Common.Utils , Common.Validation + , Data.Map.Ordered.Strict.Extra -- other-modules: -- other-extensions: default-extensions: BlockArguments @@ -35,6 +36,16 @@ library , TypeApplications , TypeOperators , ViewPatterns + , DeriveAnyClass + , OverloadedLabels + , StandaloneDeriving + , TypeSynonymInstances + , FlexibleInstances + , DataKinds + , KindSignatures + , EmptyDataDeriving + , TupleSections + , AllowAmbiguousTypes build-depends: base , aeson , bytestring @@ -43,6 +54,9 @@ library , http-api-data , lens , text + , containers + , time + , ordered-containers hs-source-dirs: src default-language: Haskell2010 ghc-options: @@ -56,3 +70,4 @@ library -Wno-missing-local-signatures -Wno-partial-fields -Wno-unsafe + -Wno-missed-specialisations diff --git a/octopod-common/src/Common/Types.hs b/octopod-common/src/Common/Types.hs index ca1bbf2..ecd83dd 100644 --- a/octopod-common/src/Common/Types.hs +++ b/octopod-common/src/Common/Types.hs @@ -7,55 +7,87 @@ -- This module contains common types between the backend and the frontend. module Common.Types where -import Data.Bifunctor -import Data.Coerce -import Data.String -import Data.Text as T hiding (filter) +import Control.Lens +import Data.Aeson hiding (Result) +import Data.Generics.Labels () +import Data.Int +import Data.Map.Ordered.Strict (OMap, (<>|)) +import qualified Data.Map.Ordered.Strict as OM +import Data.Map.Ordered.Strict.Extra () +import Data.Maybe +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time import Data.Traversable +import Deriving.Aeson import Deriving.Aeson.Stock import Web.HttpApiData --- | Deployment override. -data Override = Override - { overrideKey :: Text - , overrideValue :: Text - , overrideVisibility :: OverrideVisibility +data OverrideLevel = ApplicationLevel | DeploymentLevel + +class KnownOverrideLevel (l :: OverrideLevel) where + knownOverrideLevel :: OverrideLevel + +instance KnownOverrideLevel 'ApplicationLevel where + knownOverrideLevel = ApplicationLevel + +instance KnownOverrideLevel 'DeploymentLevel where + knownOverrideLevel = DeploymentLevel + +data OverrideValue = ValueAdded Text | ValueDeleted + deriving (ToJSON, FromJSON) via Snake OverrideValue + deriving stock (Eq, Ord, Show, Generic) + +newtype DefaultConfig (l :: OverrideLevel) = DefaultConfig (OMap Text Text) + deriving newtype (Eq, Ord, Show, ToJSON, FromJSON) + +newtype Config (l :: OverrideLevel) = Config {unConfig :: OMap Text Text} + deriving newtype (Eq, Ord, Show, ToJSON, FromJSON) + +data FullDefaultConfig = FullDefaultConfig + { appDefaultConfig :: DefaultConfig 'ApplicationLevel + , depDefaultConfig :: DefaultConfig 'DeploymentLevel } - deriving stock (Generic, Show, Eq) - deriving (FromJSON, ToJSON) via Snake Override + deriving stock (Show, Ord, Eq, Generic) + deriving (ToJSON, FromJSON) via Snake FullDefaultConfig -type Overrides = [Override] +data FullConfig = FullConfig + { appConfig :: Config 'ApplicationLevel + , depConfig :: Config 'DeploymentLevel + } + deriving stock (Show, Ord, Eq, Generic) + deriving (ToJSON, FromJSON) via Snake FullConfig --- | Deployment override scope. -data OverrideScope - = ApplicationScope - | DeploymentScope - deriving stock (Generic, Show, Read, Eq) - deriving (FromJSON, ToJSON) via Snake OverrideScope +applyOverrides :: Overrides l -> DefaultConfig l -> Config l +applyOverrides (Overrides oo) (DefaultConfig dd) = + Config . extract $ oo <>| (ValueAdded <$> dd) + where + extract :: OMap Text OverrideValue -> OMap Text Text + extract = + fmap + ( \case + ValueAdded v -> v + ValueDeleted -> error "invariant" + ) + . OM.filter + ( \_ -> \case + ValueAdded _ -> True + ValueDeleted -> False + ) --- | Deployment override visibility. -data OverrideVisibility - = Private - | Public - deriving stock (Generic, Show, Read, Eq) - deriving (FromJSON, ToJSON) via Snake OverrideVisibility +newtype Overrides (l :: OverrideLevel) = Overrides {unOverrides :: OMap Text OverrideValue} + deriving newtype (Eq, Ord, Show, ToJSON, FromJSON) --- | Deployment application-level override. -newtype ApplicationOverride = ApplicationOverride {unApplicationOverride :: Override} - deriving newtype (Show, Eq, FromJSON, ToJSON) +ov :: Text -> OverrideValue -> Overrides l +ov k v = Overrides $ OM.singleton (k, v) --- | Deployment application-level overrides. -type ApplicationOverrides = [ApplicationOverride] +instance Semigroup (Overrides l) where + (Overrides lhs) <> (Overrides rhs) = Overrides $ rhs <>| lhs --- | Deployment-level override. -newtype DeploymentOverride = DeploymentOverride - {unDeploymentOverride :: Override} - deriving newtype (Show, Eq, FromJSON, ToJSON) +instance Monoid (Overrides l) where + mempty = Overrides OM.empty --- | Deployment-level overrides. -type DeploymentOverrides = [DeploymentOverride] - -newtype DeploymentId = DeploymentId {unDeploymentId :: Int} +newtype DeploymentId = DeploymentId {unDeploymentId :: Int64} deriving stock (Show) newtype DeploymentName = DeploymentName {unDeploymentName :: Text} @@ -66,30 +98,47 @@ newtype DeploymentTag = DeploymentTag {unDeploymentTag :: Text} deriving newtype (Show, FromJSON, ToJSON, ToHttpApiData, FromHttpApiData, Eq) -newtype Action = Action {unAction :: Text} - deriving newtype (Show, FromJSON, ToJSON, IsString) +data Action = RestoreAction | ArchiveAction | UpdateAction | CreateAction + deriving stock (Show, Read, Eq, Ord, Generic) + deriving (FromJSON, ToJSON) via Snake Action + +actionText :: [(Action, Text)] +actionText = + [ (RestoreAction, "restore") + , (ArchiveAction, "archive") + , (UpdateAction, "update") + , (CreateAction, "create") + ] + +actionToText :: Action -> Text +actionToText k = fromMaybe (error $ "forgot case: " <> show k) . Prelude.lookup k $ actionText newtype ArchivedFlag = ArchivedFlag {unArchivedFlag :: Bool} deriving newtype (Show, FromJSON, ToJSON) -newtype Duration = Duration {unDuration :: Int} - deriving newtype (Show, FromJSON, ToJSON) +newtype Duration = Duration {unDuration :: CalendarDiffTime} + deriving newtype (Show, Eq, FromJSON, ToJSON, FormatTime) -newtype Timestamp = Timestamp {unTimestamp :: Int} - deriving newtype (Show, Eq, Ord, FromJSON, ToJSON) +newtype Timestamp = Timestamp {unTimestamp :: CalendarDiffTime} + deriving newtype (Show, Eq, FromJSON, ToJSON) newtype ProjectName = ProjectName {uProjectName :: Text} deriving newtype (Show, FromJSON, ToJSON) -deploymentStatusText :: DeploymentStatus -> Text -deploymentStatusText Running = "Running" -deploymentStatusText (Failure GenericFailure) = "GenericFailure" -deploymentStatusText (Failure TagMismatch) = "TagMismatch" -deploymentStatusText (Failure PartialAvailability) = "PartialAvailability" -deploymentStatusText CreatePending = "CreatePending" -deploymentStatusText UpdatePending = "UpdatePending" -deploymentStatusText ArchivePending = "ArchivePending" -deploymentStatusText Archived = "Archived" +deploymentStatusText :: [(DeploymentStatus, Text)] +deploymentStatusText = + [ (Running, "Running") + , (Failure GenericFailure, "GenericFailure") + , (Failure TagMismatch, "TagMismatch") + , (Failure PartialAvailability, "PartialAvailability") + , (CreatePending, "CreatePending") + , (UpdatePending, "UpdatePending") + , (ArchivePending, "ArchivePending") + , (Archived, "Archived") + ] + +deploymentStatusToText :: DeploymentStatus -> Text +deploymentStatusToText k = fromMaybe (error $ "forgot case: " <> show k) . Prelude.lookup k $ deploymentStatusText data DeploymentStatus = Running @@ -124,8 +173,8 @@ isArchivedStatus = (`elem` archivedStatuses) data Deployment = Deployment { name :: DeploymentName , tag :: DeploymentTag - , appOverrides :: ApplicationOverrides - , deploymentOverrides :: DeploymentOverrides + , appOverrides :: Overrides 'ApplicationLevel + , deploymentOverrides :: Overrides 'DeploymentLevel } deriving stock (Generic, Show, Eq) deriving (FromJSON, ToJSON) via Snake Deployment @@ -134,27 +183,30 @@ data DeploymentLog = DeploymentLog { actionId :: ActionId , action :: Action , deploymentTag :: DeploymentTag - , deploymentAppOverrides :: ApplicationOverrides - , deploymentDepOverrides :: DeploymentOverrides - , exitCode :: Int + , deploymentAppOverrides :: Overrides 'ApplicationLevel + , deploymentDepOverrides :: Overrides 'DeploymentLevel + , exitCode :: Int64 , duration :: Duration - , createdAt :: Int - } - deriving stock (Generic, Show) - deriving (FromJSON, ToJSON) via Snake DeploymentLog - -data DeploymentMetadata = DeploymentMetadata - { -- | The name of the link - deploymentMetadataKey :: Text - , -- | The URL - deploymentMetadataValue :: Text + , createdAt :: UTCTime } deriving stock (Generic, Show, Eq) - deriving (FromJSON, ToJSON) via Snake DeploymentMetadata + deriving (ToJSON, FromJSON) via Snake DeploymentLog + +newtype DeploymentMetadata = DeploymentMetadata {unDeploymentMetadata :: [DeploymentMetadatum]} + deriving newtype (Eq, Show, Ord, FromJSON, ToJSON) + +data DeploymentMetadatum = DeploymentMetadatum + { -- | The name of the link + name :: Text + , -- | The URL + link :: Text + } + deriving stock (Generic, Show, Eq, Ord) + deriving (FromJSON, ToJSON) via Snake DeploymentMetadatum data DeploymentInfo = DeploymentInfo { deployment :: Deployment - , metadata :: [DeploymentMetadata] + , metadata :: DeploymentMetadata , logs :: [DeploymentLog] } deriving stock (Generic, Show) @@ -163,13 +215,21 @@ data DeploymentInfo = DeploymentInfo data DeploymentFullInfo = DeploymentFullInfo { deployment :: Deployment , status :: PreciseDeploymentStatus - , metadata :: [DeploymentMetadata] - , createdAt :: Int - , updatedAt :: Int + , metadata :: DeploymentMetadata + , createdAt :: UTCTime + , updatedAt :: UTCTime + , deploymentDefaultConfig :: FullDefaultConfig } deriving stock (Generic, Show, Eq) deriving (FromJSON, ToJSON) via Snake DeploymentFullInfo +getDeploymentConfig :: DeploymentFullInfo -> FullConfig +getDeploymentConfig d = + FullConfig + { appConfig = applyOverrides (d ^. #deployment . #appOverrides) (d ^. #deploymentDefaultConfig . #appDefaultConfig) + , depConfig = applyOverrides (d ^. #deployment . #deploymentOverrides) (d ^. #deploymentDefaultConfig . #depDefaultConfig) + } + isDeploymentArchived :: DeploymentFullInfo -> Bool isDeploymentArchived DeploymentFullInfo {status = s} = case s of DeploymentNotPending s' -> isArchivedStatus s' @@ -179,27 +239,12 @@ isDeploymentArchived DeploymentFullInfo {status = s} = case s of data DeploymentUpdate = DeploymentUpdate { newTag :: DeploymentTag - , newAppOverrides :: ApplicationOverrides - , oldAppOverrides :: ApplicationOverrides - , newDeploymentOverrides :: DeploymentOverrides - , oldDeploymentOverrides :: DeploymentOverrides + , appOverrides :: Overrides 'ApplicationLevel + , deploymentOverrides :: Overrides 'DeploymentLevel } deriving stock (Generic, Show) deriving (FromJSON, ToJSON) via Snake DeploymentUpdate -applyDeploymentUpdate :: DeploymentUpdate -> Deployment -> Deployment -applyDeploymentUpdate du d = - Deployment - { name = name d - , tag = newTag du - , appOverrides = - filter (`notElem` oldAppOverrides du) (appOverrides d) - <> newAppOverrides du - , deploymentOverrides = - filter (`notElem` oldDeploymentOverrides du) (deploymentOverrides d) - <> newDeploymentOverrides du - } - data CurrentStatus = Ok | Error @@ -225,7 +270,7 @@ data WSEvent = FrontendPleaseUpdateEverything deriving stock (Generic, Show) deriving (FromJSON, ToJSON) via Snake WSEvent -newtype ActionId = ActionId {unActionId :: Int} +newtype ActionId = ActionId {unActionId :: Int64} deriving newtype (Show, Read, FromJSON, ToJSON, ToHttpApiData, FromHttpApiData, Eq) @@ -238,74 +283,36 @@ newtype Stderr = Stderr {unStderr :: Text} deriving (FromJSON, ToJSON) via Snake Stderr data ActionInfo = ActionInfo - { stdout :: Text - , stderr :: Text + { stdout :: Stdout + , stderr :: Stderr } deriving stock (Generic, Show) deriving (FromJSON, ToJSON) via Snake ActionInfo --- | Parses setting application-level overrides. -parseSetApplicationOverrides :: - OverrideVisibility -> - [Text] -> - IO [ApplicationOverride] -parseSetApplicationOverrides visibility texts = - coerce <$> parseSetOverrides visibility texts - --- | Parses setting deployment-level overrides. -parseSetDeploymentOverrides :: - OverrideVisibility -> - [Text] -> - IO [DeploymentOverride] -parseSetDeploymentOverrides visibility texts = - coerce <$> parseSetOverrides visibility texts - -- | Parses setting overrides. -parseSetOverrides :: OverrideVisibility -> [Text] -> IO [Override] -parseSetOverrides visibility texts = - for texts $ \t -> - case T.findIndex (== '=') t of - Just i -> do - let (key, value) = bimap strip (T.tail . strip) $ T.splitAt i t - pure $ Override key value visibility - Nothing -> - error $ - "Malformed override key-value pair " <> T.unpack t - <> ", should be similar to FOO=bar" +parseSetOverrides :: [Text] -> Either Text (Overrides l) +parseSetOverrides texts = do + pairs' <- for texts $ \text -> case parseSingleOverride text of + Just x -> Right x + Nothing -> + Left $ "Malformed override key-value pair " <> text <> ", should be similar to FOO=bar" + return . Overrides $ OM.fromList pairs' + where + parseSingleOverride :: Text -> Maybe (Text, OverrideValue) + parseSingleOverride t + | Just i <- T.findIndex (== '=') t = + let (key, value) = bimap T.strip (T.tail . T.strip) $ T.splitAt i t + in Just (key, ValueAdded value) + parseSingleOverride _ = Nothing --- | Parses unsetting application-level overrides. -parseUnsetApplicationOverrides :: - OverrideVisibility -> - [Text] -> - IO [ApplicationOverride] -parseUnsetApplicationOverrides visibility texts = - coerce <$> parseUnsetOverrides visibility texts +parseUnsetOverrides :: [Text] -> Overrides l +parseUnsetOverrides = Overrides . OM.fromList . fmap (,ValueDeleted) --- | Parses unsetting deployment-level overrides. -parseUnsetDeploymentOverrides :: - OverrideVisibility -> - [Text] -> - IO [DeploymentOverride] -parseUnsetDeploymentOverrides visibility texts = - coerce <$> parseUnsetOverrides visibility texts +formatOverrides :: Overrides l -> Text +formatOverrides = T.unlines . formatOverrides' --- | Parses unsetting overrides. -parseUnsetOverrides :: OverrideVisibility -> [Text] -> IO [Override] -parseUnsetOverrides visibility texts = - for texts $ \key -> - pure $ Override key "" visibility - --- | Creates pretty-printed text from override. -formatOverride :: Override -> Text -formatOverride o@(Override _ _ vis) = - overrideToArg o <> case vis of - Private -> " (" <> pack (show vis) <> ")" - Public -> mempty - --- | Creates pretty-printed texts from overrides. -formatOverrides :: Overrides -> Text -formatOverrides = T.unlines . fmap formatOverride - --- | Creates a CLI argument from an override. -overrideToArg :: Override -> Text -overrideToArg (Override k v _) = k <> "=" <> v +formatOverrides' :: Overrides l -> [Text] +formatOverrides' (Overrides m) = fmap (\(k, v) -> k <> "=" <> showValue v) . OM.assocs $ m + where + showValue (ValueAdded v) = v + showValue ValueDeleted = "" diff --git a/octopod-common/src/Common/Utils.hs b/octopod-common/src/Common/Utils.hs index 9344d21..e3aac2c 100644 --- a/octopod-common/src/Common/Utils.hs +++ b/octopod-common/src/Common/Utils.hs @@ -36,7 +36,7 @@ dfiName :: (DeploymentName -> f DeploymentName) -> DeploymentFullInfo -> f DeploymentFullInfo -dfiName = field @"deployment" . field @"name" +dfiName = field @"deployment" . field' @"name" -- | Checks that deployment status is pending. isPending :: DeploymentStatus -> Bool diff --git a/octopod-common/src/Data/Map/Ordered/Strict/Extra.hs b/octopod-common/src/Data/Map/Ordered/Strict/Extra.hs new file mode 100644 index 0000000..19021b8 --- /dev/null +++ b/octopod-common/src/Data/Map/Ordered/Strict/Extra.hs @@ -0,0 +1,15 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +module Data.Map.Ordered.Strict.Extra + ( + ) +where + +import Data.Aeson +import Data.Map.Ordered.Strict + +instance (ToJSON k, ToJSON v) => ToJSON (OMap k v) where + toJSON = toJSON . assocs + +instance (FromJSON k, FromJSON v, Ord k) => FromJSON (OMap k v) where + parseJSON = fmap fromList . parseJSON diff --git a/octopod-frontend/octopod-frontend.cabal b/octopod-frontend/octopod-frontend.cabal index 8f38122..ba6c192 100644 --- a/octopod-frontend/octopod-frontend.cabal +++ b/octopod-frontend/octopod-frontend.cabal @@ -83,6 +83,7 @@ executable frontend , FunctionalDependencies , AllowAmbiguousTypes , OverloadedLabels + , ViewPatterns build-depends: aeson , base , bytestring @@ -104,5 +105,6 @@ executable frontend , mtl , semialign , these + , ordered-containers hs-source-dirs: src default-language: Haskell2010 diff --git a/octopod-frontend/src/Frontend/Utils.hs b/octopod-frontend/src/Frontend/Utils.hs index e8ac99a..69fd78e 100644 --- a/octopod-frontend/src/Frontend/Utils.hs +++ b/octopod-frontend/src/Frontend/Utils.hs @@ -8,15 +8,23 @@ --frontend modules. module Frontend.Utils where +import Common.Types as CT import Control.Lens import Control.Monad +import Control.Monad.Reader +import Data.Functor import Data.Generics.Labels () +import qualified Data.List as L import Data.Map (Map) +import qualified Data.Map as M +import qualified Data.Map.Ordered.Strict as OM import Data.Maybe (fromMaybe) +import Data.Monoid import Data.Proxy (Proxy (..)) -import Data.Text as T (Text, pack) +import Data.Text as T (Text, null, pack) import Data.Time import Data.Time.Clock.POSIX +import Frontend.GHCJS import GHCJS.DOM import GHCJS.DOM.Element as DOM import GHCJS.DOM.EventM (on, target) @@ -24,10 +32,6 @@ import GHCJS.DOM.GlobalEventHandlers as Events (click) import GHCJS.DOM.Node as DOM import Reflex.Dom as R -import Common.Types as CT -import Control.Monad.Reader -import Frontend.GHCJS - -- | Wrapper for @Maybe DOM.Element@. It's used by 'elementClick'. newtype ClickedElement = ClickedElement {unClickedElement :: Maybe DOM.Element} @@ -374,18 +378,13 @@ elDynAttrWithModifyConfig' f elementTag attrs child = do pure result -- | Formats posix seconds to date in iso8601. -formatPosixToDate :: Int -> Text -formatPosixToDate = - pack - . formatTime defaultTimeLocale (iso8601DateFormat Nothing) - . intToUTCTime +formatPosixToDate :: FormatTime t => t -> Text +formatPosixToDate = pack . formatTime defaultTimeLocale (iso8601DateFormat Nothing) -- | Formats posix seconds to date in iso8601 with time. -formatPosixToDateTime :: Int -> Text +formatPosixToDateTime :: FormatTime t => t -> Text formatPosixToDateTime = - pack - . formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S")) - . intToUTCTime + pack . formatTime defaultTimeLocale (iso8601DateFormat (Just "%H:%M:%S")) -- | Widget displaying the current deployment status. statusWidget :: MonadWidget t m => Dynamic t PreciseDeploymentStatus -> m () @@ -487,9 +486,9 @@ errorCommonWidget = overridesWidget :: MonadWidget t m => -- | List of overrides. - Overrides -> + Overrides l -> m () -overridesWidget envs = divClass "listing listing--for-text" $ do +overridesWidget (Overrides (OM.assocs -> envs)) = divClass "listing listing--for-text" $ do let visible = take 3 envs envLength = length envs listing visible @@ -512,10 +511,12 @@ overridesWidget envs = divClass "listing listing--for-text" $ do blank where listing envs' = do - forM_ envs' $ \(Override var val _) -> + forM_ envs' $ \(var, val) -> divClass "listing__item" $ do el "b" $ text $ var <> ": " - text val + case val of + ValueAdded v -> text v + ValueDeleted -> el "i" $ text "" -- | @if-then-else@ helper for cases when bool value is wrapped in 'Dynamic'. ifThenElseDyn :: @@ -572,3 +573,87 @@ kubeDashboardUrl deploymentInfo = do template <- asks kubernetesDashboardUrlTemplate let name = unDeploymentName . view (#deployment . #name) <$> deploymentInfo return $ name <&> (\n -> (<> n) <$> template) + +-- | Widget with override fields. This widget supports adding and +-- removing key-value pairs. +envVarsInput :: + forall l t m. + MonadWidget t m => + -- | Overrides header. + Text -> + -- | Current deployment overrides. + Overrides l -> + -- | Updated deployment overrides. + m (Dynamic t (Overrides l)) +envVarsInput overridesHeader (Overrides evs) = do + elClass "section" "deployment__section" $ do + elClass "h3" "deployment__sub-heading" $ text overridesHeader + elClass "div" "deployment__widget" $ + elClass "div" "overrides" $ mdo + let initEnvs = + L.foldl' + ( \m -> \case + (k, ValueAdded v) -> fst $ insertUniq (k, v) m + (_, ValueDeleted) -> m + ) + emptyUniqKeyMap + . OM.assocs + $ evs + toOverrides :: [Override] -> Overrides l + toOverrides = Overrides . OM.fromList . (fmap . fmap) ValueAdded + emptyVar = ("", "") + addEv = clickEv $> Endo (fst . insertUniq emptyVar) + envsDyn <- foldDyn appEndo initEnvs $ leftmost [addEv, updEv] + (_, updEv) <- runEventWriterT $ listWithKey (uniqMap <$> envsDyn) envVarInput + let addingIsEnabled = all ((not . T.null) . fst) . elemsUniq <$> envsDyn + clickEv <- + buttonClassEnabled' + "overrides__add dash dash--add" + "Add an override" + addingIsEnabled + "dash--disabled" + pure $ toOverrides . elemsUniq <$> envsDyn + +-- | Widget for entering a key-value pair. The updated overrides list is +-- written to the 'EventWriter'. +envVarInput :: + (EventWriter t (Endo (UniqKeyMap Override)) m, MonadWidget t m) => + -- | Index of variable in overrides list. + Int -> + -- | Current variable key and value. + Dynamic t Override -> + m () +envVarInput i epDyn = do + ep <- sample $ current epDyn + divClass "overrides__item" $ do + (keyDyn, _) <- + octopodTextInput' "overrides__key" "key" (Just $ fst ep) never + (valDyn, _) <- + octopodTextInput' "overrides__value" "value" (Just $ snd ep) never + closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete" + let envEv = updated $ zipDynWith (,) keyDyn valDyn + deleteEv = Endo (deleteUniq i) <$ closeEv + updEv = Endo . updateUniq i . const <$> envEv + tellEvent $ leftmost [deleteEv, updEv] + +data UniqKeyMap v = UniqKeyMap (Map Int v) Int + +uniqMap :: UniqKeyMap v -> Map Int v +uniqMap (UniqKeyMap m _) = m + +insertUniq :: v -> UniqKeyMap v -> (UniqKeyMap v, Int) +insertUniq v (UniqKeyMap m x) = (UniqKeyMap (M.insert x v m) (x + 1), x) + +deleteUniq :: Int -> UniqKeyMap v -> UniqKeyMap v +deleteUniq k (UniqKeyMap m x) = UniqKeyMap (M.delete k m) x + +updateUniq :: Int -> (v -> v) -> UniqKeyMap v -> UniqKeyMap v +updateUniq k f (UniqKeyMap m x) = UniqKeyMap (M.adjust f k m) x + +elemsUniq :: UniqKeyMap v -> [v] +elemsUniq (UniqKeyMap m _) = M.elems m + +emptyUniqKeyMap :: UniqKeyMap v +emptyUniqKeyMap = UniqKeyMap mempty 0 + +type Override = (Text, Text) diff --git a/octopod-frontend/src/Main.hs b/octopod-frontend/src/Main.hs index 60de8a4..47400e6 100644 --- a/octopod-frontend/src/Main.hs +++ b/octopod-frontend/src/Main.hs @@ -22,6 +22,7 @@ import Frontend.Utils (errorCommonWidget, loadingCommonWidget) import Page.Deployment import Page.Deployments + main :: IO () main = mdo mainWidgetWithHead' diff --git a/octopod-frontend/src/Page/Deployment.hs b/octopod-frontend/src/Page/Deployment.hs index 10f83e6..c72717e 100644 --- a/octopod-frontend/src/Page/Deployment.hs +++ b/octopod-frontend/src/Page/Deployment.hs @@ -1,82 +1,89 @@ -{-| -Module : Page.Deployment -Description : Deployment page. - -This module contains the definition of a deployment page. --} - +-- | +--Module : Page.Deployment +--Description : Deployment page. +-- +--This module contains the definition of a deployment page. module Page.Deployment (deploymentPage) where -import Control.Lens -import Control.Monad -import Data.Coerce -import Data.Generics.Product (field) -import Data.Text as T (Text) -import Obelisk.Route.Frontend -import Reflex.Dom as R -import Servant.Reflex +import Control.Lens +import Control.Monad +import Data.Coerce +import Data.Generics.Product (field) +import Data.Text as T (Text, pack) +import Obelisk.Route.Frontend +import Reflex.Dom as R +import Servant.Reflex -import Common.Types as CT -import Common.Utils -import Control.Monad.Reader -import Data.Align -import Data.Generics.Labels () -import Frontend.API -import Frontend.GHCJS -import Frontend.Route -import Frontend.Utils -import Page.ClassicPopup -import Page.Elements.Links -import Page.Popup.EditDeployment -import Servant.Reflex.Extra +import Common.Types as CT +import Common.Utils +import Control.Monad.Reader +import Data.Align +import Data.Generics.Labels () +import qualified Data.Map.Ordered.Strict as OM +import Data.Time +import Frontend.API +import Frontend.GHCJS +import Frontend.Route +import Frontend.Utils +import Page.ClassicPopup +import Page.Elements.Links +import Page.Popup.EditDeployment +import Servant.Reflex.Extra -- | The root widget of a deployment page. It requests the deployment data. -- If the request fails it shows an error, -- otherwise it calls 'deploymentWidget', passing the received data. -deploymentPage - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m - , MonadReader ProjectConfig m - ) - => Event t () -- ^ Event notifying about the need to update data. - -> DeploymentName -- ^ Name of current deployment. - -> m () +deploymentPage :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + , MonadReader ProjectConfig m + ) => + -- | Event notifying about the need to update data. + Event t () -> + -- | Name of current deployment. + DeploymentName -> + m () deploymentPage updAllEv dname = do pb <- getPostBuild respEv <- fullInfoEndpoint (constDyn $ Right dname) pb let (okEv, errEv) = processResp respEv - widgetHold_ (loadingWidget dname) $ leftmost - [ errorWidget dname <$ errEv - , deploymentWidget updAllEv <$> okEv ] + widgetHold_ (loadingWidget dname) $ + leftmost + [ errorWidget dname <$ errEv + , deploymentWidget updAllEv <$> okEv + ] -- | Deployment page widget that takes the initial deployment data. -- It updates this data every time when the passed event fires. -- If an update fails, a notification widget appears at the top of the page. -deploymentWidget - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m - , MonadReader ProjectConfig m - ) - => Event t () -- ^ Event notifying about the need to update data. - -> DeploymentFullInfo -- ^ Initial deployment data. - -> m () +deploymentWidget :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + , MonadReader ProjectConfig m + ) => + -- | Event notifying about the need to update data. + Event t () -> + -- | Initial deployment data. + DeploymentFullInfo -> + m () deploymentWidget updEv dfi = mdo editEv <- pageWrapper $ mdo retryEv <- delay 10 errEv - respEv <- fullInfoEndpoint (constDyn $ Right $ dfi ^. dfiName) - $ leftmost [ updEv, retryEv ] + respEv <- + fullInfoEndpoint (constDyn $ Right $ dfi ^. dfiName) $ + leftmost [updEv, retryEv] let (okEv, errEv) = processResp respEv dfiDyn <- holdDyn dfi okEv editEv' <- deploymentHead dfiDyn sentEv - pageNotification $ leftmost - [ DPMError "Couldn't update status of deployment" <$ errEv - , DPMClear <$ okEv ] + pageNotification $ + leftmost + [ DPMError "Couldn't update status of deployment" <$ errEv + , DPMClear <$ okEv + ] deploymentBody updEv dfiDyn pure editEv' sentEv <- editDeploymentPopup editEv never @@ -89,51 +96,62 @@ deploymentWidget updEv dfi = mdo -- and \"edit deployment\" buttons. -- If the status is pending (\"Creating\", \"Updating\", etc) -- then all buttons are inactive. -deploymentHead - :: (MonadWidget t m, MonadReader ProjectConfig m) - => Dynamic t DeploymentFullInfo - -- ^ Deployment data. - -> Event t Bool - -- ^ Event with a flag showing the current state of the request. - -> m (Event t DeploymentFullInfo) - -- ^ \"Edit\" event. +deploymentHead :: + (MonadWidget t m, MonadReader ProjectConfig m) => + -- | Deployment data. + Dynamic t DeploymentFullInfo -> + -- | Event with a flag showing the current state of the request. + Event t Bool -> + -- | \"Edit\" event. + m (Event t DeploymentFullInfo) deploymentHead dfiDyn sentEv = divClass "page__head" $ do let dname = dfiDyn <^.> dfiName . coerced elClass "h1" "page__heading title" $ dynText dname - (editEv, archEv) <- hold2 . dyn $ dfiDyn <&> \dfi -> if isDeploymentArchived dfi - then mdo - let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status" - btnEnabledDyn <- holdDyn btnState $ leftmost [ False <$ btnEv, sentEv ] - btnEv <- aButtonClassEnabled - "page__action button button--secondary button--restore \ - \classic-popup-handler" - "Recover from archive" - btnEnabledDyn - void $ restoreEndpoint (Right . coerce <$> dname) btnEv - pure (never, never) - else mdo - let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status" - btnEnabledDyn <- holdDyn btnState $ not <$> sentEv - editEv <- buttonClassEnabled' - "page__action button button--edit popup-handler" - "Edit deployment" - btnEnabledDyn - "button--disabled" - archEv <- buttonClassEnabled' - "page__action button button--secondary button--archive \ - \classic-popup-handler" - "Move to archive" - btnEnabledDyn - "button--disabled" - pure (R.tag (current dfiDyn) editEv, archEv) + (editEv, archEv) <- + hold2 . dyn $ + dfiDyn <&> \dfi -> + if isDeploymentArchived dfi + then mdo + let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status" + btnEnabledDyn <- holdDyn btnState $ leftmost [False <$ btnEv, sentEv] + btnEv <- + aButtonClassEnabled + "page__action button button--secondary button--restore \ + \classic-popup-handler" + "Recover from archive" + btnEnabledDyn + void $ restoreEndpoint (Right . coerce <$> dname) btnEv + pure (never, never) + else mdo + let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status" + btnEnabledDyn <- holdDyn btnState $ not <$> sentEv + editEv <- + buttonClassEnabled' + "page__action button button--edit popup-handler" + "Edit deployment" + btnEnabledDyn + "button--disabled" + archEv <- + buttonClassEnabled' + "page__action button button--secondary button--archive \ + \classic-popup-handler" + "Move to archive" + btnEnabledDyn + "button--disabled" + pure (R.tag (current dfiDyn) editEv, archEv) url' <- kubeDashboardUrl dfiDyn - void . dyn $ url' <&> maybe blank (\url -> - void $ aButtonDynClass' - "page__action button button--secondary button--logs" - "Details" - (pure $ "href" =: url <> "target" =: "_blank") - ) + void . dyn $ + url' + <&> maybe + blank + ( \url -> + void $ + aButtonDynClass' + "page__action button button--secondary button--logs" + "Details" + (pure $ "href" =: url <> "target" =: "_blank") + ) delEv <- confirmArchivePopup archEv $ do text "Are you sure you want to archive the" el "br" blank @@ -142,10 +160,10 @@ deploymentHead dfiDyn sentEv = void $ archiveEndpoint (Right . coerce <$> dname) delEv return editEv -hold2 - :: (MonadHold t m, Reflex t) - => m (Event t (Event t a, Event t b)) - -> m (Event t a, Event t b) +hold2 :: + (MonadHold t m, Reflex t) => + m (Event t (Event t a, Event t b)) -> + m (Event t a, Event t b) hold2 = (>>= fmap fanThese . switchHold never . fmap (uncurry align)) -- | Div wrappers. @@ -153,15 +171,16 @@ deploymentBodyWrapper :: MonadWidget t m => m a -> m a deploymentBodyWrapper m = divClass "page__body" $ divClass "deployment" $ m -- | Body of a deployment page. -deploymentBody - :: MonadWidget t m - => Event t () - -- ^ Event notifying about the need to update data. - -> Dynamic t DeploymentFullInfo - -- ^ Deployment data. - -> m () +deploymentBody :: + MonadWidget t m => + -- | Event notifying about the need to update data. + Event t () -> + -- | Deployment data. + Dynamic t DeploymentFullInfo -> + m () deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do let nameDyn = dfiDyn <^.> dfiName + cfg = dfiDyn <&> getDeploymentConfig divClass "deployment__summary" $ do divClass "deployment__stat" $ do elClass "b" "deployment__param" $ text "Status" @@ -182,20 +201,16 @@ deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do elClass "h3" "deployment__sub-heading" $ text "Tag" divClass "deployment__widget" $ dynText tagDyn elClass "section" "deployment__section" $ do - let urlsDyn = dfiDyn <^.> field @"metadata" + let urlsDyn = dfiDyn <^.> field @"metadata" . to unDeploymentMetadata elClass "h3" "deployment__sub-heading" $ text "Links" divClass "deployment__widget" $ divClass "listing" $ void $ simpleList urlsDyn renderMetadataLink elClass "section" "deployment__section" $ do - let - envsDyn = dfiDyn <^.> field @"deployment" - . field @"appOverrides" . coerced + let envsDyn = cfg <^.> #appConfig allEnvsWidget "App overrides" envsDyn elClass "section" "deployment__section" $ do - let - envsDyn = dfiDyn <^.> field @"deployment" - . field @"deploymentOverrides" . coerced + let envsDyn = cfg <^.> #depConfig allEnvsWidget "Deployment overrides" envsDyn elClass "section" "deployment__section" $ do elClass "h3" "deployment__sub-heading" $ text "Actions" @@ -204,46 +219,49 @@ deploymentBody updEv dfiDyn = deploymentBodyWrapper $ do actionsTable updEv nameDyn -- | Widget that shows overrides list. It does not depend on their type. -allEnvsWidget - :: MonadWidget t m - => Text -- ^ Widget header. - -> Dynamic t Overrides -- ^ Overrides list. - -> m () +allEnvsWidget :: + MonadWidget t m => + -- | Widget header. + Text -> + -- | Overrides list. + Dynamic t (Config l) -> + m () allEnvsWidget headerText envsDyn = do elClass "h3" "deployment__sub-heading" $ text headerText divClass "deployment__widget" $ divClass "listing listing--for-text listing--larger" $ - void $ simpleList envsDyn $ \envDyn -> do - let - varDyn = overrideKey <$> envDyn - valDyn = overrideValue <$> envDyn - divClass "listing__item" $ do - el "b" $ do - dynText varDyn - text ": " - dynText valDyn - + void $ + simpleList (OM.assocs . unConfig <$> envsDyn) $ \envDyn -> do + let varDyn = fst <$> envDyn + valDyn = snd <$> envDyn + divClass "listing__item" $ do + el "b" $ do + dynText varDyn + text ": " + dynText valDyn -- ^ Widget with a table of actions that can be performed on a deployment. -- It requests deployment data. -- If a request fails it shows an error message, -- otherwise it calls 'actionsTableData', passing the received data. -actionsTable - :: MonadWidget t m - => Event t () - -- ^ Event notifying about the need to update data. - -> Dynamic t DeploymentName - -> m () + +actionsTable :: + MonadWidget t m => + -- | Event notifying about the need to update data. + Event t () -> + Dynamic t DeploymentName -> + m () actionsTable updEv nameDyn = do pb <- getPostBuild respEv <- infoEndpoint (Right <$> nameDyn) pb - let - okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv - errEv = fmapMaybe reqErrorBody respEv + let okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv + errEv = fmapMaybe reqErrorBody respEv el "table" $ do actionsTableHead - widgetHold_ actionsTableLoading $ leftmost - [ actionsTableError <$ errEv - , actionsTableData updEv nameDyn <$> okEv ] + widgetHold_ actionsTableLoading $ + leftmost + [ actionsTableError <$ errEv + , actionsTableData updEv nameDyn <$> okEv + ] -- | Header of the actions table. actionsTableHead :: MonadWidget t m => m () @@ -268,7 +286,7 @@ actionsTableLoading = do text "Loading..." -- | Widget with an error message for the actions table. -actionsTableError:: MonadWidget t m => m () +actionsTableError :: MonadWidget t m => m () actionsTableError = do el "tbody" $ elClass "tr" "no-table" $ @@ -279,73 +297,68 @@ actionsTableError = do -- | Actions table body. -- It updates data every time when the supplied event fires. -actionsTableData - :: MonadWidget t m - => Event t () - -- ^ Event notifying about the need to update data. - -> Dynamic t DeploymentName - -> [DeploymentLog] - -- ^ Initial logs. - -> m () +actionsTableData :: + MonadWidget t m => + -- | Event notifying about the need to update data. + Event t () -> + Dynamic t DeploymentName -> + -- | Initial logs. + [DeploymentLog] -> + m () actionsTableData updEv nameDyn initLogs = do respEv <- infoEndpoint (Right <$> nameDyn) updEv - let - okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv + let okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv logsDyn <- holdDyn initLogs okEv el "tbody" $ - void $ simpleList logsDyn $ \logDyn -> do - dyn_ $ actinRow <$> logDyn + void $ + simpleList logsDyn $ \logDyn -> do + dyn_ $ actinRow <$> logDyn -- | Data row of the actions table. actinRow :: MonadWidget t m => DeploymentLog -> m () -actinRow DeploymentLog{..} = do +actinRow DeploymentLog {..} = do el "tr" $ do el "td" $ do - text $ coerce action - let - statusClass = "status " <> - if exitCode == 0 then "status--success" else "status--failure" + text $ actionToText action + let statusClass = + "status " + <> if exitCode == 0 then "status--success" else "status--failure" divClass statusClass blank el "td" $ text $ coerce deploymentTag - el "td" $ overridesWidget $ coerce $ deploymentAppOverrides - el "td" $ overridesWidget $ coerce $ deploymentDepOverrides + el "td" $ overridesWidget $ deploymentAppOverrides + el "td" $ overridesWidget $ deploymentDepOverrides el "td" $ text $ showT $ exitCode el "td" $ text $ formatPosixToDateTime createdAt el "td" $ text $ formatDuration duration +-- | Formats posix seconds to date in iso8601 with time. +formatDuration :: FormatTime t => t -> Text +formatDuration = pack . formatTime defaultTimeLocale "%mm %Ss" + -- | Convert the duration of an action from milliseconds -- to a human readable format. -formatDuration - :: Duration -- ^ Duration in milliseconds. - -> Text -formatDuration (Duration d) = m <> "m " <> s <> "s" - where - m = showT $ d `div` (1000 * 60) - s = showT $ d `div` (1000) - -- | Widget with a button that returns to deployments list page. -backButton - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m ) - => m () +backButton :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + ) => + m () backButton = do - let - backRoute = constDyn $ DashboardRoute :/ Nothing - attrs = constDyn $ "class" =: "page__back dash dash--back dash--smaller" + let backRoute = constDyn $ DashboardRoute :/ Nothing + attrs = constDyn $ "class" =: "page__back dash dash--back dash--smaller" routeLinkDynAttr attrs backRoute $ text "All deployments" -- | Widget with a loading spinner. -loadingWidget - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m) - => DeploymentName - -> m () +loadingWidget :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + ) => + DeploymentName -> + m () loadingWidget dname = pageWrapper $ do divClass "page__head" $ elClass "h1" "page__heading title" $ text $ coerce dname @@ -354,14 +367,14 @@ loadingWidget dname = pageWrapper $ do loadingCommonWidget -- | Widget with an error placeholder. -errorWidget - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m) - => DeploymentName - -> m () +errorWidget :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + ) => + DeploymentName -> + m () errorWidget dname = pageWrapper $ do divClass "page__head" $ elClass "h1" "page__heading title" $ text $ coerce dname @@ -370,14 +383,15 @@ errorWidget dname = pageWrapper $ do errorCommonWidget -- | Div wrappers. -pageWrapper - :: - ( MonadWidget t m - , RouteToUrl (R Routes) m - , SetRoute t (R Routes) m - , Prerender js t m) - => m a - -> m a -pageWrapper m = divClass "page" $ divClass "page__wrap container" $ do - backButton - m +pageWrapper :: + ( MonadWidget t m + , RouteToUrl (R Routes) m + , SetRoute t (R Routes) m + , Prerender js t m + ) => + m a -> + m a +pageWrapper m = divClass "page" $ + divClass "page__wrap container" $ do + backButton + m diff --git a/octopod-frontend/src/Page/Deployments.hs b/octopod-frontend/src/Page/Deployments.hs index d3d96ec..270706e 100644 --- a/octopod-frontend/src/Page/Deployments.hs +++ b/octopod-frontend/src/Page/Deployments.hs @@ -292,7 +292,9 @@ activeDeploymentWidget clickedEv dDyn' = do el "td" $ do name statusWidget $ constDyn status - el "td" $ divClass "listing" $ forM_ metadata (renderMetadataLink . pure) + el "td" $ + divClass "listing" $ + forM_ (unDeploymentMetadata metadata) (renderMetadataLink . pure) el "td" tag' el "td" $ overridesWidget $ deployment ^. field @"appOverrides" . coerced diff --git a/octopod-frontend/src/Page/Elements/Links.hs b/octopod-frontend/src/Page/Elements/Links.hs index 83c131c..4373dcb 100644 --- a/octopod-frontend/src/Page/Elements/Links.hs +++ b/octopod-frontend/src/Page/Elements/Links.hs @@ -1,23 +1,26 @@ module Page.Elements.Links - ( renderMetadataLink - ) where + ( renderMetadataLink, + ) +where -import Common.Types -import Data.Functor +import Common.Types +import Control.Lens import qualified Data.Text as T -import Reflex.Dom +import Reflex.Dom -renderMetadataLink - :: (DomBuilder t m, PostBuild t m) - => Dynamic t DeploymentMetadata -> m () +renderMetadataLink :: + (DomBuilder t m, PostBuild t m) => + Dynamic t DeploymentMetadatum -> + m () renderMetadataLink metadataD = do - let - attrDyn = metadataD <&> \metadata -> - "class" =: "listing__item external bar bar--larger" - <> "href" =: deploymentMetadataValue metadata - <> "target" =: "_blank" - elDynAttr "a" attrDyn . dynText $ metadataD <&> \case - -- If the name is empty, then use the url - DeploymentMetadata {deploymentMetadataKey = name} - | (not . T.null . T.strip) name -> name - DeploymentMetadata {deploymentMetadataValue = url} -> url + let attrDyn = + metadataD <&> \metadata -> + "class" =: "listing__item external bar bar--larger" + <> "href" =: metadata ^. #link + <> "target" =: "_blank" + elDynAttr "a" attrDyn . dynText $ + metadataD <&> \case + -- If the name is empty, then use the url + DeploymentMetadatum {name = name} + | (not . T.null . T.strip) name -> name + DeploymentMetadatum {link = url} -> url diff --git a/octopod-frontend/src/Page/Popup/EditDeployment.hs b/octopod-frontend/src/Page/Popup/EditDeployment.hs index 78e0b97..f1ff79f 100644 --- a/octopod-frontend/src/Page/Popup/EditDeployment.hs +++ b/octopod-frontend/src/Page/Popup/EditDeployment.hs @@ -1,195 +1,128 @@ -{-| -Module : Page.Popup.EditDeployment -Description : Edit deployment sidebar. - -This module contains the definition of the "edit deployment" sidebar. --} - +-- | +--Module : Page.Popup.EditDeployment +--Description : Edit deployment sidebar. +-- +--This module contains the definition of the "edit deployment" sidebar. module Page.Popup.EditDeployment (editDeploymentPopup) where -import Control.Lens (coerced, preview, to, (^.), _2) -import Control.Monad -import Data.Coerce -import Data.Functor -import Data.Generics.Product -import Data.Generics.Sum -import Data.List (deleteFirstsBy) -import qualified Data.List as L -import Data.Map as M -import Data.Monoid +import Control.Lens (coerced, preview, to, (^.), _2) +import Control.Monad +import Data.Coerce +import Data.Functor +import Data.Generics.Product +import Data.Generics.Sum +import Data.Monoid import qualified Data.Text as T -import Prelude as P -import Reflex.Dom as R +import Reflex.Dom as R hiding (mapMaybe) +import Prelude as P -import Common.Types -import Common.Utils -import Data.Text (Text) -import Frontend.API -import Frontend.Utils -import Servant.Reflex -import Servant.Reflex.Extra +import Common.Types +import Common.Utils +import Data.Text (Text) +import Frontend.API +import Frontend.Utils +import Servant.Reflex +import Servant.Reflex.Extra -- | The root function for \"edit deployment\" sidebar. -editDeploymentPopup - :: MonadWidget t m - => Event t DeploymentFullInfo - -- ^ \"Show\" event carrying an editable sidebar. - -> Event t () - -- ^ \"Close\" event. - -> m (Event t Bool) - -- ^ Event with a flag showing the current state of the request. +editDeploymentPopup :: + MonadWidget t m => + -- | \"Show\" event carrying an editable sidebar. + Event t DeploymentFullInfo -> + -- | \"Close\" event. + Event t () -> + -- | Event with a flag showing the current state of the request. + m (Event t Bool) editDeploymentPopup showEv hideEv = sidebar showEv hideEv $ \dfi -> mdo divClass "popup__body" $ mdo let dname = dfi ^. dfiName (closeEv', saveEv) <- editDeploymentPopupHeader dname enabledDyn (deploymentDyn, validDyn) <- editDeploymentPopupBody dfi respEv - respEv <- updateEndpoint (constDyn $ Right dname) - (Right <$> deploymentDyn) saveEv - sentDyn <- holdDyn False $ leftmost - [ True <$ saveEv - , False <$ respEv ] - let - successEv = - fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv - closeEv = leftmost [ closeEv', successEv ] - enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn + respEv <- + updateEndpoint + (constDyn $ Right dname) + (Right <$> deploymentDyn) + saveEv + sentDyn <- + holdDyn False $ + leftmost + [ True <$ saveEv + , False <$ respEv + ] + let successEv = + fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv + closeEv = leftmost [closeEv', successEv] + enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn pure (updated sentDyn, closeEv) -- | The header of the sidebar contains the deployment name and control buttons: -- \"Save\" and \"Close\". -editDeploymentPopupHeader - :: MonadWidget t m - => DeploymentName -- ^ Name of the deployment. - -> Dynamic t Bool -- ^ Form validation state. - -> m (Event t (), Event t ()) -- ^ \"Close\" event and \"Save\" click event. +editDeploymentPopupHeader :: + MonadWidget t m => + -- | Name of the deployment. + DeploymentName -> + -- | Form validation state. + Dynamic t Bool -> + -- | \"Close\" event and \"Save\" click event. + m (Event t (), Event t ()) editDeploymentPopupHeader dname validDyn = divClass "popup__head" $ do closeEv <- buttonClass "popup__close" "Close popup" elClass "h2" "popup__project" $ text $ "Edit " <> coerce dname - saveEv <- divClass "popup__operations" $ - buttonClassEnabled "popup__action button button--save" "Save" validDyn + saveEv <- + divClass "popup__operations" $ + buttonClassEnabled "popup__action button button--save" "Save" validDyn divClass "popup__menu drop drop--actions" blank pure (closeEv, saveEv) -- | The body of the sidebar containing the edit form. Contains a tag field and -- an override field. -editDeploymentPopupBody - :: MonadWidget t m - => DeploymentFullInfo - -- ^ Full deployment data. - -> Event t (ReqResult tag CommandResponse) - -- ^ \"Edit request\" failure event. - -> m (Dynamic t DeploymentUpdate, Dynamic t Bool) - -- ^ Returns deployment update and validation state. +editDeploymentPopupBody :: + MonadWidget t m => + -- | Full deployment data. + DeploymentFullInfo -> + -- | \"Edit request\" failure event. + Event t (ReqResult tag CommandResponse) -> + -- | Returns deployment update and validation state. + m (Dynamic t DeploymentUpdate, Dynamic t Bool) editDeploymentPopupBody dfi errEv = divClass "popup__content" $ divClass "deployment" $ mdo - let - commandResponseEv = fmapMaybe commandResponse errEv - appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv - dfiTag = dfi ^. field @"deployment" . field @"tag" . coerced . to Just - dfiAppVars = dfi ^. field @"deployment" . field @"appOverrides" . coerced - dfiDeploymentVars = - dfi ^. field @"deployment" . field @"deploymentOverrides" . coerced - tagErrEv = getTagError commandResponseEv tagDyn + let commandResponseEv = fmapMaybe commandResponse errEv + appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv + dfiTag = dfi ^. field @"deployment" . field @"tag" . coerced . to Just + dfiAppVars = dfi ^. field @"deployment" . field @"appOverrides" . coerced + dfiDeploymentVars = + dfi ^. field @"deployment" . field @"deploymentOverrides" . coerced + tagErrEv = getTagError commandResponseEv tagDyn errorHeader appErrEv (tagDyn, tOkEv) <- octopodTextInput "tag" "Tag" "Tag" dfiTag tagErrEv appVarsDyn <- envVarsInput "App overrides" dfiAppVars deploymentVarsDyn <- envVarsInput "Deployment overrides" dfiDeploymentVars - let - oldAppVarDyn = coerce <$> getOldVars dfiAppVars <$> appVarsDyn - newAppVarDyn = coerce <$> getNewVars dfiAppVars <$> appVarsDyn - oldDeploymentVarDyn = coerce <$> getOldVars dfiDeploymentVars <$> deploymentVarsDyn - newDeploymentVarDyn = coerce <$> getNewVars dfiDeploymentVars <$> deploymentVarsDyn validDyn <- holdDyn True $ updated tOkEv - pure $ (DeploymentUpdate - <$> (DeploymentTag <$> tagDyn) - <*> newAppVarDyn - <*> oldAppVarDyn - <*> newDeploymentVarDyn - <*> oldDeploymentVarDyn, validDyn) + pure + ( DeploymentUpdate + <$> (DeploymentTag <$> tagDyn) + <*> appVarsDyn + <*> deploymentVarsDyn + , validDyn + ) where - getTagError crEv tagDyn = let - tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2 )) crEv - tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv' - badTagText = "Tag should not be empty" - badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn) - in leftmost [tagErrEv, badNameEv] - getOldVars i u = deleteFirstsBy cmpKey i u - getNewVars i u = deleteFirstsBy (==) u i - cmpKey (Override k1 _ v1) (Override k2 _ v2) = k1 == k2 && v1 == v2 + getTagError crEv tagDyn = + let tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2)) crEv + tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv' + badTagText = "Tag should not be empty" + badNameEv = badTagText <$ ffilter (== "") (updated tagDyn) + in leftmost [tagErrEv, badNameEv] -- | The widget used to display errors. -errorHeader - :: MonadWidget t m - => Event t Text -- ^ Message text. - -> m () +errorHeader :: + MonadWidget t m => + -- | Message text. + Event t Text -> + m () errorHeader appErrEv = do - widgetHold_ blank $ appErrEv <&> \appErr -> do - divClass "deployment__output notification notification--danger" $ do - el "b" $ text "App error: " - text appErr - --- | Widget with override fields. This widget supports adding and --- removing key-value pairs. -envVarsInput - :: MonadWidget t m - => Text -- ^ Overrides header. - -> Overrides -- ^ Current deployment overrides. - -> m (Dynamic t Overrides) -- ^ Updated deployment overrides. -envVarsInput overridesHeader evs = do - elClass "section" "deployment__section" $ do - elClass "h3" "deployment__sub-heading" $ text overridesHeader - elClass "div" "deployment__widget" $ - elClass "div" "overrides" $ mdo - let - initEnvs = L.foldl' (\m v -> fst $ insertUniq v m) emptyUniqKeyMap evs - emptyVar = Override "" "" Public - addEv = clickEv $> Endo (fst . insertUniq emptyVar) - envsDyn <- foldDyn appEndo initEnvs $ leftmost [ addEv, updEv ] - (_, updEv) <- runEventWriterT $ listWithKey (uniqMap <$> envsDyn) envVarInput - let addingIsEnabled = all ( (not . T.null) . overrideKey ) . elemsUniq <$> envsDyn - clickEv <- buttonClassEnabled' - "overrides__add dash dash--add" "Add an override" addingIsEnabled - "dash--disabled" - pure $ elemsUniq <$> envsDyn - --- | Widget for entering a key-value pair. The updated overrides list is --- written to the 'EventWriter'. -envVarInput - :: (EventWriter t (Endo (UniqKeyMap Override)) m, MonadWidget t m) - => Int -- ^ Index of variable in overrides list. - -> Dynamic t Override -- ^ Current variable key and value. - -> m () -envVarInput ix epDyn = do - ep <- sample $ current epDyn - divClass "overrides__item" $ do - (keyDyn, _) <- - octopodTextInput' "overrides__key" "key" (Just $ overrideKey ep) never - (valDyn, _) <- - octopodTextInput' "overrides__value" "value" (Just $ overrideValue ep) never - closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete" - let - envEv = updated $ zipDynWith (\k v -> Override k v Public) keyDyn valDyn - deleteEv = Endo (deleteUniq ix) <$ closeEv - updEv = Endo . updateUniq ix . const <$> envEv - tellEvent $ leftmost [deleteEv, updEv] - -data UniqKeyMap v = UniqKeyMap (Map Int v) (Int) - -uniqMap :: UniqKeyMap v -> Map Int v -uniqMap (UniqKeyMap m _) = m - -insertUniq :: v -> UniqKeyMap v -> (UniqKeyMap v, Int) -insertUniq v (UniqKeyMap m x) = (UniqKeyMap (M.insert x v m) (x + 1), x) - -deleteUniq :: Int -> UniqKeyMap v -> UniqKeyMap v -deleteUniq k (UniqKeyMap m x) = UniqKeyMap (M.delete k m) x - -updateUniq :: Int -> (v -> v) -> UniqKeyMap v -> UniqKeyMap v -updateUniq k f (UniqKeyMap m x) = UniqKeyMap (M.adjust f k m) x - -elemsUniq :: UniqKeyMap v -> [v] -elemsUniq (UniqKeyMap m _) = M.elems m - -emptyUniqKeyMap :: UniqKeyMap v -emptyUniqKeyMap = UniqKeyMap mempty 0 + widgetHold_ blank $ + appErrEv <&> \appErr -> do + divClass "deployment__output notification notification--danger" $ do + el "b" $ text "App error: " + text appErr diff --git a/octopod-frontend/src/Page/Popup/NewDeployment.hs b/octopod-frontend/src/Page/Popup/NewDeployment.hs index c9533c5..fef9500 100644 --- a/octopod-frontend/src/Page/Popup/NewDeployment.hs +++ b/octopod-frontend/src/Page/Popup/NewDeployment.hs @@ -1,157 +1,123 @@ -{-| -Module : Page.Popup.NewDeployment -Description : New deployment sidebar. - -This module contains the definition of \"new deployment\" sidebar. --} - - +-- | +--Module : Page.Popup.NewDeployment +--Description : New deployment sidebar. +-- +--This module contains the definition of \"new deployment\" sidebar. module Page.Popup.NewDeployment (newDeploymentPopup) where -import Control.Lens (preview, _1, _2) -import Control.Monad -import Data.Coerce -import Data.Functor -import Data.Generics.Sum -import Data.Map as M -import Data.Monoid -import Data.Text as T (Text, intercalate) -import Prelude as P -import Reflex.Dom as R - -import Common.Types -import Common.Validation (isNameValid) -import Frontend.API -import Frontend.Utils -import Servant.Reflex -import Servant.Reflex.Extra +import Control.Lens (preview, _1, _2) +import Control.Monad +import Data.Functor +import Data.Generics.Sum +import Data.Monoid +import Data.Text as T (Text, intercalate) +import Reflex.Dom as R +import Prelude as P +import Common.Types +import Common.Validation (isNameValid) +import Frontend.API +import Frontend.Utils +import Servant.Reflex +import Servant.Reflex.Extra -- | The root function for \"new deployment\" sidebar. -newDeploymentPopup - :: MonadWidget t m - => Event t () -- ^ \"Show\" event. - -> Event t () -- ^ \"Close\" event. - -> m () -newDeploymentPopup showEv hideEv = void $ sidebar showEv hideEv $ const $ mdo - divClass "popup__body" $ mdo - (closeEv', saveEv) <- newDeploymentPopupHeader enabledDyn - (deploymentDyn, validDyn) <- newDeploymentPopupBody respEv - respEv <- createEndpoint (Right <$> deploymentDyn) saveEv - sentDyn <- holdDyn False $ leftmost - [ True <$ saveEv - , False <$ respEv ] - let - successEv = - fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv - closeEv = leftmost [ closeEv', successEv ] - enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn - pure (never, closeEv) +newDeploymentPopup :: + MonadWidget t m => + -- | \"Show\" event. + Event t () -> + -- | \"Close\" event. + Event t () -> + m () +newDeploymentPopup showEv hideEv = void $ + sidebar showEv hideEv $ + const $ mdo + divClass "popup__body" $ mdo + (closeEv', saveEv) <- newDeploymentPopupHeader enabledDyn + (deploymentDyn, validDyn) <- newDeploymentPopupBody respEv + respEv <- createEndpoint (Right <$> deploymentDyn) saveEv + sentDyn <- + holdDyn False $ + leftmost + [ True <$ saveEv + , False <$ respEv + ] + let successEv = + fmapMaybe (preview (_Ctor @"Success") <=< commandResponse) respEv + closeEv = leftmost [closeEv', successEv] + enabledDyn = zipDynWith (&&) (not <$> sentDyn) validDyn + pure (never, closeEv) -- | The header of sidebar contains control buttons: \"Save\" and \"Close\". -newDeploymentPopupHeader - :: MonadWidget t m - => Dynamic t Bool - -> m (Event t (), Event t ()) +newDeploymentPopupHeader :: + MonadWidget t m => + Dynamic t Bool -> + m (Event t (), Event t ()) newDeploymentPopupHeader enabledDyn = divClass "popup__head" $ do closeEv <- buttonClass "popup__close" "Close popup" elClass "h2" "popup__project" $ text "Create new deployment" - saveEv <- divClass "popup__operations" $ - buttonClassEnabled "popup__action button button--save" "Save" enabledDyn + saveEv <- + divClass "popup__operations" $ + buttonClassEnabled "popup__action button button--save" "Save" enabledDyn divClass "popup__menu drop drop--actions" blank pure (closeEv, saveEv) -- | The body of the sidebar contains the creation form. It contains: a name field, -- a tag field and overrides fields. The name field is validated with the regexp: -- @^[a-z][a-z0-9\\-]{1,16}$@. -newDeploymentPopupBody - :: MonadWidget t m - => Event t (ReqResult tag CommandResponse) - -- ^ Request failure event. - -> m (Dynamic t Deployment, Dynamic t Bool) - -- ^ Returns new deployment and validation states. +newDeploymentPopupBody :: + MonadWidget t m => + -- | Request failure event. + Event t (ReqResult tag CommandResponse) -> + -- | Returns new deployment and validation states. + m (Dynamic t Deployment, Dynamic t Bool) newDeploymentPopupBody errEv = divClass "popup__content" $ divClass "deployment" $ mdo - let - commandResponseEv = fmapMaybe commandResponse errEv - appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv - nameErrEv = getNameError commandResponseEv nameDyn - tagErrEv = getTagError commandResponseEv tagDyn + let commandResponseEv = fmapMaybe commandResponse errEv + appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv + nameErrEv = getNameError commandResponseEv nameDyn + tagErrEv = getTagError commandResponseEv tagDyn errorHeader appErrEv (nameDyn, nOkDyn) <- octopodTextInput "tag" "Name" "Name" Nothing nameErrEv (tagDyn, tOkDyn) <- octopodTextInput "tag" "Tag" "Tag" Nothing tagErrEv - appVarsDyn <- envVarsInput "App overrides" - deploymentVarsDyn <- envVarsInput "Deployment overrides" + appVarsDyn <- envVarsInput "App overrides" mempty + deploymentVarsDyn <- envVarsInput "Deployment overrides" mempty validDyn <- holdDyn False $ updated $ zipDynWith (&&) nOkDyn tOkDyn - pure $ (Deployment - <$> (DeploymentName <$> nameDyn) - <*> (DeploymentTag <$> tagDyn) - <*> (coerce <$> appVarsDyn) - <*> (coerce <$> deploymentVarsDyn), validDyn) + pure + ( Deployment + <$> (DeploymentName <$> nameDyn) + <*> (DeploymentTag <$> tagDyn) + <*> appVarsDyn + <*> deploymentVarsDyn + , validDyn + ) where - getNameError crEv nameDyn = let - nameErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _1 )) crEv - isNameValidDyn = isNameValid . DeploymentName <$> nameDyn - badNameText = "Deployment name length should be longer than 2 characters \ - \and under 17 characters and begin with a letter." - badNameEv = badNameText <$ (ffilter not $ updated isNameValidDyn) - nameErrEv = ffilter (/= "") $ T.intercalate ". " <$> nameErrEv' - in leftmost [nameErrEv, badNameEv] - getTagError crEv tagDyn = let - tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2 )) crEv - tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv' - badTagText = "Tag should not be empty" - badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn) - in leftmost [tagErrEv, badNameEv] + getNameError crEv nameDyn = + let nameErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _1)) crEv + isNameValidDyn = isNameValid . DeploymentName <$> nameDyn + badNameText = + "Deployment name length should be longer than 2 characters \ + \and under 17 characters and begin with a letter." + badNameEv = badNameText <$ (ffilter not $ updated isNameValidDyn) + nameErrEv = ffilter (/= "") $ T.intercalate ". " <$> nameErrEv' + in leftmost [nameErrEv, badNameEv] + getTagError crEv tagDyn = + let tagErrEv' = fmapMaybe (preview (_Ctor @"ValidationError" . _2)) crEv + tagErrEv = ffilter (/= "") $ T.intercalate ". " <$> tagErrEv' + badTagText = "Tag should not be empty" + badNameEv = badTagText <$ (ffilter (== "") $ updated tagDyn) + in leftmost [tagErrEv, badNameEv] -- | The widget used to display errors. -errorHeader - :: MonadWidget t m - => Event t Text -- ^ Message text. - -> m () +errorHeader :: + MonadWidget t m => + -- | Message text. + Event t Text -> + m () errorHeader appErrEv = do - widgetHold_ blank $ appErrEv <&> \appErr -> do - divClass "deployment__output notification notification--danger" $ do - el "b" $ text "App error: " - text appErr - --- | Widget with override fields. This widget supports adding and --- a removing key-value pairs. -envVarsInput - :: MonadWidget t m - => Text -- ^ Widget header. - -> m (Dynamic t [Override]) -envVarsInput headerText = do - elClass "section" "deployment__section" $ do - elClass "h3" "deployment__sub-heading" $ text headerText - elClass "div" "deployment__widget" $ - elClass "div" "overrides" $ mdo - let - emptyVar = Override "" "" Public - addEv = clickEv $> Endo (\envs -> P.length envs =: emptyVar <> envs) - envsDyn <- foldDyn appEndo mempty $ leftmost [ addEv, updEv ] - (_, updEv) <- runEventWriterT $ listWithKey envsDyn envVarInput - let addDisabledDyn = all ( (/= "") . overrideKey ) . elems <$> envsDyn - clickEv <- buttonClassEnabled' - "overrides__add dash dash--add" "Add an override" addDisabledDyn - "dash--disabled" - pure $ elems <$> envsDyn - --- | Widget for a key-value pair. It returns an event carrying an update --- of overrides list via 'EventWriter'. -envVarInput - :: (EventWriter t (Endo (Map Int Override)) m, MonadWidget t m) - => Int -- ^ Index of variable in overrides list. - -> Dynamic t Override -- ^ Current variable key and value. - -> m () -envVarInput ix _ = do - divClass "overrides__item" $ do - (keyDyn, _) <- octopodTextInput' "overrides__key" "key" Nothing never - (valDyn, _) <- octopodTextInput' "overrides__value" "value" Nothing never - closeEv <- buttonClass "overrides__delete spot spot--cancel" "Delete" - let - envEv = updated $ zipDynWith (\k v -> Override k v Public) keyDyn valDyn - deleteEv = Endo (M.delete ix) <$ closeEv - updEv = Endo . flip update ix . const . Just <$> envEv - tellEvent $ leftmost [deleteEv, updEv] + widgetHold_ blank $ + appErrEv <&> \appErr -> do + divClass "deployment__output notification notification--danger" $ do + el "b" $ text "App error: " + text appErr