1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
|
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | Mini-PaaS deployer service.
--
-- Polls manifest from S3, compares to local state, pulls changed closures,
-- generates systemd units, updates Caddy routes, and manages GC roots.
--
-- : out biz-deployer
-- : dep aeson
-- : dep amazonka
-- : dep amazonka-core
-- : dep amazonka-s3
-- : dep directory
-- : dep http-conduit
-- : dep http-types
-- : dep time
module Omni.Deploy.Deployer
( DeployerState (..),
loadState,
saveState,
pullClosure,
createGcRoot,
removeGcRoot,
deployService,
removeService,
reconcile,
runOnce,
runDaemon,
stateDir,
stateFile,
gcrootsDir,
main,
test,
)
where
import Alpha
import qualified Control.Concurrent as Concurrent
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Text as Text
import qualified Network.HostName as HostName
import qualified Omni.Cli as Cli
import qualified Omni.Deploy.Caddy as Caddy
import qualified Omni.Deploy.Manifest as Manifest
import qualified Omni.Deploy.Systemd as Systemd
import qualified Omni.Log as Log
import qualified Omni.Test as Test
import qualified System.Directory as Dir
import qualified System.Exit as Exit
import System.FilePath ((</>))
import qualified System.Process as Process
stateDir :: FilePath
stateDir = "/var/lib/biz-deployer"
stateFile :: FilePath
stateFile = stateDir </> "state.json"
gcrootsDir :: FilePath
gcrootsDir = "/nix/var/nix/gcroots/biz"
s3Url :: String
s3Url = "s3://omni-nix-cache?profile=digitalocean&scheme=https&endpoint=nyc3.digitaloceanspaces.com"
newtype DeployerState = DeployerState
{ stateServices :: Map Text Text
}
deriving (Show, Eq, Generic, Aeson.FromJSON, Aeson.ToJSON)
emptyState :: DeployerState
emptyState = DeployerState mempty
loadState :: IO DeployerState
loadState = do
exists <- Dir.doesFileExist stateFile
if exists
then do
contents <- BL.readFile stateFile
case Aeson.eitherDecode contents of
Left _ -> pure emptyState
Right s -> pure s
else pure emptyState
saveState :: DeployerState -> IO ()
saveState st = do
Dir.createDirectoryIfMissing True stateDir
BL.writeFile stateFile (Aeson.encode st)
getHostname :: IO Text
getHostname = HostName.getHostName /> Text.pack
pullClosure :: Text -> IO Bool
pullClosure storePath = do
-- First check if the path already exists locally
exists <- Dir.doesDirectoryExist (Text.unpack storePath)
if exists
then do
Log.info ["deployer", "path already exists locally", storePath]
pure True
else do
(exitCode, _, stderr') <-
Process.readProcessWithExitCode
"nix"
[ "copy",
"--extra-experimental-features",
"nix-command",
"--from",
s3Url,
Text.unpack storePath
]
""
case exitCode of
Exit.ExitSuccess -> pure True
Exit.ExitFailure _ -> do
Log.fail ["deployer", "pull failed", storePath, Text.pack stderr']
pure False
createGcRoot :: Text -> Text -> IO FilePath
createGcRoot serviceName storePath = do
Dir.createDirectoryIfMissing True gcrootsDir
let rootPath = gcrootsDir </> Text.unpack serviceName
exists <- Dir.doesPathExist rootPath
when exists <| Dir.removeFile rootPath
Dir.createFileLink (Text.unpack storePath) rootPath
pure rootPath
removeGcRoot :: Text -> IO ()
removeGcRoot serviceName = do
let rootPath = gcrootsDir </> Text.unpack serviceName
exists <- Dir.doesPathExist rootPath
when exists <| Dir.removeFile rootPath
deployService :: Manifest.Service -> DeployerState -> IO (Bool, DeployerState)
deployService svc st = do
let name = Manifest.serviceName svc
path = Manifest.storePath (Manifest.serviceArtifact svc)
currentPath = Map.lookup name (stateServices st)
if currentPath == Just path
then do
Log.info ["deployer", name, "already at", path]
pure (True, st)
else do
Log.info ["deployer", "deploying", name, fromMaybe "new" currentPath, "->", path]
pulled <- pullClosure path
if don't pulled
then do
Log.fail ["deployer", "failed to pull", name]
pure (False, st)
else do
_ <- createGcRoot name path
_ <- Systemd.writeUnit Systemd.servicesDir svc
_ <- Systemd.createSymlink Systemd.servicesDir "/run/systemd/system" svc
Systemd.reloadAndRestart name
case Manifest.serviceHttp svc of
Just _ -> void <| Caddy.upsertRoute Caddy.caddyAdmin svc
Nothing -> pure ()
let newSt = st {stateServices = Map.insert name path (stateServices st)}
Log.good ["deployer", "deployed", name]
pure (True, newSt)
removeService :: Text -> DeployerState -> IO DeployerState
removeService svcName st = do
Log.info ["deployer", "removing", svcName]
Systemd.stopAndDisable svcName
Systemd.removeUnit Systemd.servicesDir "/run/systemd/system" svcName
_ <- Caddy.deleteRoute Caddy.caddyAdmin svcName
removeGcRoot svcName
pure <| st {stateServices = Map.delete svcName (stateServices st)}
reconcile :: Manifest.Manifest -> DeployerState -> IO DeployerState
reconcile manifest st = do
hostname <- getHostname
let mfstServices =
Set.fromList
[ Manifest.serviceName svc
| svc <- Manifest.manifestServices manifest,
hostname `elem` Manifest.serviceHosts svc
]
localServices = Set.fromList <| Map.keys (stateServices st)
toRemove = localServices Set.\\ mfstServices
st' <- foldM (flip removeService) st (Set.toList toRemove)
foldM
( \s svc ->
if hostname `elem` Manifest.serviceHosts svc
then do
(_, newSt) <- deployService svc s
pure newSt
else pure s
)
st'
(Manifest.manifestServices manifest)
runOnce :: IO Bool
runOnce = do
Log.info ["deployer", "starting reconciliation"]
manifest <- Manifest.loadManifestFromS3
case manifest of
Nothing -> do
Log.warn ["deployer", "no manifest found in S3"]
pure False
Just m -> do
st <- loadState
st' <- reconcile m st
saveState st'
Log.good ["deployer", "reconciliation complete"]
pure True
runDaemon :: Int -> IO ()
runDaemon intervalSeconds = do
Log.info ["deployer", "starting daemon", "interval=" <> tshow intervalSeconds <> "s"]
forever <| do
result <- try runOnce
case result of
Left (e :: SomeException) ->
Log.fail ["deployer", "error in reconciliation", tshow e]
Right _ -> pure ()
Concurrent.threadDelay (intervalSeconds * 1_000_000)
help :: Cli.Docopt
help =
[Cli.docopt|
biz-deployer - Mini-PaaS deployment agent
Usage:
biz-deployer test
biz-deployer once
biz-deployer daemon [<interval>]
biz-deployer status
biz-deployer (-h | --help)
Commands:
test Run tests
once Run a single reconciliation cycle
daemon Run as daemon with interval in seconds (default: 300)
status Show current deployer state
Options:
-h --help Show this help
|]
move :: Cli.Arguments -> IO ()
move args
| args `Cli.has` Cli.command "once" = do
success <- runOnce
if success
then Exit.exitSuccess
else Exit.exitWith (Exit.ExitFailure 1)
| args `Cli.has` Cli.command "daemon" = do
let interval =
Cli.getArg args (Cli.argument "interval")
+> readMaybe
|> fromMaybe 300
runDaemon interval
| args `Cli.has` Cli.command "status" = do
st <- loadState
BL.putStr <| Aeson.encode st
putStrLn ("" :: String)
| otherwise = do
Log.fail ["deployer", "unknown command"]
Exit.exitWith (Exit.ExitFailure 1)
test :: Test.Tree
test =
Test.group
"Omni.Deploy.Deployer"
[ test_emptyState,
test_stateJsonRoundtrip
]
test_emptyState :: Test.Tree
test_emptyState =
Test.unit "empty state has no services" <| do
let st = emptyState
Map.null (stateServices st) Test.@=? True
test_stateJsonRoundtrip :: Test.Tree
test_stateJsonRoundtrip =
Test.unit "state JSON roundtrip" <| do
let testState =
DeployerState
{ stateServices =
Map.fromList
[ ("svc-a", "/nix/store/abc"),
("svc-b", "/nix/store/xyz")
]
}
let encoded = Aeson.encode testState
case Aeson.eitherDecode encoded of
Left err -> Test.assertFailure err
Right decoded -> stateServices decoded Test.@=? stateServices testState
main :: IO ()
main = Cli.main <| Cli.Plan help move test pure
|