summaryrefslogtreecommitdiff
path: root/Omni/Deploy/Deployer.hs
blob: 7e57b3457388177a9d0ab2fd2c54280e66decc39 (plain)
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
314
315
316
317
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# 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)
  deriving anyclass (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)

  -- Check what's actually running in systemd instead of in-memory state
  runningPath <- Systemd.getRunningStorePath name

  if runningPath == Just path
    then do
      Log.info ["deployer", name, "already at", path]
      pure (True, st)
    else do
      Log.info ["deployer", "deploying", name, fromMaybe "new" runningPath, "->", 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