on master help sign in
Add obelisk-command
commit5f025af7c055c959df4a8863f602dadcad594c2f authorRyan Trinkle <ryan.trinkle@gmail.com> dateMon 1 Jan 2018, 18:32 (9 years ago) parentb94938f
6 files changed+462
command/obelisk-command.cabal+32 command/src-bin/ob.hs+4 command/src/Obelisk/Command.hs+78 command/src/Obelisk/Command/Project.hs+85 command/src/Obelisk/Command/Thunk.hs+243 default.nix+20
command/obelisk-command.cabalnew file+32
@@ -0,0 +1,32 @@
1+name: obelisk-command
2+version: 0.1
3+cabal-version: >= 1.8
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends:
9+ aeson
10+ , aeson-pretty
11+ , base
12+ , bytestring
13+ , directory
14+ , filepath
15+ , github
16+ , hit
17+ , network-uri
18+ , optparse-applicative
19+ , process
20+ , text
21+ , unix
22+ , yaml
23+ exposed-modules:
24+ Obelisk.Command
25+ Obelisk.Command.Project
26+ Obelisk.Command.Thunk
27+ ghc-options: -Wall
28+
29+executable ob
30+ main-is: src-bin/ob.hs
31+ build-depends: base, obelisk-command
32+ ghc-options: -Wall -O2
command/src-bin/ob.hsnew file+4
@@ -0,0 +1,4 @@
1+import qualified Obelisk.Command
2+
3+main :: IO ()
4+main = Obelisk.Command.main
command/src/Obelisk/Command.hsnew file+78
@@ -0,0 +1,78 @@
1+{-# LANGUAGE LambdaCase #-}
2+module Obelisk.Command where
3+
4+import Obelisk.Command.Project
5+import Obelisk.Command.Thunk
6+import Options.Applicative
7+import System.Environment
8+import System.Posix.Process
9+
10+data Args = Args
11+ { _args_handoff :: Bool
12+ , _args_command :: ObCommand
13+ }
14+
15+args :: Parser Args
16+args = Args <$> noHandoff <*> obCommand
17+
18+noHandoff :: Parser Bool
19+noHandoff = flag True False $ mconcat
20+ [ long "no-handoff"
21+ , help "Do not hand off execution to a project-specific implementation of this command"
22+ ]
23+
24+argsInfo :: ParserInfo Args
25+argsInfo = info (args <**> helper) $ mconcat
26+ [ fullDesc
27+ , progDesc "Manage Obelisk projects"
28+ ]
29+
30+data ObCommand
31+ = ObCommand_Init
32+ | ObCommand_Dev
33+ | ObCommand_Thunk ThunkCommand
34+
35+obCommand :: Parser ObCommand
36+obCommand = hsubparser $ mconcat
37+ [ command "init" $ info (pure ObCommand_Init) $ progDesc "Initialize an Obelisk project"
38+ , command "dev" $ info (pure ObCommand_Dev) $ progDesc "Run the current project in development mode"
39+ , command "thunk" $ info (ObCommand_Thunk <$> thunkCommand) $ progDesc "Manipulate thunk directories"
40+ ]
41+
42+data ThunkCommand
43+ = ThunkCommand_Update [FilePath]
44+
45+thunkCommand :: Parser ThunkCommand
46+thunkCommand = hsubparser $ mconcat
47+ [ command "update" $ info (ThunkCommand_Update <$> some (strArgument (action "directory"))) $ progDesc "Update a thunk to the latest revision available"
48+ ]
49+
50+main :: IO ()
51+main = do
52+ myArgs <- getArgs
53+ --TODO: We'd like to actually use the parser to determine whether to hand off,
54+ --but in the case where this implementation of 'ob' doesn't support all
55+ --arguments being passed along, this could fail. For now, we don't bother
56+ --with optparse-applicative until we've done the handoff.
57+
58+ --TODO: Doing handoff before command-line argument completion is currently too
59+ --slow; we need to make it faster, probably by avoiding calling nix-build when
60+ --we know things are fresh.
61+ let go = ob . _args_command =<< execParser argsInfo
62+ case myArgs of
63+ "--no-handoff" : _ -> go -- If we've been told not to hand off, don't hand off
64+ _ -> do
65+ findProjectObeliskCommand "." >>= \case
66+ Nothing -> go -- If we aren't in a project, just run ourselves
67+ Just impl -> do
68+ -- Invoke the real implementation, using --no-handoff to prevent infinite recursion
69+ executeFile impl False ("--no-handoff" : myArgs) Nothing
70+
71+ob :: ObCommand -> IO ()
72+ob = \case
73+ ObCommand_Init -> initProject "."
74+ ObCommand_Dev -> putStrLn "Dev!"
75+ ObCommand_Thunk tc -> case tc of
76+ ThunkCommand_Update thunks -> mapM_ updateThunkToLatest thunks
77+
78+--TODO: Clean up all the magic strings throughout this codebase
command/src/Obelisk/Command/Project.hsnew file+85
@@ -0,0 +1,85 @@
1+{-# LANGUAGE LambdaCase #-}
2+{-# LANGUAGE OverloadedStrings #-}
3+module Obelisk.Command.Project
4+ ( initProject
5+ , findProjectObeliskCommand
6+ ) where
7+
8+import Control.Monad
9+import Data.Bits
10+import qualified Data.ByteString.Lazy as LBS
11+import Data.Function (on)
12+import qualified Data.Text as T
13+import qualified Data.Text.IO as T
14+import Obelisk.Command.Thunk
15+import System.Directory
16+import System.Exit
17+import System.FilePath
18+import System.Posix (getFileStatus, deviceID, fileID, getRealUserID, fileOwner, fileMode)
19+import System.Process
20+
21+--TODO: Don't hardcode this
22+-- | Source for the Obelisk project
23+obeliskSource :: ThunkSource
24+obeliskSource = ThunkSource_GitHub $ GitHubSource
25+ { _gitHubSource_owner = "obsidiansystems"
26+ , _gitHubSource_repo = "obelisk"
27+ , _gitHubSource_branch = Just "master"
28+ , _gitHubSource_private = True
29+ }
30+
31+initProject :: FilePath -> IO ()
32+initProject target = do
33+ let obDir = target </> ".obelisk"
34+ createDirectory obDir
35+ createThunkWithLatest (obDir </> "impl") obeliskSource
36+
37+nixBuildDashA :: FilePath -> String -> IO FilePath
38+nixBuildDashA path attr = do
39+ -- We need to keep 'err' around, because nix-build seems to crash if it runs
40+ -- for long enough and we've let 'err' die. It's probably dying from the
41+ -- closed output pipe, but I haven't investigated. The code below that
42+ -- outputs the contents of 'err' when an error is hit is sufficient to keep it
43+ -- alive.
44+ (_, out, err, p) <- runInteractiveProcess "nix-build" --TODO: Make this package depend on nix-prefetch-url properly
45+ [ path
46+ , "-A"
47+ , attr
48+ ] Nothing Nothing
49+ waitForProcess p >>= \case
50+ ExitSuccess -> return ()
51+ _ -> LBS.putStr =<< LBS.hGetContents err
52+ T.unpack . T.strip <$> T.hGetContents out
53+
54+--TODO: Handle errors
55+--TODO: Allow the user to ignore our security concerns
56+-- | Find the Obelisk implementation for the project at the given path
57+findProjectObeliskCommand :: FilePath -> IO (Maybe FilePath)
58+findProjectObeliskCommand target = do
59+ myUid <- getRealUserID
60+ let isSecure s = fileOwner s == myUid && fileMode s .&. 0o22 == 0
61+ go this = doesDirectoryExist this >>= \case
62+ -- It's not a directory, so it can't be a project
63+ False -> go $ takeDirectory this
64+ True -> do
65+ thisStat <- getFileStatus this
66+ guard $ isSecure thisStat
67+ doesDirectoryExist (this </> ".obelisk") >>= \case
68+ True -> do
69+ let obDir = this </> ".obelisk"
70+ obDirStat <- getFileStatus obDir
71+ guard $ isSecure obDirStat
72+ let implThunk = obDir </> "impl"
73+ implThunkStat <- getFileStatus implThunk
74+ guard $ isSecure implThunkStat
75+ obeliskCommandPkg <- nixBuildDashA implThunk "command"
76+ return $ Just $ obeliskCommandPkg </> "bin" </> "ob"
77+ False -> do
78+ let next = this </> ".." -- Use ".." instead of chopping off path segments, so that if the current directory is moved during the traversal, the traversal stays consistent
79+ nextStat <- getFileStatus next
80+ let fileIdentity fs = (deviceID fs, fileID fs)
81+ isSameFileAs = (==) `on` fileIdentity
82+ if thisStat `isSameFileAs` nextStat
83+ then return Nothing -- Found a cycle; probably hit root directory
84+ else go next
85+ go target
command/src/Obelisk/Command/Thunk.hsnew file+243
@@ -0,0 +1,243 @@
1+{-# LANGUAGE DataKinds #-}
2+{-# LANGUAGE LambdaCase #-}
3+{-# LANGUAGE OverloadedStrings #-}
4+module Obelisk.Command.Thunk
5+ ( ThunkPtr (..)
6+ , ThunkRev (..)
7+ , ThunkSource (..)
8+ , GitHubSource (..)
9+ , getLatestRev
10+ , updateThunkToLatest
11+ , createThunkWithLatest
12+ ) where
13+
14+import Control.Exception
15+import Control.Monad
16+import qualified Data.ByteString.Lazy as LBS
17+import Data.Aeson as Aeson
18+import Data.Aeson.Encode.Pretty
19+import Data.Foldable
20+import Data.Git.Ref (Ref)
21+import qualified Data.Git.Ref as Ref
22+import Data.Maybe
23+import Data.Monoid
24+import Data.List.NonEmpty (NonEmpty (..))
25+import qualified Data.List.NonEmpty as NonEmpty
26+import Data.Text (Text)
27+import qualified Data.Text as T
28+import qualified Data.Text.IO as T
29+import Data.Text.Encoding
30+import Data.Yaml (parseMaybe, (.:))
31+import qualified Data.Yaml as Yaml
32+import GitHub
33+import GitHub.Endpoints.Repos.Contents (archiveForR)
34+import GitHub.Data.Name
35+import Network.URI
36+import System.Directory
37+import System.Exit
38+import System.FilePath
39+import System.Process
40+
41+-- | A reference to the exact data that a thunk should translate into
42+data ThunkPtr = ThunkPtr
43+ { _thunkPtr_rev :: ThunkRev
44+ , _thunkPtr_source :: ThunkSource
45+ }
46+ deriving (Show, Eq, Ord)
47+
48+type NixSha256 = Text --TODO: Use a smart constructor and make this actually verify itself
49+
50+-- | A specific revision of data; it may be available from multiple sources
51+data ThunkRev = ThunkRev
52+ { _thunkRev_commit :: Ref
53+ , _thunkRev_nixSha256 :: NixSha256
54+ }
55+ deriving (Show, Eq, Ord)
56+
57+-- | A location from which a thunk's data can be retrieved
58+data ThunkSource
59+ = ThunkSource_GitHub GitHubSource
60+ deriving (Show, Eq, Ord)
61+
62+data GitHubSource = GitHubSource
63+ { _gitHubSource_owner :: Name Owner
64+ , _gitHubSource_repo :: Name Repo
65+ , _gitHubSource_branch :: Maybe (Name Branch)
66+ , _gitHubSource_private :: Bool
67+ }
68+ deriving (Show, Eq, Ord)
69+
70+commitNameToRef :: Name Commit -> Ref
71+commitNameToRef (N c) = Ref.fromHex $ encodeUtf8 c
72+
73+getNixSha256ForUriUnpacked :: URI -> IO NixSha256
74+getNixSha256ForUriUnpacked uri = do
75+ (_, out, _, p) <- runInteractiveProcess "nix-prefetch-url" --TODO: Make this package depend on nix-prefetch-url properly
76+ [ "--unpack"
77+ , "--type"
78+ , "sha256"
79+ , show uri
80+ ] Nothing Nothing
81+ ExitSuccess <- waitForProcess p --TODO: Deal with errors here; usually they're HTTP errors
82+ T.strip <$> T.hGetContents out
83+
84+-- | Get the latest revision available from the given source
85+getLatestRev :: ThunkSource -> IO ThunkRev
86+getLatestRev = \case
87+ ThunkSource_GitHub s -> do
88+ auth <- getHubAuth "github.com"
89+ commitsResult <- executeRequestMaybe auth $ commitsWithOptionsForR
90+ (_gitHubSource_owner s)
91+ (_gitHubSource_repo s)
92+ (FetchAtLeast 1)
93+ $ case _gitHubSource_branch s of
94+ Nothing -> []
95+ Just b -> [CommitQuerySha $ untagName b]
96+ commitInfos <- either throwIO return commitsResult
97+ commitInfo : _ <- return $ toList commitInfos
98+ let commit = commitSha commitInfo
99+ Right archiveUri <- executeRequestMaybe auth $ archiveForR (_gitHubSource_owner s) (_gitHubSource_repo s) ArchiveFormatTarball $ Just $ untagName commit
100+ nixSha256 <- getNixSha256ForUriUnpacked archiveUri
101+ return $ ThunkRev
102+ { _thunkRev_commit = commitNameToRef commit
103+ , _thunkRev_nixSha256 = nixSha256
104+ }
105+
106+-- | Read a thunk and validate that it is only a thunk. If additional data is
107+-- present, fail.
108+readThunk :: FilePath -> IO (Maybe ThunkPtr)
109+readThunk thunkDir = do
110+ let githubJson = thunkDir </> "github.json"
111+ doesFileExist githubJson >>= \case
112+ False -> return Nothing
113+ True -> do
114+ -- Ensure that we recognize the thunk loader
115+ loader <- T.readFile $ thunkDir </> "default.nix"
116+ guard $ loader `elem` gitHubStandaloneLoaders
117+
118+ -- Ensure that there aren't any other files in the thunk
119+ files <- listDirectory thunkDir
120+ guard $ length files == 2
121+
122+ txt <- LBS.readFile githubJson
123+ let p v = do
124+ owner <- v Aeson..: "owner"
125+ repo <- v Aeson..: "repo"
126+ rev <- v Aeson..: "rev"
127+ sha256 <- v Aeson..: "sha256"
128+ branch <- v Aeson..:! "branch"
129+ mPrivate <- v Aeson..:! "private"
130+ return $ ThunkPtr
131+ { _thunkPtr_rev = ThunkRev
132+ { _thunkRev_commit = Ref.fromHexString rev
133+ , _thunkRev_nixSha256 = sha256
134+ }
135+ , _thunkPtr_source = ThunkSource_GitHub $ GitHubSource
136+ { _gitHubSource_owner = owner
137+ , _gitHubSource_repo = repo
138+ , _gitHubSource_branch = branch
139+ , _gitHubSource_private = fromMaybe False mPrivate
140+ }
141+ }
142+ return $ parseMaybe p =<< decode txt
143+
144+overwriteThunk :: FilePath -> ThunkPtr -> IO ()
145+overwriteThunk target thunk = do
146+ -- Ensure that this directory is a valid thunk (i.e. so we aren't losing any data)
147+ Just _ <- readThunk target
148+
149+ --TODO: Is there a safer way to do this overwriting?
150+ removeDirectoryRecursive target
151+ createThunk target thunk
152+
153+createThunk :: FilePath -> ThunkPtr -> IO ()
154+createThunk target thunk = do
155+ createDirectory target
156+ case _thunkPtr_source thunk of
157+ ThunkSource_GitHub s -> do
158+ T.writeFile (target </> "default.nix") $ NonEmpty.head gitHubStandaloneLoaders
159+ LBS.writeFile (target </> "github.json") $
160+ -- It's important that formatting be very consistent here, because
161+ -- otherwise when people update thunks, their patches will be messy
162+ let cfg = defConfig
163+ { confIndent = Spaces 2
164+ , confCompare = keyOrder
165+ [ "owner"
166+ , "repo"
167+ , "branch"
168+ , "private"
169+ , "rev"
170+ , "sha256"
171+ ] <> compare
172+ , confTrailingNewline = True
173+ }
174+ in encodePretty' cfg $ Aeson.object $ catMaybes
175+ [ Just $ "owner" .= _gitHubSource_owner s
176+ , Just $ "repo" .= _gitHubSource_repo s
177+ , case _gitHubSource_branch s of
178+ Just b -> Just $ "branch" .= b
179+ Nothing -> Nothing
180+ , if _gitHubSource_private s
181+ then Just $ "private" .= True
182+ else Nothing
183+ , Just $ "rev" .= Ref.toHexString (_thunkRev_commit (_thunkPtr_rev thunk))
184+ , Just $ "sha256" .= _thunkRev_nixSha256 (_thunkPtr_rev thunk)
185+ ]
186+
187+createThunkWithLatest :: FilePath -> ThunkSource -> IO ()
188+createThunkWithLatest target s = do
189+ rev <- getLatestRev s
190+ createThunk target $ ThunkPtr
191+ { _thunkPtr_source = s
192+ , _thunkPtr_rev = rev
193+ }
194+
195+updateThunkToLatest :: FilePath -> IO ()
196+updateThunkToLatest target = do
197+ Just ptr <- readThunk target
198+ let src = _thunkPtr_source ptr
199+ rev <- getLatestRev src
200+ overwriteThunk target $ ThunkPtr
201+ { _thunkPtr_source = src
202+ , _thunkPtr_rev = rev
203+ }
204+
205+-- | All recognized github standalone loaders, ordered from newest to oldest.
206+-- This tool will only ever produce the newest one when it writes a thunk.
207+gitHubStandaloneLoaders :: NonEmpty Text
208+gitHubStandaloneLoaders =
209+ gitHubStandaloneLoaderV2 :|
210+ [ gitHubStandaloneLoaderV1
211+ ]
212+
213+gitHubStandaloneLoaderV1 :: Text
214+gitHubStandaloneLoaderV1 = T.unlines
215+ [ "import ((import <nixpkgs> {}).fetchFromGitHub (builtins.fromJSON (builtins.readFile ./github.json)))"
216+ ]
217+
218+gitHubStandaloneLoaderV2 :: Text
219+gitHubStandaloneLoaderV2 = T.unlines
220+ [ "# DO NOT HAND-EDIT THIS FILE" --TODO: Add something about how to get more info on Obelisk, etc.
221+ , "import ((import <nixpkgs> {}).fetchFromGitHub ("
222+ , " let json = builtins.fromJSON (builtins.readFile ./github.json);"
223+ , " in { inherit (json) owner repo rev sha256;"
224+ , " private = json.private or false;"
225+ , " }"
226+ , "))"
227+ ]
228+
229+-- | Look for authentication info from the 'hub' command
230+getHubAuth
231+ :: Text -- ^ Domain name
232+ -> IO (Maybe Auth)
233+getHubAuth domain = do
234+ hubConfig <- getXdgDirectory XdgConfig "hub"
235+ Yaml.decodeFile hubConfig >>= \case
236+ Nothing -> return Nothing
237+ Just v -> return $ flip parseMaybe v $ \v' -> do
238+ Yaml.Array domainConfigs <- v' .: domain --TODO: Determine what multiple domainConfigs means
239+ [Yaml.Object domainConfig] <- return $ toList domainConfigs
240+ token <- domainConfig .: "oauth_token"
241+ return $ OAuth $ encodeUtf8 token
242+
243+--TODO: when checking something out, make a shallow clone
default.nix+20
@@ -1,10 +1,30 @@
11{ system ? builtins.currentSystem }:
22let reflex-platform = import ./reflex-platform { inherit system; };
33 pkgs = reflex-platform.nixpkgs;
4+ # The haskell environment used to build Obelisk itself, e.g. the 'ob' command
5+ ghcObelisk = reflex-platform.ghc.override {
6+ overrides = self: super: with pkgs.haskell.lib; {
7+ #TODO: Eliminate this when https://github.com/phadej/github/pull/307 makes its way to reflex-platform
8+ github = overrideCabal super.github (drv: {
9+ src = pkgs.fetchFromGitHub {
10+ owner = "ryantrinkle";
11+ repo = "github";
12+ rev = "8f543cdc07876bfb7b924d3722e3dbc1df4b02ca";
13+ sha256 = "0vcnx9cxqd821kmjx1r4cvj95zs742qm1pwqnb52vw3djplbqd86";
14+ };
15+ sha256 = null;
16+ revision = null;
17+ editedCabalFile = null;
18+ });
19+ # Dynamic linking with split objects dramatically increases startup time (about 0.5 seconds on a decent machine with SSD)
20+ obelisk-command = justStaticExecutables (self.callCabal2nix "obelisk-command" ./command {});
21+ };
22+ };
423in
524with pkgs.lib;
625rec {
726 inherit reflex-platform;
27+ command = ghcObelisk.obelisk-command;
828 nullIfAbsent = p: if pathExists p then p else null;
929 #TODO: Avoid copying files within the nix store. Right now, obelisk-asset-manifest-generate copies files into a big blob so that the android/ios static assets can be imported from there; instead, we should get everything lined up right before turning it into an APK, so that copies, if necessary, only exist temporarily.
1030 processAssets = { src, packageName ? "static", moduleName ? "Static" }: pkgs.runCommand "asset-manifest" {