git.iko.soy/typeable/octopod
on master help sign in
Refactored status changes and added deployment locking (#24)

* First stab at refactoring status transitions * Removed 'archived' column from database * Added deployment locking * Added transition possibility assertion at the start of background tasks * Added a notification control script (#27) * Added notification script * Updated docs * Fixed link * fixed link * Fixed response error body rendering * Resolved comments and fixed some bugs after rebasing * added info script back in

commitac83908e80094560913025d6ceea5067e8b45c48 authoriko <ilyakooo0@gmail.com> dateFri 5 Feb 2021, 17:17 (6 years ago) parent46b58e9
24 files changed+685−333
.gitignore+1 dev/dev_backend.sh+1 dev/write.sh+4 docs/en/Control_scripts.md+24 docs/ru/Control_scripts.md+16 migrations/deploy/remove_archived_column.sql+9 migrations/revert/remove_archived_column.sql+8 migrations/sqitch.plan+1 migrations/verify/remove_archived_column.sql+7 octopod-backend/octopod-backend.cabal+11 octopod-backend/src/Control/Octopod/DeploymentLock.hs+55 octopod-backend/src/Database/PostgreSQL/Simple/Instances.hs+4−1 octopod-backend/src/Octopod/Server.hs+391−293 octopod-backend/src/Octopod/Server/ControlScriptUtils.hs+84−9 octopod-backend/src/Octopod/Server/Logger.hs+4 octopod-common/src/Common/Types.hs+18−2 octopod-frontend/src/Frontend/API.hs+2−2 octopod-frontend/src/Frontend/Utils.hs+9−8 octopod-frontend/src/Main.hs+1−1 octopod-frontend/src/Page/Deployment.hs+7−6 octopod-frontend/src/Page/Deployments.hs+4−3 octopod-frontend/src/Page/Popup/EditDeployment.hs+2−1 octopod-frontend/src/Page/Popup/NewDeployment.hs+2−1 octopod-frontend/src/Servant/Reflex/Extra.hs+20−6
.gitignore+1
@@ -8,3 +8,4 @@ octopod-config.json
88frontend-result
99octopod-css/node_modules
1010dev/certs/*
11+tmp/*
dev/dev_backend.sh+1
@@ -18,6 +18,7 @@ export CLEANUP_COMMAND=$MOUNT_DIR/echo.sh
1818export ARCHIVE_CHECKING_COMMAND=$MOUNT_DIR/echo.sh
1919export TAG_CHECKING_COMMAND=$MOUNT_DIR/echo.sh
2020export INFO_COMMAND=$MOUNT_DIR/info.sh
21+export NOTIFICATION_COMMAND=$MOUNT_DIR/write.sh
2122$1/bin/octopod-exe \
2223 --port 4443 \
2324 --ui-port 3002 \
dev/write.shnew file+4
@@ -0,0 +1,4 @@
1+#!/bin/bash
2+
3+echo $0 $@ >> ./tmp/calls.txt
4+exit 0
docs/en/Control_scripts.md+24
@@ -39,6 +39,9 @@
3939 - [Description](#description-8)
4040 - [Execution example](#execution-example-7)
4141 - [Sample implementation](#sample-implementation-7)
42+ - [🔔 notifications](#-notifications)
43+ - [Description](#description-9)
44+ - [Execution example](#execution-example-8)
4245
4346</details>
4447
@@ -296,4 +299,25 @@ echo "app,https://${name}.example.com"
296299echo "api,https://api.${name}.example.com"
297300```
298301
302+### 🔔 notifications
303+
304+#### Description
305+
306+This script gets called every time a deployment changes its status (apart from creation and deletion). It might be useful if you want to send notifications about certain deployment status transitions. The complete list of statuses and their transitions can be found in the [technical architecture document](Technical_architecture.md#️-deployment-state-transitions).
307+
308+It is optional and can be omitted altogether.
309+
310+This script receives the following additional command-line arguments as input:
311+* `--tag` – The _Docker Image tag_ that should be deployed. (In practice you can use some other string that identifies a version of your system to deploy – you will need to process it accordingly in the script.)
312+- `--old-status` – The previous status the deployment was in.
313+- `--new-status` – The new status the deployment transitioned to.
314+
315+#### Execution example
316+
317+The script might be called something like this:
318+
319+```bash
320+notification --project-name "Cactus store" --base-domain "cactus-store.com" --namespace "cactus" --name "orange-button" --tag "c9bbc3fcc69e5aa094bca110c6f79419ab7be77a" --old-status "UpdatePending" --new-status "Running"
321+```
322+
299323[configmap]: https://kubernetes.io/docs/concepts/configuration/configmap/
docs/ru/Control_scripts.md+16
@@ -12,6 +12,7 @@
1212- [tag_check](#tag_check)
1313- [init](#init)
1414- [info](#info)
15+- [notifications](#notifications)
1516
1617</details>
1718
@@ -212,3 +213,18 @@ echo "api,https://api.${name}.example.com"
212213```
213214
214215[kubedog]: https://github.com/werf/kubedog
216+
217+## notifications
218+
219+Этот скрипт вызывается каждый раз, когда развертывание меняет свой статус. (При создании и удалении не вызывается.)
220+
221+Полный список статусов и их переходов можно найти в документе по [технической архитектуре](Technical_architecture.md#deployment-state-transitions).
222+
223+Получает на вход следующие аргументы:
224+* `--project-name` – название проекта
225+* `--base-domain` – базовый домен
226+* `--namespace` – namespace
227+* `--name` – имя стейджинга
228+* `--tag` – тег развертывания
229+- `--old-status` – предыдущий статус
230+- `--new-status` – новый статус
migrations/deploy/remove_archived_column.sqlnew file+9
@@ -0,0 +1,9 @@
1+-- Deploy octopod:remove_archived_column to pg
2+ BEGIN;
3+
4+
5+ALTER TABLE "public"."deployments"
6+DROP COLUMN "archived";
7+
8+
9+COMMIT;
migrations/revert/remove_archived_column.sqlnew file+8
@@ -0,0 +1,8 @@
1+-- Revert octopod:remove_archived_column from pg
2+ BEGIN;
3+
4+
5+ALTER TABLE "public"."deployments" ADD COLUMN "archived" bool NOT NULL DEFAULT 'false';
6+
7+
8+COMMIT;
migrations/sqitch.plan+1
@@ -14,3 +14,4 @@ rename_elements_of_scope_enum 2020-08-19T09:38:40Z Typeable LLC <octopod@typeabl
1414rename-delete-to-archive 2020-11-23T10:53:10Z Ilya <octopod@typeable.io> # Renamed delete to archive
1515rename_delete_to_archive_2 2020-11-26T08:28:58Z Typeable LLC <octopod@typeable.io> # Renamed delete to archive
1616add_detailed_failures 2021-02-04T11:01:15Z Typeable LLC <octopod@typeable.io> # Added more failure states
17+remove_archived_column 2021-01-28T18:44:54Z Typeable LLC <octopod@typeable.io> # Removed 'archived' column
migrations/verify/remove_archived_column.sqlnew file+7
@@ -0,0 +1,7 @@
1+-- Verify octopod:remove_archived_column on pg
2+
3+BEGIN;
4+
5+-- XXX Add verifications here.
6+
7+ROLLBACK;
octopod-backend/octopod-backend.cabal+11
@@ -35,6 +35,13 @@ library
3535 , StandaloneDeriving
3636 , TypeApplications
3737 , TypeOperators
38+ , OverloadedLabels
39+ , UndecidableInstances
40+ , MultiParamTypeClasses
41+ , FunctionalDependencies
42+ , RankNTypes
43+ , TypeFamilies
44+ , QuantifiedConstraints
3845 exposed-modules:
3946 Octopod.Server
4047 Octopod.Server.Args
@@ -45,6 +52,7 @@ library
4552 TLS
4653 Types
4754 Database.PostgreSQL.Simple.Instances
55+ Control.Octopod.DeploymentLock
4856 hs-source-dirs:
4957 src
5058 build-depends:
@@ -93,6 +101,9 @@ library
93101 , x509-store
94102 , x509-validation
95103 , containers
104+ , monad-control
105+ , lifted-base
106+ , containers
96107 default-language: Haskell2010
97108
98109executable octopod-exe
octopod-backend/src/Control/Octopod/DeploymentLock.hsnew file+55
@@ -0,0 +1,55 @@
1+module Control.Octopod.DeploymentLock
2+ ( LockedDeployments
3+ , initLockedDeployments
4+ , withLockedDeployment
5+ , isDeploymentLocked
6+ ) where
7+
8+import Common.Types
9+import Control.Concurrent.STM
10+import Control.Exception.Lifted
11+import Control.Monad.Base
12+import Control.Monad.Reader
13+import Control.Monad.Trans.Control
14+import Data.Generics.Product.Typed
15+import Data.Set (Set)
16+import qualified Data.Set as S
17+
18+newtype LockedDeployments = LockedDeployments (TVar (Set DeploymentName))
19+
20+initLockedDeployments :: IO LockedDeployments
21+initLockedDeployments = LockedDeployments <$> newTVarIO S.empty
22+
23+withLockedDeployment
24+ ::
25+ ( MonadReader r m
26+ , HasType LockedDeployments r
27+ , MonadBaseControl IO m
28+ )
29+ => DeploymentName
30+ -> m a
31+ -- ^ The conflict handler. Gets called if the deployment is
32+ -- already being processed.
33+ -> m a
34+ -- ^ The actions to be performed when the deployment is locked.
35+ -> m a
36+withLockedDeployment dName conflictHandler m = do
37+ (LockedDeployments tvar) <- asks getTyped
38+ proceed <- liftBase . atomically $ do
39+ s <- readTVar tvar
40+ if S.member dName s
41+ then return False -- Do not proceed
42+ else do
43+ writeTVar tvar (S.insert dName s)
44+ return True -- Proceed
45+ if proceed
46+ then finally m $ liftBase . atomically $ modifyTVar tvar (S.delete dName)
47+ else conflictHandler
48+
49+isDeploymentLocked
50+ :: (MonadReader r m , HasType LockedDeployments r, MonadBase IO m)
51+ => DeploymentName -> m Bool
52+isDeploymentLocked dName = do
53+ (LockedDeployments tvar) <- asks getTyped
54+ s <- liftBase $ readTVarIO tvar
55+ return $ S.member dName s
octopod-backend/src/Database/PostgreSQL/Simple/Instances.hs+4−1
@@ -11,6 +11,7 @@ import qualified Data.Map as M
1111import Data.Text (Text)
1212import Database.PostgreSQL.Simple.FromField
1313import Database.PostgreSQL.Simple.ToField
14+import qualified Data.Text.Encoding as T
1415
1516deploymentStatusText :: DeploymentStatus -> Text
1617deploymentStatusText Running = "Running"
@@ -26,7 +27,8 @@ instance ToField DeploymentStatus where
2627 toField = toField @Text . deploymentStatusText
2728
2829instance FromField DeploymentStatus where
29− fromField f b = fromField f b >>= maybe empty return . flip M.lookup m
30+ fromField _ (Just b) =
31+ (either (const empty) pure . T.decodeUtf8' $ b) >>= maybe empty return . flip M.lookup m
3032 where
3133 m = M.fromList . fmap (deploymentStatusText &&& id) $
3234 [ Running
@@ -38,3 +40,4 @@ instance FromField DeploymentStatus where
3840 , ArchivePending
3941 , Archived
4042 ]
43+ fromField _ Nothing = empty
octopod-backend/src/Octopod/Server.hs+391−293
@@ -3,27 +3,28 @@ module Octopod.Server (runOctopodServer) where
33
44import Chronos (Time, getTime, now)
55import Control.Applicative
6−import Control.Concurrent (forkFinally, threadDelay)
6+import Control.Concurrent (threadDelay)
77import Control.Concurrent.Async (race_)
8+import qualified Control.Concurrent.Lifted as L
89import Control.Concurrent.MVar
910import Control.Concurrent.STM
1011import Control.Exception (Exception, throwIO, try)
12+import Control.Lens
1113import Control.Monad
12−import Control.Monad.IO.Class
13−import Control.Monad.Trans.Reader (ReaderT, ask, runReaderT)
14+import Control.Monad.Except
15+import Control.Monad.Reader
1416import Data.Aeson (Value(..), encode, toJSON)
1517import Data.ByteString (ByteString)
1618import qualified Data.ByteString.Lazy as BSL
1719import Data.Coerce
1820import Data.Conduit (ConduitT, yield)
1921import Data.Foldable (foldrM)
20−import Data.Functor ((<&>))
22+import Data.Functor
2123import Data.Int (Int64)
2224import Data.IORef
2325import Data.Maybe
2426import Data.Pool
2527import Data.Text (lines, pack, unpack, unwords)
26−import Data.Text.IO (hGetContents)
2728import Data.Traversable
2829import Database.PostgreSQL.Simple
2930import Database.PostgreSQL.Simple.Instances ()
@@ -37,11 +38,15 @@ import System.Environment (lookupEnv)
3738import System.Exit
3839import System.Log.FastLogger
3940import System.Posix.Signals (sigTERM)
40−import System.Process.Typed
4141
4242
43−import Common.Utils
4443import Common.Validation (isNameValid)
44+import Control.Monad.Base
45+import Control.Monad.Trans.Control
46+import Control.Octopod.DeploymentLock
47+import qualified Data.ByteString.Lazy.Char8 as BSLC
48+import Data.Generics.Labels ()
49+import qualified Data.Text.Encoding as T
4550import Database.PostgreSQL.Simple.Transaction
4651import Octopod.API
4752import Octopod.PowerAPI
@@ -52,6 +57,7 @@ import Octopod.Server.Posix
5257import Orphans ()
5358import TLS (createTLSOpts)
5459import Types
60+import Control.Lens.Extras
5561
5662type PgPool = Pool Connection
5763type AppM = ReaderT AppState Handler
@@ -95,7 +101,11 @@ data AppState = AppState
95101 , tagCheckingCommand :: Command
96102 -- ^ tag checking command path
97103 , infoCommand :: Command
98− }
104+ , notificationCommand :: Maybe Command
105+ , lockedDeployments :: LockedDeployments
106+ -- ^ Deployments currently being processed which has not yet been
107+ -- recorded in the database.
108+ } deriving Generic
99109
100110-- | Deployment exception definition.
101111data DeploymentException
@@ -118,18 +128,6 @@ data FullInfoListType
118128 | FullInfoOnlyForOne DeploymentName
119129 deriving (Show)
120130
121−-- | Deployment search errors definition.
122−data DeploymentNotFound
123− = DeploymentNotFound
124− | ArchivedDeploymentNotFound
125− | ActiveDeploymentNotFound
126− | DeploymentHasPendingStatus DeploymentStatus
127−
128−-- | Definition of a filter by pending statuses.
129−data FilterByPending
130− = AllowPending
131− | DenyPending
132−
133131runOctopodServer :: IO ()
134132runOctopodServer = do
135133 logger' <- newLogger
@@ -158,10 +156,15 @@ runOctopodServer = do
158156 archiveCheckingCmd <- coerce . pack <$> getEnvOrDie "ARCHIVE_CHECKING_COMMAND"
159157 tagCheckingCmd <- coerce . pack <$> getEnvOrDie "TAG_CHECKING_COMMAND"
160158 infoCmd <- coerce . pack <$> getEnvOrDie "INFO_COMMAND"
159+ notificationCmd <- (fmap . fmap) (Command . pack)
160+ $ lookupEnv "NOTIFICATION_COMMAND" <&> \case
161+ Just "" -> Nothing
162+ x -> x
161163 pgPool <- initConnectionPool
162164 (unDBConnectionString $ octopodDB opts)
163165 (unDBPoolSize $ octopodDBPoolSize opts)
164166 channel <- liftIO . atomically $ newBroadcastTChan
167+ lockedDs <- initLockedDeployments
165168 let
166169 appSt =
167170 AppState
@@ -184,6 +187,8 @@ runOctopodServer = do
184187 archiveCheckingCmd
185188 tagCheckingCmd
186189 infoCmd
190+ notificationCmd
191+ lockedDs
187192 app' = app appSt
188193 powerApp' = powerApp appSt
189194 wsApp' = wsApp channel
@@ -263,7 +268,9 @@ powerListH :: AppM [DeploymentFullInfo]
263268powerListH = getFullInfo FullInfoForAll
264269
265270-- | Handles the 'full_info' request of the Web UI API.
266−fullInfoH :: DeploymentName -> AppM DeploymentFullInfo
271+fullInfoH
272+ :: (MonadReader AppState m, MonadBaseControl IO m, MonadError ServerError m)
273+ => DeploymentName -> m DeploymentFullInfo
267274fullInfoH dName = do
268275 fullInfoList <- hidePrivateOverridesInFullInfos <$>
269276 getFullInfo (FullInfoOnlyForOne dName)
@@ -284,7 +291,7 @@ powerFullInfoH dName = do
284291-- | Hides private overrides in 'full_info' response.
285292hidePrivateOverridesInFullInfos :: [DeploymentFullInfo] -> [DeploymentFullInfo]
286293hidePrivateOverridesInFullInfos dFullInfos = do
287− dFullInfos <&> \(DeploymentFullInfo dep s a ct ut u) ->
294+ dFullInfos <&> \(DeploymentFullInfo dep s ct ut u) ->
288295 let
289296 hidePrivate (Deployment n t appOvs depOvs) =
290297 Deployment n t (hideP appOvs) (hideP depOvs)
@@ -295,32 +302,47 @@ hidePrivateOverridesInFullInfos dFullInfos = do
295302 Private -> "*"
296303 Public -> v
297304 in coerce $ Override k v' vis
298− in DeploymentFullInfo (hidePrivate dep) s a ct ut u
305+ in DeploymentFullInfo (hidePrivate dep) s ct ut u
306+
307+getFullInfo
308+ :: (MonadReader AppState m, MonadBaseControl IO m)
309+ => FullInfoListType
310+ -> m [DeploymentFullInfo]
311+getFullInfo lType = do
312+ p <- asks pool
313+ liftBaseOp (withResource p) $ \conn -> getFullInfo' conn lType
299314
300315-- | Helper to get full_info from the database.
301−getFullInfo :: FullInfoListType -> AppM [DeploymentFullInfo]
302−getFullInfo listType = do
303− AppState {pool = p, logger = l} <- ask
304− deployments <- liftIO $ withResource p $ \conn -> do
316+getFullInfo'
317+ :: (MonadReader AppState m, MonadBase IO m)
318+ => Connection
319+ -> FullInfoListType
320+ -> m [DeploymentFullInfo]
321+getFullInfo' conn listType = do
322+ AppState {logger = l} <- ask
323+ deployments <- do
305324 rows <- case listType of
306− FullInfoForAll -> query_ conn qAll
307− FullInfoOnlyForOne (dName) -> query conn qOne (Only dName)
308− for rows $ \(n, t, a, ct, ut, st) -> do
309− (appOvs, depOvs) <- selectOverrides conn n
310− dMeta <- selectDeploymentMetadata conn n
325+ FullInfoForAll -> liftBase $ query_ conn qAll
326+ FullInfoOnlyForOne dName -> liftBase $ query conn qOne (Only dName)
327+ for rows $ \(n, t, ct, ut, st) -> do
328+ (appOvs, depOvs) <- liftBase $ selectOverrides conn n
329+ dMeta <- liftBase $ selectDeploymentMetadata conn n
330+ st' <- isDeploymentLocked n <&> \case
331+ True -> DeploymentPending st
332+ False -> DeploymentNotPending st
311333 pure $ do
312− let dep = (Deployment n t appOvs depOvs)
313− DeploymentFullInfo dep st a dMeta ct ut
314− liftIO . logInfo l $ "get deployments: " <> (pack . show $ deployments)
334+ let dep = Deployment n t appOvs depOvs
335+ DeploymentFullInfo dep st' dMeta ct ut
336+ liftBase . logInfo l $ "get deployments: " <> (pack . show $ deployments)
315337 return deployments
316338 where
317339 qAll =
318− "SELECT name, tag, archived, extract(epoch from created_at)::int, \
319− \extract(epoch from updated_at)::int, status::text \
340+ "SELECT name, tag, extract(epoch from created_at)::int, \
341+ \extract(epoch from updated_at)::int, status \
320342 \FROM deployments ORDER BY name"
321343 qOne =
322− "SELECT name, tag, archived, extract(epoch from created_at)::int, \
323− \extract(epoch from updated_at)::int, status::text \
344+ "SELECT name, tag, extract(epoch from created_at)::int, \
345+ \extract(epoch from updated_at)::int, status \
324346 \FROM deployments \
325347 \WHERE name = ?"
326348
@@ -337,42 +359,43 @@ createH dep = do
337359 { errBody = validationError [badNameText] [] }
338360 t1 <- liftIO $ now
339361 st <- ask
340− let
341− q =
342− "INSERT INTO deployments (name, tag, status) \
343− \VALUES (?, ?, ?) RETURNING id"
344− pgPool = pool st
345− createDep :: PgPool -> Deployment -> IO [Only Int]
346− createDep p Deployment { name = n, tag = t } =
347− withResource p $ \conn ->
348− query conn q (n, t, CreatePending)
349362 failIfImageNotFound (name dep) (tag dep)
350363 failIfGracefulShutdownActivated
351− res :: Either SqlError [Only Int] <- liftIO . try $ createDep pgPool dep
352− dId <- case res of
353− Right ((Only depId) : _) ->
354− pure . DeploymentId $ depId
355− Right [] ->
356− throwError err404
357− { errBody = validationError ["Name not found"] [] }
358− Left (SqlError code _ _ _ _) | code == unique_violation ->
359− throwError err400
360− { errBody = validationError ["Deployment already exists"] [] }
361− Left (SqlError _ _ _ _ _) ->
362− throwError err409 { errBody = appError "Some database error" }
363− liftIO . withResource pgPool $ \conn ->
364− upsertNewOverrides conn dId (appOverrides dep) (deploymentOverrides dep)
365− liftIO . runBgWorker st $ do
366− sendReloadEvent st
367− updateDeploymentInfo (name dep) st
368− (ec, out, err) <- createDeployment dep st
369− t2 <- now
364+ runDeploymentBgWorker Nothing (name dep) $ do
365+ let
366+ q =
367+ "INSERT INTO deployments (name, tag, status) \
368+ \VALUES (?, ?, ?) RETURNING id"
369+ pgPool = pool st
370+ createDep :: PgPool -> Deployment -> IO [Only Int]
371+ createDep p Deployment { name = n, tag = t } =
372+ withResource p $ \conn ->
373+ query conn q (n, t, CreatePending)
374+ res :: Either SqlError [Only Int] <- liftIO . try $ createDep pgPool dep
375+ dId <- case res of
376+ Right ((Only depId) : _) ->
377+ pure . DeploymentId $ depId
378+ Right [] ->
379+ throwError err404
380+ { errBody = validationError ["Name not found"] [] }
381+ Left (SqlError code _ _ _ _) | code == unique_violation ->
382+ throwError err400
383+ { errBody = validationError ["Deployment already exists"] [] }
384+ Left (SqlError _ _ _ _ _) ->
385+ throwError err409 { errBody = appError "Some database error" }
386+ liftIO . withResource pgPool $ \conn ->
387+ upsertNewOverrides conn dId (appOverrides dep) (deploymentOverrides dep)
388+ liftBase $ sendReloadEvent st
389+ liftBase $ updateDeploymentInfo (name dep) st
390+ (ec, out, err) <- liftBase $ createDeployment dep st
391+ t2 <- liftBase $ now
370392 let
371− arch = ArchivedFlag False
372393 elTime = elapsedTime t2 t1
373− createDeploymentLog pgPool dep "create" ec arch elTime out err
374− sendReloadEvent st
375− handleExitCode ec
394+ liftIO . withResource pgPool $ \conn ->
395+ -- calling it directly now is fine since there is no previous status.
396+ createDeploymentLog conn dep "create" ec elTime out err
397+ liftBase $ sendReloadEvent st
398+ liftBase $ handleExitCode ec
376399 pure Success
377400
378401-- | Updates deployment info.
@@ -455,74 +478,173 @@ selectDeploymentLogs pgPool dId = do
455478
456479-- | Helper to get a deployment.
457480selectDeployment
458− :: PgPool
459− -> DeploymentName
460− -> DeploymentListType
461− -> FilterByPending
462− -> AppM (Either DeploymentNotFound Deployment)
463−selectDeployment pgPool dName lType filterByPending = do
464− let
465− baseQuery =
466− "SELECT name, tag, status::text FROM deployments \
467− \WHERE name = ?"
468− q AllDeployments = baseQuery
469− q ArchivedOnlyDeployments = baseQuery <> " AND archived = 't'"
470− q ActiveOnlyDeployments = baseQuery <> " AND archived = 'f'"
471− allowPending =
472− case filterByPending of
473− AllowPending -> True
474− DenyPending -> False
475− result <- liftIO . withResource pgPool $ \conn -> do
476− retrieved <- query conn (q lType) (Only dName)
477− for retrieved $ \(n, t, s) -> do
478− (appOvs, stOvs) <- selectOverrides conn n
479− pure $ (Deployment n t appOvs stOvs, read s)
480− pure $
481− case result of
482− [(_, st)] | isPending st && not allowPending ->
483− Left $ DeploymentHasPendingStatus st
484− [(dep, _)] ->
485− Right dep
486− _ ->
487− Left $
488− case lType of
489− AllDeployments -> DeploymentNotFound
490− ArchivedOnlyDeployments -> ArchivedDeploymentNotFound
491− ActiveOnlyDeployments -> ActiveDeploymentNotFound
492−
493−-- | Handles deployment search result.
494−unwrapOrValidateError
495− :: AppM (Either DeploymentNotFound Deployment)
481+ :: DeploymentName
496482 -> AppM Deployment
497−unwrapOrValidateError deploymentSearchResult = do
498− deploymentSearchResult' <- deploymentSearchResult
499− case deploymentSearchResult' of
500− Right dep -> pure dep
501− Left DeploymentNotFound ->
502− throwError err404
503− { errBody = validationError ["Deployment not found"] [] }
504− Left ArchivedDeploymentNotFound ->
505− throwError err404
506− { errBody = validationError ["Archived deployment not found"] [] }
507− Left ActiveDeploymentNotFound ->
508− throwError err404
509− { errBody = validationError ["Active deployment not found"] [] }
510− Left (DeploymentHasPendingStatus st) ->
511− let
512− err =
513− "You can not apply this operation \
514− \on deployment with \"" <> (pack . show $ st) <> "\" status"
515− in throwError err405 { errBody = validationError [err] [] }
483+selectDeployment dName = do
484+ pgPool <- asks pool
485+ let q = "SELECT name, tag FROM deployments WHERE name = ?"
486+ liftBaseOp (withResource pgPool) $ \conn -> do
487+ retrieved <- liftBase $ query conn q (Only dName)
488+ case retrieved of
489+ [(n, t)] -> do
490+ (appOvs, stOvs) <- liftBase $ selectOverrides conn n
491+ pure $ Deployment n t appOvs stOvs
492+ [] -> throwError err404 {errBody = "Deployment not found."}
493+ _ -> throwError err500
494+
495+data StatusTransitionProcessOutput = StatusTransitionProcessOutput
496+ { exitCode :: ExitCode
497+ , duration :: Duration
498+ , stdout :: Stdout
499+ , stderr :: Stderr
500+ } deriving (Generic, Show)
501+
502+data DeploymentStatusTransition
503+ = TransitionArchived
504+ | TransitionCreate
505+ | TransitionUpdate
506+ | TransitionRestore StatusTransitionProcessOutput
507+ | TransitionArchivePending StatusTransitionProcessOutput
508+ | TransitionUpdatePending StatusTransitionProcessOutput
509+ | TransitionCreatePending StatusTransitionProcessOutput
510+ | TransitionFailure FailureType
511+ deriving Show
512+
513+transitionStatus :: DeploymentStatusTransition -> DeploymentStatus
514+transitionStatus TransitionArchived {} = Archived
515+transitionStatus TransitionCreate {} = Running
516+transitionStatus TransitionUpdate {} = Running
517+transitionStatus TransitionRestore {} = Running
518+transitionStatus TransitionArchivePending {} = ArchivePending
519+transitionStatus TransitionUpdatePending {} = UpdatePending
520+transitionStatus TransitionCreatePending {} = CreatePending
521+transitionStatus (TransitionFailure t) = Failure t
522+
523+processOutput :: DeploymentStatusTransition -> Maybe (StatusTransitionProcessOutput, Action)
524+processOutput TransitionArchived = Nothing
525+processOutput TransitionCreate = Nothing
526+processOutput TransitionUpdate = Nothing
527+processOutput (TransitionRestore x) = Just (x, Action "restore")
528+processOutput (TransitionArchivePending x) = Just (x, Action "archive")
529+processOutput (TransitionUpdatePending x) = Just (x, Action "update")
530+processOutput (TransitionCreatePending x) = Just (x, Action "create")
531+processOutput TransitionFailure {} = Nothing
532+
533+transitionToStatusS
534+ :: (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m)
535+ => DeploymentName -> DeploymentStatusTransition -> m ()
536+transitionToStatusS dName tran =
537+ runExceptT (transitionToStatus dName tran) >>= transitionErrorToServerError
538+
539+transitionErrorToServerError :: MonadError ServerError m => Either StatusTransitionError a -> m a
540+transitionErrorToServerError = \case
541+ Right x -> return x
542+ Left (InvalidStatusTransition dName a b) -> throwError err409
543+ { errBody
544+ = "Unable to transition deployment "
545+ <> (BSL.fromStrict . T.encodeUtf8 . unDeploymentName) dName
546+ <> " from " <> show'' a <> " to " <> show'' b <> "."
547+ }
548+ Left (DeploymentNotFound dName) -> throwError err404
549+ { errBody
550+ = "Couldn't find deployment "
551+ <> (BSL.fromStrict . T.encodeUtf8 . unDeploymentName) dName <> "."
552+ }
553+ where
554+ show'' = BSLC.pack . show
555+
556+transitionToStatus
557+ :: (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m)
558+ => DeploymentName -> DeploymentStatusTransition -> ExceptT StatusTransitionError m ()
559+transitionToStatus dName s = do
560+ p <- asks pool
561+ st <- ask
562+ let log = liftBase . logInfo (logger st)
563+ (oldS, newS) <- withResource p $ \conn -> liftBaseOp_ (withTransaction conn) $ do
564+ dep <- lift (getFullInfo' conn (FullInfoOnlyForOne dName)) >>= \case
565+ [x] -> return x
566+ _ -> throwError $ DeploymentNotFound dName
567+ let
568+ oldS = recordedStatus $ dep ^. #status
569+ newS = transitionStatus s
570+ assertStatusTransitionPossible dName oldS newS
571+ log $ "Transitioning deployment " <> (show' . unDeploymentName) dName <> " " <> show' s
572+ let
573+ q =
574+ "UPDATE deployments \
575+ \SET status = ?, status_updated_at = now() "
576+ <> (if newS == ArchivePending then ", archived_at = now()" else mempty)
577+ <> " WHERE name = ?"
578+ void . liftBase $ execute conn q (newS, dName)
579+ liftBase $ forM_ (processOutput s) $ \(output, act) ->
580+ createDeploymentLog
581+ conn
582+ (dep ^. #deployment)
583+ act
584+ (output ^. #exitCode)
585+ (output ^. #duration)
586+ (output ^. #stdout)
587+ (output ^. #stderr)
588+ return (oldS, newS)
589+ notificationCmd <- asks notificationCommand
590+ forM_ notificationCmd $ \nCmd ->
591+ runBgWorker . void $ runCommandArgs' nCmd =<< notificationCommandArgs dName oldS newS
592+ liftBase $ sendReloadEvent st
593+
594+assertStatusTransitionPossible
595+ :: MonadError StatusTransitionError m
596+ => DeploymentName
597+ -> DeploymentStatus
598+ -> DeploymentStatus
599+ -> m ()
600+assertStatusTransitionPossible dName old new =
601+ unless
602+ (possibleTransitions old new)
603+ (throwError $ InvalidStatusTransition dName old new)
604+
605+assertDeploymentTransitionPossible
606+ :: (MonadError StatusTransitionError m, MonadReader AppState m, MonadBaseControl IO m)
607+ => DeploymentName
608+ -> DeploymentStatus
609+ -> m ()
610+assertDeploymentTransitionPossible dName new = do
611+ p <- asks pool
612+ dep <- withResource p $ \conn -> (getFullInfo' conn (FullInfoOnlyForOne dName)) >>= \case
613+ [x] -> return x
614+ _ -> throwError $ DeploymentNotFound dName
615+ assertStatusTransitionPossible dName (recordedStatus $ dep ^. #status) new
616+
617+assertDeploymentTransitionPossibleS
618+ :: (MonadError ServerError m, MonadReader AppState m, MonadBaseControl IO m)
619+ => DeploymentName
620+ -> DeploymentStatus
621+ -> m ()
622+assertDeploymentTransitionPossibleS dName new =
623+ runExceptT (assertDeploymentTransitionPossible dName new) >>= transitionErrorToServerError
624+data StatusTransitionError
625+ = InvalidStatusTransition DeploymentName DeploymentStatus DeploymentStatus
626+ | DeploymentNotFound DeploymentName
627+ deriving Show
628+
629+possibleTransitions :: DeploymentStatus -> DeploymentStatus -> Bool
630+possibleTransitions CreatePending = anyPred [is #_Running, is #_Failure]
631+possibleTransitions Running = anyPred [is #_Failure, is #_ArchivePending, is #_UpdatePending]
632+possibleTransitions UpdatePending = anyPred [is #_Failure, is #_Running]
633+possibleTransitions (Failure _) = anyPred [is #_UpdatePending, is #_Running, is #_ArchivePending]
634+possibleTransitions ArchivePending = anyPred [is #_ArchivePending, is #_Archived]
635+possibleTransitions Archived = anyPred [is #_CreatePending]
636+
637+anyPred :: [a -> Bool] -> a -> Bool
638+anyPred preds x = any ($ x) preds
516639
517640-- | Handles the 'archive' request.
518641archiveH :: DeploymentName -> AppM CommandResponse
519642archiveH dName = do
520643 failIfGracefulShutdownActivated
521− t1 <- liftIO $ now
644+ t1 <- liftBase now
522645 st <- ask
523646 let
524− log = logInfo (logger st)
525− pgPool = pool st
647+ log = liftBase . logInfo (logger st)
526648 args =
527649 [ "--project-name", coerce $ projectName st
528650 , "--base-domain", coerce $ baseDomain st
@@ -530,33 +652,20 @@ archiveH dName = do
530652 , "--name", coerce dName
531653 ]
532654 cmd = coerce $ archiveCommand st
533− arch = ArchivedFlag True
534− dep <- unwrapOrValidateError
535− $ selectDeployment pgPool dName ActiveOnlyDeployments DenyPending
536− liftIO . runBgWorker st $ do
655+ runDeploymentBgWorker (Just ArchivePending) dName $ do
537656 log $ "call " <> unwords (cmd : args)
538657 (ec, out, err) <- runCommand (unpack cmd) (unpack <$> args)
539− void $ archiveDeployment pgPool dName
540− sendReloadEvent st
541− t2 <- now
658+ t2 <- liftBase now
542659 let elTime = elapsedTime t2 t1
543− void $ createDeploymentLog pgPool dep "archive" ec arch elTime out err
544− log $ "deployment archived, name: " <> coerce dName
545− sendReloadEvent st
660+ transitionToStatusS dName $ TransitionArchivePending StatusTransitionProcessOutput
661+ { exitCode = ec
662+ , duration = elTime
663+ , stdout = out
664+ , stderr = err
665+ }
546666 handleExitCode ec
547667 pure Success
548668
549−-- | Handles the 'archive' request.
550−archiveDeployment :: PgPool -> DeploymentName -> IO Int64
551−archiveDeployment p dName = withResource p $ \conn -> do
552− let
553− q =
554− "UPDATE deployments \
555− \SET archived = 't', archived_at = now(), \
556− \status = ?, status_updated_at = now() \
557− \WHERE name = ?"
558− execute conn q (ArchivePending, dName)
559−
560669-- | Handles the 'update' request.
561670updateH :: DeploymentName -> DeploymentUpdate -> AppM CommandResponse
562671updateH dName dUpdate = do
@@ -573,21 +682,18 @@ updateH dName dUpdate = do
573682 } = dUpdate
574683 pgPool = pool st
575684 log = logInfo (logger st)
576−
577− void $ unwrapOrValidateError
578− $ selectDeployment pgPool dName ActiveOnlyDeployments DenyPending
579685 dId <- selectDeploymentId pgPool dName
580686 failIfImageNotFound dName dTag
581687 failIfGracefulShutdownActivated
582− liftIO . runBgWorker st $ do
583− (appOvs, depOvs) <- withResource pgPool $ \conn ->
688+ runDeploymentBgWorker (Just UpdatePending) dName $ do
689+ (appOvs, depOvs) <- liftBase . withResource pgPool $ \conn ->
584690 withTransaction conn $ do
585691 deleteOldOverrides conn dId oldAppOvs oldDepOvs
586692 upsertNewOverrides conn dId newAppOvs newDepOvs
587− void $ updateDeployment conn dName dTag
693+ updateTag conn dId dTag
588694 selectOverrides conn dName
589− updateDeploymentInfo dName st
590− sendReloadEvent st
695+ liftBase $ updateDeploymentInfo dName st
696+ liftBase $ sendReloadEvent st
591697 let
592698 args =
593699 [ "--project-name", coerce $ projectName st
@@ -598,18 +704,18 @@ updateH dName dUpdate = do
598704 ] ++ applicationOverridesToArgs appOvs
599705 ++ deploymentOverridesToArgs depOvs
600706 cmd = coerce $ updateCommand st
601− log $ "call " <> unwords (cmd : args)
602− (ec, out, err) <- runCommand (unpack cmd) (unpack <$> args)
603− log $ "deployment updated, name: "
707+ liftBase . log $ "call " <> unwords (cmd : args)
708+ (ec, out, err) <- liftBase $ runCommand (unpack cmd) (unpack <$> args)
709+ liftBase . log $ "deployment updated, name: "
604710 <> coerce dName <> ", tag: " <> coerce dTag
605− void $ do
606− t2 <- now
607− let
608− dep = Deployment dName dTag appOvs depOvs
609− arch = ArchivedFlag False
610− elTime = elapsedTime t2 t1
611− createDeploymentLog pgPool dep "update" ec arch elTime out err
612− sendReloadEvent st
711+ t2 <- liftBase now
712+ let elTime = elapsedTime t2 t1
713+ transitionToStatusS dName $ TransitionUpdatePending StatusTransitionProcessOutput
714+ { exitCode = ec
715+ , duration = elTime
716+ , stdout = out
717+ , stderr = err
718+ }
613719 handleExitCode ec
614720 return Success
615721
@@ -731,20 +837,15 @@ upsertNewOverrides conn dId appOvs depOvs = do
731837 oVis = show . overrideVisibility . unDeploymentOverride $ o
732838 execute conn q (oKey, oValue, dId', oScope, oVis, oValue, oVis)
733839
734−-- | Helper to update a deployment.
735−updateDeployment
840+updateTag
736841 :: Connection
737− -> DeploymentName
842+ -> DeploymentId
738843 -> DeploymentTag
739− -> IO Int64
740−updateDeployment conn dName dTag = do
844+ -> IO ()
845+updateTag conn (DeploymentId dId) (DeploymentTag dTag) = do
741846 let
742− q =
743− "UPDATE deployments \
744− \SET tag = ?, updated_at = now(), \
745− \status = ?, status_updated_at = now() \
746− \WHERE name = ?"
747− execute conn q (dTag, UpdatePending, dName)
847+ q = "UPDATE deployments SET tag=? WHERE id=?;"
848+ void $ execute conn q (dTag, dId)
748849
749850-- | Handles the 'info' request of the Web UI API.
750851infoH :: DeploymentName -> AppM [DeploymentInfo]
@@ -786,8 +887,7 @@ getInfo :: DeploymentName -> AppM DeploymentInfo
786887getInfo dName = do
787888 st <- ask
788889 let pgPool = pool st
789− dep <- unwrapOrValidateError
790− $ selectDeployment pgPool dName AllDeployments AllowPending
890+ dep <- selectDeployment dName
791891 dId <- selectDeploymentId pgPool dName
792892 liftIO $ do
793893 depLogs <- selectDeploymentLogs pgPool dId
@@ -813,14 +913,11 @@ statusH :: DeploymentName -> AppM CurrentDeploymentStatus
813913statusH dName = do
814914 st <- ask
815915 let
816− pgPool = pool st
817916 log = logInfo (logger st)
818917 cmd = checkingCommand st
819918 args =
820919 [ "--namespace", coerce $ namespace st
821920 , "--name", coerce $ dName ]
822− void $ unwrapOrValidateError
823− $ selectDeployment pgPool dName AllDeployments AllowPending
824921 liftIO $ log $ "call " <> unwords (coerce cmd : args)
825922 ec <- liftIO $ runCommandWithoutPipes (unpack $ coerce cmd) (unpack <$> args)
826923 pure . CurrentDeploymentStatus $
@@ -833,9 +930,7 @@ cleanupH :: DeploymentName -> AppM CommandResponse
833930cleanupH dName = do
834931 failIfGracefulShutdownActivated
835932 st <- ask
836− void $ unwrapOrValidateError
837− $ selectDeployment (pool st) dName ArchivedOnlyDeployments DenyPending
838− liftIO . runBgWorker st $ cleanupDeployment dName st
933+ runDeploymentBgWorker Nothing dName $ liftBase $ cleanupDeployment dName st
839934 pure Success
840935
841936-- | Helper to cleanup deployment.
@@ -905,11 +1000,12 @@ cleanArchiveH = do
9051000 archRetention = unArchiveRetention . archiveRetention $ st
9061001 q =
9071002 "SELECT name FROM deployments \
908− \WHERE archived = 't' AND archived_at + interval '?' second < now()"
1003+ \WHERE status in ? AND archived_at + interval '?' second < now()"
9091004 retrieved :: [Only DeploymentName] <- liftIO $
910− withResource pgPool $ \conn -> query conn q (Only archRetention)
911− liftIO . runBgWorker st . void $
912− for retrieved $ \(Only dName) -> cleanupDeployment dName st
1005+ withResource pgPool $ \conn -> query conn q (In archivedStatuses, archRetention)
1006+ runBgWorker . void $
1007+ for retrieved $ \(Only dName) ->
1008+ runDeploymentBgWorker Nothing dName $ liftBase $ cleanupDeployment dName st
9131009
9141010 pure Success
9151011
@@ -919,27 +1015,21 @@ restoreH dName = do
9191015 failIfGracefulShutdownActivated
9201016 t1 <- liftIO $ now
9211017 st <- ask
922− let pgPool = pool st
923− dep <- unwrapOrValidateError
924− $ selectDeployment pgPool dName ArchivedOnlyDeployments DenyPending
1018+ dep <- selectDeployment dName
9251019 failIfImageNotFound (name dep) (tag dep)
9261020 failIfGracefulShutdownActivated
927− liftIO . runBgWorker st $ do
928− updateDeploymentInfo dName st
929− let
930− q =
931− "UPDATE deployments \
932− \SET archived = 'f', archived_at = null, status = 'CreatePending' \
933− \WHERE name = ?"
934− void $ withResource pgPool $ \conn -> execute conn q (Only dName)
935− sendReloadEvent st
936− (ec, out, err) <- createDeployment dep st
937− t2 <- now
938− let
939− arch = ArchivedFlag False
940− elTime = elapsedTime t2 t1
941− createDeploymentLog pgPool dep "restore" ec arch elTime out err
942− sendReloadEvent st
1021+ runDeploymentBgWorker (Just CreatePending) dName $ do
1022+ dep' <- selectDeployment dName
1023+ liftBase $ updateDeploymentInfo dName st
1024+ (ec, out, err) <- liftBase $ createDeployment dep' st
1025+ t2 <- liftBase now
1026+ let elTime = elapsedTime t2 t1
1027+ transitionToStatusS dName $ TransitionCreatePending StatusTransitionProcessOutput
1028+ { exitCode = ec
1029+ , duration = elTime
1030+ , stdout = out
1031+ , stderr = err
1032+ }
9431033 handleExitCode ec
9441034 pure Success
9451035
@@ -958,56 +1048,37 @@ getActionInfoH aId = do
9581048 _ ->
9591049 throwError err400 { errBody = appError "Action not found" }
9601050
961−-- | Helper to run command with pipes.
962−runCommand :: FilePath -> [String] -> IO (ExitCode, Stdout, Stderr)
963−runCommand cmd args = do
964− let proc' c a = setStdout createPipe . setStderr createPipe $ proc c a
965− withProcessWait (proc' cmd args) $ \p -> do
966− out <- hGetContents . getStdout $ p
967− err <- hGetContents . getStderr $ p
968− ec <- waitExitCode p
969− pure (ec, Stdout out, Stderr err)
970−
971−-- | Helper to run command without pipes.
972−runCommandWithoutPipes :: FilePath -> [String] -> IO ExitCode
973−runCommandWithoutPipes cmd args = do
974− withProcessWait (proc cmd args) $ \p -> do
975− ec <- waitExitCode p
976− pure ec
977−
9781051-- | Helper to handle exit code.
979−handleExitCode :: ExitCode -> IO ()
1052+handleExitCode :: MonadBaseControl IO m => ExitCode -> m ()
9801053handleExitCode ExitSuccess = return ()
981−handleExitCode (ExitFailure c) = throwIO $ DeploymentFailed c
1054+handleExitCode (ExitFailure c) = liftBase . throwIO $ DeploymentFailed c
9821055
9831056-- | Helper to log a deployment action.
9841057createDeploymentLog
985− :: PgPool
1058+ :: Connection
9861059 -> Deployment
9871060 -> Action
9881061 -> ExitCode
989− -> ArchivedFlag
9901062 -> Duration
9911063 -> Stdout
9921064 -> Stderr
9931065 -> IO ()
994−createDeploymentLog pgPool dep act ec arch dur out err = do
1066+createDeploymentLog conn dep act ec dur out err = do
9951067 let
9961068 (Deployment dName dTag appOvs depOvs) = dep
9971069 exitCode' =
9981070 case ec of
9991071 ExitSuccess -> 0
10001072 ExitFailure errCode -> errCode
1001− arch' = unArchivedFlag arch
10021073 dur' = unDuration dur
10031074 out' = unStdout out
10041075 err' = unStderr err
10051076 qInsertLog =
10061077 "INSERT INTO deployment_logs \
1007− \(deployment_id, action, tag, exit_code, archived, \
1078+ \(deployment_id, action, tag, exit_code, \
10081079 \duration, stdout, stderr) \
10091080 \(\
1010− \SELECT id, ?, ?, ?, ?, ?, ?, ? \
1081+ \SELECT id, ?, ?, ?, ?, ?, ? \
10111082 \FROM deployments \
10121083 \WHERE name = ? \
10131084 \) RETURNING id"
@@ -1015,27 +1086,24 @@ createDeploymentLog pgPool dep act ec arch dur out err = do
10151086 "INSERT INTO deployment_log_overrides \
10161087 \(key, value, deployment_log_id, scope, visibility) \
10171088 \VALUES (?, ?, ?, ?, ?)"
1018−
1019− void $ withResource pgPool $ \conn ->
1020− withTransaction conn $ do
1021− aIds :: [Only Int] <- query conn qInsertLog
1022− (act, dTag, exitCode', arch', dur', out', err', dName)
1023− void $ for appOvs $ \o -> do
1024− let
1025− [Only aId] = aIds
1026− oKey = overrideKey . unApplicationOverride $ o
1027− oValue = overrideValue . unApplicationOverride $ o
1028− oScope = show ApplicationScope
1029− oVis = show . overrideVisibility . unApplicationOverride $ o
1030− execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis)
1031− void $ for depOvs $ \o -> do
1032− let
1033− [Only aId] = aIds
1034− oKey = overrideKey . unDeploymentOverride $ o
1035− oValue = overrideValue . unDeploymentOverride $ o
1036− oScope = show DeploymentScope
1037− oVis = show . overrideVisibility . unDeploymentOverride $ o
1038− execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis)
1089+ aIds :: [Only Int] <- query conn qInsertLog
1090+ (act, dTag, exitCode', dur', out', err', dName)
1091+ void $ for appOvs $ \o -> do
1092+ let
1093+ [Only aId] = aIds
1094+ oKey = overrideKey . unApplicationOverride $ o
1095+ oValue = overrideValue . unApplicationOverride $ o
1096+ oScope = show ApplicationScope
1097+ oVis = show . overrideVisibility . unApplicationOverride $ o
1098+ execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis)
1099+ void $ for depOvs $ \o -> do
1100+ let
1101+ [Only aId] = aIds
1102+ oKey = overrideKey . unDeploymentOverride $ o
1103+ oValue = overrideValue . unDeploymentOverride $ o
1104+ oScope = show DeploymentScope
1105+ oVis = show . overrideVisibility . unDeploymentOverride $ o
1106+ execute conn qInsertLogOverride (oKey, oValue, aId, oScope, oVis)
10391107
10401108-- | Helper to get deployment metadata from the database.
10411109selectDeploymentMetadata
@@ -1140,12 +1208,10 @@ runStatusUpdater state = do
11401208 \extract(epoch from status_updated_at)::int \
11411209 \FROM deployments \
11421210 \WHERE checked_at < now() - interval '?' second AND status != 'Archived'"
1143− updateStatus =
1144− "UPDATE deployments \
1145− \SET status = ?, status_updated_at = now(), checked_at = now() \
1146− \WHERE name = ? and status = ?"
11471211 updateCheckedAt =
1148− "UPDATE deployments SET checked_at = now() WHERE name = ? and status = ?"
1212+ "UPDATE deployments SET checked_at = now() WHERE name = ? AND status = ?"
1213+ logErr :: Text -> IO ()
1214+ logErr = logWarning (logger state)
11491215
11501216 forever $ do
11511217 rows :: [(DeploymentName, DeploymentStatus, Int)] <- liftIO $
@@ -1164,34 +1230,36 @@ runStatusUpdater state = do
11641230 cmd _ = unpack . coerce $ checkingCommand state
11651231 timeout = statusUpdateTimeout state
11661232 ec <- runCommandWithoutPipes (cmd dStatus) args
1167− pure (dName, newStatus ec dStatus ts timeout, ts)
1168− void $
1169− for (zip checkList checkResult) $ \((dName, oldSt, _), (_, newSt, _)) ->
1233+ pure (dName, statusTransition ec dStatus ts timeout, dStatus)
1234+ updated <-
1235+ for checkResult $ \(dName, transitionM, dStatus) ->
11701236 withResource pgPool $ \conn ->
1171− if oldSt == newSt
1172− then execute conn updateCheckedAt (dName, oldSt)
1173− else execute conn updateStatus (newSt, dName, oldSt)
1174− if checkList == checkResult
1175− then pure ()
1176− else sendReloadEvent state
1237+ case transitionM of
1238+ Nothing -> execute conn updateCheckedAt (dName, dStatus) $> False
1239+ Just transition ->
1240+ ($> True) $ (flip runReaderT state . runExceptT . runExceptT) (transitionToStatus dName transition) >>= \case
1241+ Right (Right ()) -> void $ execute conn updateCheckedAt (dName, transitionStatus transition)
1242+ Left e -> logErr $ show' e
1243+ Right (Left e) -> logErr $ show' e
1244+ when (or updated) $ sendReloadEvent state
11771245 threadDelay 5000000
11781246
11791247-- | Returns the new deployment status.
1180−newStatus
1248+statusTransition
11811249 :: ExitCode
11821250 -> DeploymentStatus
11831251 -> Timestamp
11841252 -> Timeout
1185− -> DeploymentStatus
1186−newStatus ExitSuccess ArchivePending _ _ = Archived
1187−newStatus ExitSuccess _ _ _ = Running
1188−newStatus (ExitFailure n) Running _ _ = Failure $ failureStatusType n
1189−newStatus (ExitFailure n) CreatePending ts timeout | ts > coerce timeout =
1190− Failure $ failureStatusType n
1191−newStatus (ExitFailure n) UpdatePending ts timeout | ts > coerce timeout =
1192− Failure $ failureStatusType n
1193−newStatus (ExitFailure _) ArchivePending _ _ = ArchivePending
1194−newStatus (ExitFailure _) oldStatus _ _ = oldStatus
1253+ -> Maybe DeploymentStatusTransition
1254+statusTransition ExitSuccess ArchivePending _ _ = Just TransitionArchived
1255+statusTransition ExitSuccess Running _ _ = Nothing
1256+statusTransition ExitSuccess _ _ _ = Just TransitionCreate
1257+statusTransition (ExitFailure n) Running _ _ = Just . TransitionFailure $ failureStatusType n
1258+statusTransition (ExitFailure n) CreatePending ts timeout | ts > coerce timeout =
1259+ Just . TransitionFailure $ failureStatusType n
1260+statusTransition (ExitFailure n) UpdatePending ts timeout | ts > coerce timeout =
1261+ Just . TransitionFailure $ failureStatusType n
1262+statusTransition (ExitFailure _) _ _ _ = Nothing
11951263
11961264failureStatusType :: Int -> FailureType
11971265failureStatusType 2 = PartialAvailability
@@ -1231,14 +1299,44 @@ runShutdownHandler = takeMVar . shutdownSem
12311299-- Sends a signal to 'shutdownSem' semaphore
12321300-- if the background worker counter is 0
12331301-- and graceful shutdown is activated.
1234−runBgWorker :: AppState -> IO () -> IO ()
1235−runBgWorker state act = void $ forkFinally act' cleanup
1302+runBgWorker :: (MonadBaseControl IO m, MonadReader AppState m) => m () -> m ()
1303+runBgWorker act = void $ L.forkFinally act' cleanup
12361304 where
1237− act' =
1238− atomicModifyIORef' (bgWorkersCounter state) (\c -> (c + 1, c)) >> act
1305+ act' = do
1306+ state <- ask
1307+ liftBase (atomicModifyIORef' (bgWorkersCounter state) (\c -> (c + 1, c))) >> act
12391308 cleanup _ = do
1240− c <- atomicModifyIORef' (bgWorkersCounter state) (\c -> (c - 1, c - 1))
1241− gracefulShutdown <- readIORef (gracefulShutdownActivated state)
1242− if gracefulShutdown && c == 0
1243− then putMVar (shutdownSem state) ()
1244− else pure ()
1309+ state <- ask
1310+ liftBase $ do
1311+ c <- atomicModifyIORef' (bgWorkersCounter state) (\c -> (c - 1, c - 1))
1312+ gracefulShutdown <- readIORef (gracefulShutdownActivated state)
1313+ if gracefulShutdown && c == 0
1314+ then putMVar (shutdownSem state) ()
1315+ else pure ()
1316+
1317+-- | Same as 'runBgWorker' but also locks the specified deployment.
1318+runDeploymentBgWorker
1319+ :: (MonadBaseControl IO m, MonadReader AppState m, MonadError ServerError m)
1320+ => Maybe DeploymentStatus -> DeploymentName -> m () -> m ()
1321+runDeploymentBgWorker newS dName m = do
1322+ st <- ask
1323+ (err :: MVar (Either ServerError ())) <- L.newEmptyMVar
1324+ runBgWorker $ withLockedDeployment
1325+ dName
1326+ (L.putMVar err $ Left err409 {errBody = "The deployment is currently being processed."})
1327+ ( do
1328+ let
1329+ ok = do
1330+ L.putMVar err $ Right ()
1331+ liftBase (sendReloadEvent st)
1332+ m
1333+ case newS of
1334+ Just newS' ->
1335+ runExceptT (assertDeploymentTransitionPossibleS dName newS') >>= \case
1336+ Left e -> L.putMVar err $ Left e
1337+ Right () -> ok
1338+ Nothing -> ok
1339+ )
1340+ L.readMVar err >>= \case
1341+ Left e -> throwError e
1342+ Right () -> return ()
octopod-backend/src/Octopod/Server/ControlScriptUtils.hs+84−9
@@ -6,13 +6,27 @@ This module contains control script utils.
66-}
77
88
9−module Octopod.Server.ControlScriptUtils where
9+module Octopod.Server.ControlScriptUtils
10+ ( infoCommandArgs
11+ , notificationCommandArgs
12+ , runCommand
13+ , runCommandWithoutPipes
14+ , runCommandArgs
15+ , runCommandArgs'
16+ ) where
1017
1118
19+import Control.Monad.Base
20+import Control.Monad.Reader
21+import qualified Data.ByteString.Lazy as TL
1222import Data.Coerce
13−import Data.Text
14−
15−
23+import Data.Generics.Product.Typed
24+import qualified Data.Text as T
25+import qualified Data.Text.Encoding as T
26+import Octopod.Server.Logger
27+import System.Exit
28+import System.Log.FastLogger
29+import System.Process.Typed
1630import Types
1731
1832
@@ -24,8 +38,69 @@ infoCommandArgs
2438 -> DeploymentName
2539 -> ControlScriptArgs
2640infoCommandArgs pName domain ns dName =
27− ControlScriptArgs $
28− [ "--project-name", unpack . coerce $ pName
29− , "--base-domain", unpack . coerce $ domain
30− , "--namespace", unpack . coerce $ ns
31− , "--name", unpack . coerce $ dName ]
41+ ControlScriptArgs
42+ [ "--project-name", T.unpack . coerce $ pName
43+ , "--base-domain", T.unpack . coerce $ domain
44+ , "--namespace", T.unpack . coerce $ ns
45+ , "--name", T.unpack . coerce $ dName ]
46+
47+notificationCommandArgs
48+ ::
49+ ( MonadReader r m
50+ , HasType Namespace r
51+ , HasType ProjectName r
52+ , HasType Domain r
53+ )
54+ => DeploymentName
55+ -> DeploymentStatus
56+ -- ^ Previous status
57+ -> DeploymentStatus
58+ -- ^ New status
59+ -> m ControlScriptArgs
60+notificationCommandArgs dName old new = do
61+ (Namespace namespace) <- asks getTyped
62+ (ProjectName projectName) <- asks getTyped
63+ (Domain domain) <- asks getTyped
64+ return $ ControlScriptArgs
65+ [ "--project-name", T.unpack projectName
66+ , "--base-domain", T.unpack domain
67+ , "--namespace", T.unpack namespace
68+ , "--name", T.unpack . coerce $ dName
69+ , "--tag", T.unpack . coerce $ dName
70+ , "--old-status", show old
71+ , "--new-status", show new
72+ ]
73+
74+runCommandArgs
75+ :: (MonadReader r m, MonadBase IO m, HasType TimedFastLogger r)
76+ => (r -> Command) -> ControlScriptArgs -> m (ExitCode, Stdout, Stderr)
77+runCommandArgs f args = do
78+ cmd <- asks f
79+ runCommandArgs' cmd args
80+
81+runCommandArgs'
82+ :: (MonadBase IO m, HasType TimedFastLogger r, MonadReader r m)
83+ => Command -> ControlScriptArgs -> m (ExitCode, Stdout, Stderr)
84+runCommandArgs' (Command cmd) (ControlScriptArgs args) = do
85+ logger <- asks (getTyped @TimedFastLogger)
86+ let logText = T.unwords (cmd : fmap T.pack args)
87+ liftBase $ logInfo logger $ "calling: " <> logText
88+ res@(ec, _, _) <- runCommand (T.unpack cmd) args
89+ liftBase $ logInfo logger
90+ $ "calling `" <> logText <> "` exited with: " <> show' ec
91+ return res
92+
93+-- | Helper to run command with pipes.
94+runCommand :: MonadBase IO m => FilePath -> [String] -> m (ExitCode, Stdout, Stderr)
95+runCommand cmd args = do
96+ (ec, out, err) <- liftBase . readProcess $ proc cmd args
97+ pure
98+ ( ec
99+ , Stdout . T.decodeUtf8 . TL.toStrict $ out
100+ , Stderr . T.decodeUtf8 . TL.toStrict $ err
101+ )
102+
103+-- | Helper to run command without pipes.
104+runCommandWithoutPipes :: FilePath -> [String] -> IO ExitCode
105+runCommandWithoutPipes cmd args =
106+ withProcessWait (proc cmd args) waitExitCode
octopod-backend/src/Octopod/Server/Logger.hs+4
@@ -8,6 +8,7 @@ module Octopod.Server.Logger where
88
99import Data.ByteString
1010import Data.Text
11+import qualified Data.Text as T
1112import System.Log.FastLogger
1213
1314-- | Creates new logger.
@@ -32,3 +33,6 @@ logWithSeverity l severity msg = l $ \ft -> metadata ft <> message
3233 metadata :: ByteString -> LogStr
3334 metadata ft = foldMap toLogStr ["[", ft, " ", severity, "] "]
3435 message = toLogStr msg <> toLogStr ("\n" :: ByteString)
36+
37+show' :: Show a => a -> Text
38+show' = T.pack . show
octopod-common/src/Common/Types.hs+18−2
@@ -15,6 +15,7 @@ import Data.String
1515import Data.Text as T
1616import Data.Traversable
1717import Deriving.Aeson.Stock
18+import GHC.Records
1819import Web.HttpApiData
1920
2021-- | Deployment override.
@@ -101,6 +102,19 @@ data FailureType
101102 deriving (Generic, Read, Show, Eq)
102103 deriving (FromJSON, ToJSON) via Snake FailureType
103104
105+data PreciseDeploymentStatus
106+ = DeploymentPending { recordedStatus :: DeploymentStatus }
107+ -- ^ The deployment is currently being processed by the server
108+ | DeploymentNotPending { recordedStatus :: DeploymentStatus }
109+ deriving (Generic, Read, Show, Eq)
110+ deriving (FromJSON, ToJSON) via Snake PreciseDeploymentStatus
111+
112+archivedStatuses :: [DeploymentStatus]
113+archivedStatuses = [ArchivePending, Archived]
114+
115+isArchivedStatus :: DeploymentStatus -> Bool
116+isArchivedStatus = (`elem` archivedStatuses)
117+
104118data Deployment = Deployment
105119 { name :: DeploymentName
106120 , tag :: DeploymentTag
@@ -140,8 +154,7 @@ data DeploymentInfo = DeploymentInfo
140154
141155data DeploymentFullInfo = DeploymentFullInfo
142156 { deployment :: Deployment
143− , status :: DeploymentStatus
144− , archived :: Bool
157+ , status :: PreciseDeploymentStatus
145158 , metadata :: [DeploymentMetadata]
146159 , createdAt :: Int
147160 , updatedAt :: Int
@@ -149,6 +162,9 @@ data DeploymentFullInfo = DeploymentFullInfo
149162 deriving (Generic, Show, Eq)
150163 deriving (FromJSON, ToJSON) via Snake DeploymentFullInfo
151164
165+isDeploymentArchived :: DeploymentFullInfo -> Bool
166+isDeploymentArchived = isArchivedStatus . recordedStatus . getField @"status"
167+
152168data DeploymentUpdate = DeploymentUpdate
153169 { newTag :: DeploymentTag
154170 , newAppOverrides :: ApplicationOverrides
octopod-frontend/src/Frontend/API.hs+2−2
@@ -21,7 +21,7 @@ import GHC.TypeLits
2121import Reflex.Dom as R
2222import Servant.API as S
2323import Servant.Reflex as SR
24−import Servant.Reflex.Extra ()
24+import Servant.Reflex.Extra
2525
2626import Common.Types
2727import Frontend.GHCJS
@@ -140,5 +140,5 @@ processResp
140140processResp respEv =
141141 let
142142 respOkEv = fmapMaybe reqSuccess respEv
143− errEv = fmapMaybe reqFailure respEv
143+ errEv = fmapMaybe reqErrorBody respEv
144144 in (respOkEv, () <$ errEv)
octopod-frontend/src/Frontend/Utils.hs+9−8
@@ -351,18 +351,19 @@ formatPosixToDateTime = pack
351351 . intToUTCTime
352352
353353-- | Widget displaying the current deployment status.
354−statusWidget :: MonadWidget t m => Dynamic t DeploymentStatus -> m ()
354+statusWidget :: MonadWidget t m => Dynamic t PreciseDeploymentStatus -> m ()
355355statusWidget stDyn = do
356356 stDyn' <- holdUniqDyn stDyn
357357 let
358− pendingWidget = divClass "loading loading--status-alike"
358+ loadingWidget = divClass "loading loading--status-alike"
359359 dyn_ $ stDyn' <&> \case
360− Running -> divClass "status status--success" $ text "Running"
361− Failure _ -> divClass "status status--failure" $ text "Failure"
362− CreatePending -> pendingWidget $ text "Creating..."
363− UpdatePending -> pendingWidget $ text "Updating..."
364− ArchivePending -> pendingWidget $ text "Archiving..."
365− Archived -> divClass "status status--archived" $ text "Archived"
360+ DeploymentPending _ -> divClass "status status--pending" $ text "Pending..."
361+ DeploymentNotPending Running -> divClass "status status--success" $ text "Running"
362+ DeploymentNotPending (Failure _) -> divClass "status status--failure" $ text "Failure"
363+ DeploymentNotPending CreatePending -> loadingWidget $ text "Creating..."
364+ DeploymentNotPending UpdatePending -> loadingWidget $ text "Updating..."
365+ DeploymentNotPending ArchivePending -> loadingWidget $ text "Archiving..."
366+ DeploymentNotPending Archived -> divClass "status status--archived" $ text "Archived"
366367
367368-- | Text input field with label.
368369octopodTextInput
octopod-frontend/src/Main.hs+1−1
@@ -13,9 +13,9 @@ import Reflex.Dom
1313import Servant.Reflex
1414
1515import Common.Types as CT
16−import qualified Data.Semigroup as S
1716import Control.Monad.Reader
1817import Data.Maybe
18+import qualified Data.Semigroup as S
1919import Frontend.API
2020import Frontend.GHCJS
2121import Frontend.Route
octopod-frontend/src/Page/Deployment.hs+7−6
@@ -28,6 +28,7 @@ import Frontend.Utils
2828import Page.ClassicPopup
2929import Page.Elements.Links
3030import Page.Popup.EditDeployment
31+import Servant.Reflex.Extra
3132
3233-- | The root widget of a deployment page. It requests the deployment data.
3334-- If the request fails it shows an error,
@@ -100,9 +101,9 @@ deploymentHead dfiDyn sentEv =
100101 divClass "page__head" $ do
101102 let dname = dfiDyn <^.> dfiName . coerced
102103 elClass "h1" "page__heading title" $ dynText dname
103− (editEv, archEv) <- hold2 . dyn $ dfiDyn <&> \dfi -> case dfi ^. field @"archived" of
104− True -> mdo
105− let btnState = not $ isPending $ dfi ^. field @"status"
104+ (editEv, archEv) <- hold2 . dyn $ dfiDyn <&> \dfi -> if isDeploymentArchived dfi
105+ then mdo
106+ let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
106107 btnEnabledDyn <- holdDyn btnState $ leftmost [ False <$ btnEv, sentEv ]
107108 btnEv <- aButtonClassEnabled
108109 "page__action button button--secondary button--restore \
@@ -111,8 +112,8 @@ deploymentHead dfiDyn sentEv =
111112 btnEnabledDyn
112113 void $ restoreEndpoint (Right . coerce <$> dname) btnEv
113114 pure (never, never)
114− False -> mdo
115− let btnState = not $ isPending $ dfi ^. field @"status"
115+ else mdo
116+ let btnState = not $ isPending . recordedStatus $ dfi ^. field @"status"
116117 btnEnabledDyn <- holdDyn btnState $ not <$> sentEv
117118 editEv <- aButtonClassEnabled
118119 "page__action button button--edit popup-handler"
@@ -235,7 +236,7 @@ actionsTable updEv nameDyn = do
235236 respEv <- infoEndpoint (Right <$> nameDyn) pb
236237 let
237238 okEv = join . fmap logs <$> fmapMaybe reqSuccess respEv
238− errEv = fmapMaybe reqFailure respEv
239+ errEv = fmapMaybe reqErrorBody respEv
239240 el "table" $ do
240241 actionsTableHead
241242 widgetHold_ actionsTableLoading $ leftmost
octopod-frontend/src/Page/Deployments.hs+4−3
@@ -42,6 +42,7 @@ import Page.Elements.Links
4242import Page.Popup.EditDeployment
4343import Page.Popup.NewDeployment
4444import Reflex.MultiEventWriter.Class
45+import Servant.Reflex.Extra
4546
4647
4748-- | The root widget of the deployments list page.
@@ -174,10 +175,10 @@ deploymentsListWidget updAllEv termDyn ds = dataWidgetWrapper $ mdo
174175 updRespEv <- listEndpoint $ leftmost [updAllEv, () <$ retryEv]
175176 let
176177 okUpdEv = fmapMaybe reqSuccess updRespEv
177− errUpdEv = fmapMaybe reqFailure updRespEv
178+ errUpdEv = fmapMaybe reqErrorBody updRespEv
178179 dsDyn <- holdDyn ds okUpdEv
179180 let
180− isArchived = view (field @"deployment" . field @"archived")
181+ isArchived = isDeploymentArchived . view #deployment
181182 filteredDyn = ffor2 termDyn dsDyn $ \term ds' ->
182183 mapMaybe (searchDeployments . T.filter (not . isSpace) $ term) ds'
183184 (archivedDsDyn, activeDsDyn) = splitDynPure $ L.partition isArchived
@@ -295,7 +296,7 @@ activeDeploymentWidget clickedEv dDyn' = do
295296 text $ formatPosixToDate updatedAt
296297 el "td" $ do
297298 let
298− enabled = not $ isPending status
299+ enabled = not . isPending . recordedStatus $ status
299300 elId = "deployment_row_" <> unDeploymentName dName
300301 btn = elAttr "button"
301302 ( "class" =: "drop__handler"
octopod-frontend/src/Page/Popup/EditDeployment.hs+2−1
@@ -27,6 +27,7 @@ import Data.Text (Text)
2727import Frontend.API
2828import Frontend.Utils
2929import Servant.Reflex
30+import Servant.Reflex.Extra
3031
3132-- | The root function for \"edit deployment\" sidebar.
3233editDeploymentPopup
@@ -84,7 +85,7 @@ editDeploymentPopupBody dfi errEv = divClass "popup__content" $
8485 divClass "deployment" $ mdo
8586 let
8687 commandResponseEv = fmapMaybe commandResponse errEv
87− appErrEv = R.difference (fmapMaybe reqFailure errEv) commandResponseEv
88+ appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
8889 dfiTag = dfi ^. field @"deployment" . field @"tag" . coerced . to Just
8990 dfiAppVars = dfi ^. field @"deployment" . field @"appOverrides" . coerced
9091 dfiDeploymentVars =
octopod-frontend/src/Page/Popup/NewDeployment.hs+2−1
@@ -24,6 +24,7 @@ import Common.Validation (isNameValid)
2424import Frontend.API
2525import Frontend.Utils
2626import Servant.Reflex
27+import Servant.Reflex.Extra
2728
2829
2930-- | The root function for \"new deployment\" sidebar.
@@ -74,7 +75,7 @@ newDeploymentPopupBody errEv = divClass "popup__content" $
7475 divClass "deployment" $ mdo
7576 let
7677 commandResponseEv = fmapMaybe commandResponse errEv
77− appErrEv = R.difference (fmapMaybe reqFailure errEv) commandResponseEv
78+ appErrEv = R.difference (fmapMaybe reqErrorBody errEv) commandResponseEv
7879 nameErrEv = getNameError commandResponseEv nameDyn
7980 tagErrEv = getTagError commandResponseEv tagDyn
8081 errorHeader appErrEv
octopod-frontend/src/Servant/Reflex/Extra.hs+20−6
@@ -1,13 +1,16 @@
11{-# OPTIONS_GHC -Wno-orphans #-}
22
3−module Servant.Reflex.Extra () where
3+module Servant.Reflex.Extra
4+ ( reqErrorBody
5+ ) where
46
7+import Data.Functor.Identity
8+import Data.Proxy
9+import Data.Text (Text)
510import qualified Data.Text.Encoding as E
6−import Servant.Reflex
7−import Servant.API
8−import Reflex.Dom
9−import Data.Proxy
10−import Data.Functor.Identity
11+import Reflex.Dom
12+import Servant.API
13+import Servant.Reflex
1114
1215instance (SupportsServantReflex t m)
1316 => HasClient t m (NoContentVerb 'GET) tag where
@@ -15,3 +18,14 @@ instance (SupportsServantReflex t m)
1518 clientWithRouteAndResultHandler Proxy _ _ req baseurl opts wrap' trigs =
1619 wrap' =<< fmap runIdentity <$> performRequestsNoBody method (constDyn $ Identity req) baseurl opts trigs
1720 where method = E.decodeUtf8 $ reflectMethod (Proxy :: Proxy 'GET)
21+
22+reqErrorBody :: ReqResult tag a -> Maybe Text
23+reqErrorBody (ResponseFailure _ _ XhrResponse {_xhrResponse_responseText = Just b})
24+ = Just b
25+reqErrorBody (ResponseFailure _ _ XhrResponse {_xhrResponse_response = Just (XhrResponseBody_Default t)}) =
26+ Just t
27+reqErrorBody (ResponseFailure _ _ XhrResponse {_xhrResponse_response = Just (XhrResponseBody_Text t)}) =
28+ Just t
29+reqErrorBody (ResponseFailure _ _ XhrResponse {_xhrResponse_response = Just (XhrResponseBody_ArrayBuffer t)}) =
30+ Just . E.decodeUtf8 $ t
31+reqErrorBody x = reqFailure x