packages feed

cachix 1.12.1 → 1.13.0

raw patch · 22 files changed

+574/−34 lines, 22 filesdep +ramdep ~crypton

Dependencies added: ram

Dependency ranges changed: crypton

Files

CHANGELOG.md view
@@ -7,6 +7,23 @@  ## [Unreleased] +## [1.13.0] - 2026-10-21++### Added++- `cachix list`: list Cachix caches configured in nix.conf (`--all` also shows non-Cachix substituters)++### Changed++- deps: support crypton >= 1.1 with ram while retaining support for older crypton versions with memory++### Fixed++- client: retry HTTP 408 request timeouts+- client: detect interactive terminals correctly and disable interactive progress when `CI=true` or `CI=1`+- deploy: ignore duplicate deployment commands+- deploy: retry deployments skipped because another deployment holds the lock+ ## [1.12.1] - 2026-08-31  ### Fixed
cachix-deployment/Main.hs view
@@ -12,7 +12,7 @@ import Cachix.Client.URI qualified as URI import Cachix.Deploy.Activate qualified as Activate import Cachix.Deploy.Agent qualified as Agent-import Cachix.Deploy.Deployment (Deployment (..))+import Cachix.Deploy.Deployment (Deployment (..), lockContentionExitCode) import Cachix.Deploy.Lock qualified as Lock import Cachix.Deploy.Log qualified as Log import Cachix.Deploy.Websocket qualified as WebSocket@@ -78,8 +78,8 @@    lockFilePath <- Lock.newLockFilePath (lockFilenameFrom agentName) -  Log.withLog logOptions $ \withLog ->-    void . Lock.withTryLock lockFilePath $ do+  deploymentResult <- Log.withLog logOptions $ \withLog ->+    Lock.withTryLock lockFilePath $ do       -- Open a connection to logging stream       (logQueue, loggingThread) <- runLogStream withLog logWebsocketOptions @@ -93,6 +93,12 @@           atomically $ TMQueue.closeTMQueue logQueue           shutdownService           Async.wait loggingThread++  -- A lock holder is still deploying, so no deployment status was reported.+  -- Return a non-success exit code so the agent keeps this delivery retryable+  -- while the backend reschedules it.+  when (isNothing deploymentResult) $+    exitWith lockContentionExitCode  -- | Run the deployment commands deploy ::
cachix.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.2 name:               cachix-version:            1.12.1+version:            1.13.0 synopsis:   Command-line client for Nix binary cache hosting https://cachix.org @@ -26,6 +26,14 @@   location: https://github.com/cachix/cachix   subdir:   cachix +flag ram+  description:+    Use ram instead of memory for Data.ByteArray, matching crypton >= 1.1.+    The solver picks this automatically.++  default:     True+  manual:      False+ common defaults   build-depends:      base >=4.7 && <5   default-extensions:@@ -62,6 +70,7 @@     Cachix.Client.Command.Config     Cachix.Client.Command.Doctor     Cachix.Client.Command.Import+    Cachix.Client.Command.List     Cachix.Client.Command.Pin     Cachix.Client.Command.Push     Cachix.Client.Command.Watch@@ -81,6 +90,7 @@     Cachix.Client.Retry     Cachix.Client.Secrets     Cachix.Client.Servant+    Cachix.Client.Terminal     Cachix.Client.URI     Cachix.Client.Version     Cachix.Client.WatchStore@@ -143,7 +153,6 @@     , conduit-extra     , conduit-zstd     , containers-    , crypton     , deepseq     , dhall                   >=1.28.0     , directory@@ -169,7 +178,6 @@     , lukko     , lzma-conduit     , megaparsec              >=7.0.0-    , memory     , microlens     , netrc     , network@@ -207,6 +215,16 @@     , websockets     , wuss +  if flag(ram)+    build-depends:+      , crypton  >=1.1+      , ram++  else+    build-depends:+      , crypton  <1.1+      , memory+   -- These versions are pinned in hercules-ci-cnix-store   pkgconfig-depends: nix-store @@ -250,6 +268,7 @@   hs-source-dirs:     test   other-modules:     CommandConfigSpec+    CommandListSpec     Daemon.NarinfoQuerySpec     Daemon.PostBuildHookSpec     Daemon.ProtocolSpec@@ -261,7 +280,9 @@     NetRcSpec     NixConfSpec     NixVersionSpec+    RetrySpec     Spec+    TerminalSpec     URISpec    build-depends:@@ -278,6 +299,7 @@     , hercules-ci-cnix-store     , here     , hspec+    , http-types     , katip     , protolude     , retry@@ -287,6 +309,7 @@     , temporary     , time     , unliftio+    , uuid     , versions    build-tool-depends: hspec-discover:hspec-discover
src/Cachix/Client.hs view
@@ -51,6 +51,7 @@     Push (PushPaths opts name cliPaths) -> Command.push env opts name cliPaths     Push (PushWatchStore _ _) ->       throwIO $ DeprecatedCommand "DEPRECATED: cachix watch-store has replaced cachix push --watch-store."+    List listOptions -> Command.list env listOptions     Remove name -> Command.remove env name     Use name useOptions -> Command.use env name useOptions     Version -> putText cachixVersion
src/Cachix/Client/Command.hs view
@@ -6,6 +6,7 @@     Doctor.daemonDoctor,     Doctor.doctor,     Import.import',+    List.list,     Pin.pin,     Push.push,     Watch.watchExec,@@ -19,6 +20,7 @@ import Cachix.Client.Command.Config qualified as Config import Cachix.Client.Command.Doctor qualified as Doctor import Cachix.Client.Command.Import qualified as Import+import Cachix.Client.Command.List qualified as List import Cachix.Client.Command.Pin qualified as Pin import Cachix.Client.Command.Push qualified as Push import Cachix.Client.Command.Watch qualified as Watch
+ src/Cachix/Client/Command/List.hs view
@@ -0,0 +1,122 @@+module Cachix.Client.Command.List (list, cachixCacheName, Entry (..), configuredEntries) where++import Cachix.Client.Env (Env, effectiveHost)+import Cachix.Client.Exception (CachixException)+import Cachix.Client.NixConf qualified as NixConf+import Cachix.Client.OptionsParser (ListOptions (..))+import Cachix.Client.URI (URI)+import Cachix.Client.URI qualified as URI+import Control.Exception.Safe qualified as Safe+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T+import Protolude hiding (hPutStrLn, list, toS)+import Protolude.Conv (toS)+import System.IO (hPutStrLn)++data Entry = Entry+  { entrySource :: FilePath,+    entryUri :: Text,+    entryKey :: Text,+    entryCacheName :: Maybe Text+  }++list :: Env -> ListOptions -> IO ()+list env ListOptions {listAll} = do+  let cachixHost = effectiveHost env+  globalSources <- readSources NixConf.Global+  localSources <- readSources NixConf.Local+  let filtered = configuredEntries cachixHost ListOptions {listAll} (globalSources <> localSources)+  if null filtered+    then+      if listAll+        then putStrLn ("No substituters configured in nix.conf." :: Text)+        else putStrLn ("No Cachix caches configured. Try `cachix use <name>` first." :: Text)+    else printEntries filtered++-- | Select and deduplicate entries before rendering, retaining their source.+configuredEntries :: URI -> ListOptions -> [NixConf.NixConfSource] -> [Entry]+configuredEntries cachixHost ListOptions {listAll} sources =+  let allSources = dedupBy NixConf.nixConfPath sources+      entries = dedupBy entryKey (concatMap (toEntries cachixHost) allSources)+   in if listAll then entries else filter (isJust . entryCacheName) entries++readSources :: NixConf.NixConfLoc -> IO [NixConf.NixConfSource]+readSources loc =+  NixConf.read loc >>= \case+    Nothing -> pure []+    Just s ->+      Safe.catch (NixConf.resolveIncludes s) $ \(e :: CachixException) -> do+        hPutStrLn stderr $+          "Warning: skipping includes from "+            <> NixConf.nixConfPath s+            <> ": "+            <> show e+        pure [s]++toEntries :: URI -> NixConf.NixConfSource -> [Entry]+toEntries cachixHost src =+  [ Entry+      { entrySource = NixConf.nixConfPath src,+        entryUri = u,+        entryKey = canonicalKey u,+        entryCacheName = cachixCacheName cachixHost u+      }+  | u <- NixConf.readLines NixConf.isAnySubstituter src+  ]++-- | Keep the first occurrence of each @key x@, preserving input order.+dedupBy :: (Ord k) => (a -> k) -> [a] -> [a]+dedupBy key = go Set.empty+  where+    go _ [] = []+    go seen (x : xs)+      | Set.member (key x) seen = go seen xs+      | otherwise = x : go (Set.insert (key x) seen) xs++-- | Canonical form used only for deduplication (lowercased scheme + host,+-- default ports dropped). The original+-- substituter text is preserved for display.+canonicalKey :: Text -> Text+canonicalKey raw =+  case URI.parseURI (toS raw) of+    Right parsed | Just k <- URI.canonicalForm parsed -> k+    _ -> raw++cachixCacheName :: URI -> Text -> Maybe Text+cachixCacheName cachixHost rawUri = do+  parsed <- rightToMaybe $ URI.parseURI (toS rawUri)+  cHost <- hostText cachixHost+  sHost <- hostText parsed+  let suffix = "." <> cHost+  guard (suffix `T.isSuffixOf` sHost && T.length sHost > T.length suffix)+  pure $ T.dropEnd (T.length suffix) sHost+  where+    hostText u = T.toLower . toS . URI.hostBS <$> URI.getHostnameMay u++printEntries :: [Entry] -> IO ()+printEntries entries =+  sequence_ $ intersperse (putStrLn ("" :: Text)) (map printGroup grouped)+  where+    grouped :: [(FilePath, [Entry])]+    grouped =+      let bucket =+            foldl'+              (\m e -> Map.insertWith (flip (<>)) (entrySource e) [e] m)+              Map.empty+              entries+          order = dedupBy identity (map entrySource entries)+       in [(p, Map.findWithDefault [] p bucket) | p <- order]++    nameWidth = maximum (0 : [T.length n | e <- entries, Just n <- [entryCacheName e]])++    printGroup (path, es) = do+      putStrLn $ (toS path :: Text) <> ":"+      forM_ es printEntry++    printEntry e =+      case entryCacheName e of+        Just name ->+          putStrLn $ "  " <> T.justifyLeft nameWidth ' ' name <> "  " <> entryUri e+        Nothing ->+          putStrLn $ "  " <> T.justifyLeft nameWidth ' ' "-" <> "  " <> entryUri e
src/Cachix/Client/Command/Push.hs view
@@ -22,6 +22,7 @@ import Cachix.Client.Retry (retryClientM) import Cachix.Client.Secrets import Cachix.Client.Servant+import Cachix.Client.Terminal (useInteractiveProgress) import Cachix.Types.BinaryCache (BinaryCacheName) import Cachix.Types.BinaryCache qualified as BinaryCache import Control.Exception.Safe (throwM)@@ -96,10 +97,9 @@       let hSize = toS $ humanSize $ fromIntegral size       path <- liftIO $ decodeUtf8With lenientDecode <$> storePathToPath store storePath -      isTerminal <- liftIO $ hIsTerminalDevice stderr-      isCI <- liftIO $ (== Just "true") <$> lookupEnv "CI"+      interactiveProgress <- liftIO $ useInteractiveProgress stderr       onTick <--        if isTerminal && not isCI+        if interactiveProgress           then do             let bar = color Blue "[:bar] " <> toS (retryText retryStatus) <> toS path <> " (:percent of " <> hSize <> ")"                 barLength = T.length $ T.replace ":percent" "  0%" (T.replace "[:bar]" "" (toS bar))
src/Cachix/Client/Env.hs view
@@ -1,6 +1,7 @@ module Cachix.Client.Env   ( Env (..),     mkEnv,+    effectiveHost,     createClientEnv,     customManagerSettings,     Config.defaultCachixOptions,@@ -10,7 +11,7 @@ import Cachix.Client.Config (Config) import Cachix.Client.Config qualified as Config import Cachix.Client.OptionsParser qualified as Options-import Cachix.Client.URI (getBaseUrl)+import Cachix.Client.URI (URI, getBaseUrl) import Cachix.Client.Version (cachixVersion) import Network.HTTP.Client   ( ManagerSettings,@@ -49,6 +50,12 @@         clientenv = clientEnv,         config = cfg       }++-- | The Cachix host this process is actually talking to: the value from+-- @--host@ when supplied, otherwise @hostname@ from cachix.dhall. Use this+-- instead of @Config.hostname (config env)@ in commands, which ignores @--host@.+effectiveHost :: Env -> URI+effectiveHost = Config.host . cachixoptions  customManagerSettings :: ManagerSettings customManagerSettings =
src/Cachix/Client/NixConf.hs view
@@ -30,6 +30,8 @@     parse,     readLines,     writeLines,+    isSubstituter,+    isAnySubstituter,     isTrustedUsers,     defaultPublicURI,     defaultSigningKey,@@ -169,6 +171,35 @@ isSubstituter :: NixConfLine -> Maybe [Text] isSubstituter (Substituters xs) = Just xs isSubstituter _ = Nothing++-- | Read active substituters, including @extra-substituters@. Trusted+-- substituters only grant permission to enable a cache and are not active.+-- Strip comments only when reading, preserving the parsed lines for writers.+isAnySubstituter :: NixConfLine -> Maybe [Text]+isAnySubstituter (Substituters xs) = Just (substituterValues (T.unwords xs))+isAnySubstituter (Other raw) = parseSubstitutersLine raw+isAnySubstituter _ = Nothing++substituterValues :: Text -> [Text]+substituterValues = T.words . T.takeWhile (/= '#')++parseSubstitutersLine :: Text -> Maybe [Text]+parseSubstitutersLine raw = do+  let stripped = T.stripStart raw+  key <- find (`matchesDirective` stripped) substituterKeys+  let afterKey = T.stripStart (T.drop (T.length key) stripped)+  rest <- T.stripPrefix "=" afterKey+  pure (substituterValues rest)+  where+    substituterKeys :: [Text]+    substituterKeys = ["extra-substituters", "substituters", "binary-caches"]++    matchesDirective :: Text -> Text -> Bool+    matchesDirective k s = case T.stripPrefix k s of+      Nothing -> False+      Just rest -> case T.uncons rest of+        Just (c, _) -> c == ' ' || c == '\t' || c == '='+        Nothing -> False  isPublicKey :: NixConfLine -> Maybe [Text] isPublicKey (TrustedPublicKeys xs) = Just xs
src/Cachix/Client/OptionsParser.hs view
@@ -26,6 +26,9 @@     -- * Doctor options     DoctorOptions (..), +    -- * List options+    ListOptions (..),+     -- * Global options     Flags (..), @@ -92,6 +95,7 @@   | WatchStore PushOptions Text   | WatchExec WatchExecMode PushOptions NarinfoQueryOptions Text Text [Text]   | Use BinaryCacheName InstallationMode.UseOptions+  | List ListOptions   | Remove BinaryCacheName   | DeployCommand DeployOptions.DeployCommand   | Version@@ -203,6 +207,11 @@   }   deriving (Show) +data ListOptions = ListOptions+  { listAll :: Bool+  }+  deriving (Show)+ -- | CLI parser entry point getOpts :: IO (Flags, CachixCommand) getOpts = do@@ -245,6 +254,7 @@             hidden,             command "generate-keypair" $ infoH generateKeypairCommand $ progDesc "Generate a signing key pair for a binary cache",             command "use" $ infoH useCommand $ progDesc "Configure a binary cache in nix.conf",+            command "list" $ infoH listCommand $ progDesc "List Cachix caches configured in nix.conf",             command "remove" $ infoH removeCommand $ progDesc "Remove a binary cache from nix.conf"           ] @@ -712,6 +722,17 @@  removeCommand :: Parser CachixCommand removeCommand = Remove <$> cacheNameParser++listCommand :: Parser CachixCommand+listCommand = List <$> listOptionsParser++listOptionsParser :: Parser ListOptions+listOptionsParser =+  ListOptions+    <$> switch+      ( long "all"+          <> help "Also show non-Cachix substituters"+      )  useCommand :: Parser CachixCommand useCommand = Use <$> cacheNameParser <*> installationMode
src/Cachix/Client/Retry.hs view
@@ -5,6 +5,7 @@     -- * HTTP retry     retryHttp,     retryClientM,+    shouldRetryHttpStatusCode,      -- * Policies     endlessConstantRetryPolicy,@@ -145,9 +146,10 @@     HTTP.HttpZlibException _ -> True     _ -> False --- | Whether an HTTP status code is worth retrying. 429 and any 5xx.+-- | Whether an HTTP status code is worth retrying. 408, 429 and any 5xx. shouldRetryHttpStatusCode :: HTTP.Status -> Bool shouldRetryHttpStatusCode code+  | code == HTTP.requestTimeout408 = True   | code == HTTP.tooManyRequests429 = True   | HTTP.statusIsServerError code = True   | otherwise = False
+ src/Cachix/Client/Terminal.hs view
@@ -0,0 +1,20 @@+module Cachix.Client.Terminal+  ( isCI,+    useInteractiveProgress,+  )+where++import Data.Char qualified as Char+import Protolude+import System.Environment (lookupEnv)+import System.IO (hIsTerminalDevice)+import Prelude (String)++isCI :: Maybe String -> Bool+isCI = maybe False $ \value -> fmap Char.toLower value `elem` ["true", "1"]++useInteractiveProgress :: Handle -> IO Bool+useInteractiveProgress hdl = do+  isTerminal <- hIsTerminalDevice hdl+  runningInCI <- isCI <$> lookupEnv "CI"+  return $ isTerminal && not runningInCI
src/Cachix/Client/URI.hs view
@@ -7,6 +7,7 @@     fromURIRef,     getScheme,     getHostname,+    getHostnameMay,     appendSubdomain,     getPortFor,     getPath,@@ -16,6 +17,7 @@     serialize,     toByteString,     getBaseUrl,+    canonicalForm,     defaultCachixURI,     defaultCachixBaseUrl,     UBS.Host (..),@@ -62,6 +64,25 @@  getHostname :: URI -> UBS.Host getHostname = UBS.authorityHost . fromJust . UBS.uriAuthority . getUri++-- | Like 'getHostname', but returns 'Nothing' if the URI has no authority+-- (e.g. @urn:…@, @mailto:…@, scheme-only forms) instead of throwing.+getHostnameMay :: URI -> Maybe UBS.Host+getHostnameMay = fmap UBS.authorityHost . UBS.uriAuthority . getUri++-- | Normalize scheme, hostname and default ports for deduplication,+-- preserving paths, credentials, query parameters and fragments.+-- Returns 'Nothing' for URIs without an authority.+canonicalForm :: URI -> Maybe Text+canonicalForm (URI u) = do+  authority <- UBS.uriAuthority u+  let scheme = UBS.Scheme (Char8.map toLower (UBS.schemeBS (UBS.uriScheme u)))+      host = UBS.Host (Char8.map toLower (UBS.hostBS (UBS.authorityHost authority)))+      port = case UBS.authorityPort authority of+        Just p | Just p == getPortFor scheme -> Nothing+        p -> p+      normalizedAuthority = authority {UBS.authorityHost = host, UBS.authorityPort = port}+  pure $ toS $ UBS.serializeURIRef' $ u {UBS.uriScheme = scheme, UBS.uriAuthority = Just normalizedAuthority}  -- TODO: lenses? appendSubdomain :: Text -> URI -> URI
src/Cachix/Daemon/Progress.hs view
@@ -12,6 +12,7 @@ where  import Cachix.Client.HumanSize (humanSize)+import Cachix.Client.Terminal (useInteractiveProgress) import Cachix.Daemon.Types (PushRetryStatus (..)) import Cachix.Daemon.Types.PushEvent (PushEvent (..), PushEventMessage (..)) import Control.Concurrent.Async qualified as Async@@ -24,8 +25,6 @@ import System.Console.AsciiProgress qualified as Ascii import System.Console.AsciiProgress.Internal qualified as Ascii.Internal import System.Console.Pretty-import System.Environment (lookupEnv)-import System.IO (hIsTerminalDevice)  data UploadProgress   = ProgressBar@@ -50,9 +49,8 @@ new :: Handle -> String -> Int64 -> PushRetryStatus -> IO UploadProgress new hdl path size retryStatus = do   lastBytesRef <- newIORef 0-  isCI <- liftIO $ (== Just "true") <$> lookupEnv "CI"-  isTerminal <- liftIO $ hIsTerminalDevice hdl-  if isTerminal && not isCI+  interactiveProgress <- useInteractiveProgress hdl+  if interactiveProgress     then do       progressBar <- newProgressBar path size retryStatus       return $ ProgressBar {..}
src/Cachix/Deploy/Agent.hs view
@@ -3,6 +3,7 @@  module Cachix.Deploy.Agent where +import Cachix.API.WebSocketSubprotocol qualified as DeploymentDetails (DeploymentDetails (..)) import Cachix.API.WebSocketSubprotocol qualified as WSS import Cachix.Client.Config qualified as Config import Cachix.Client.URI (URI)@@ -22,7 +23,10 @@ import Control.Retry qualified as Retry import Data.Aeson qualified as Aeson import Data.IORef+import Data.Set qualified as Set import Data.String (String)+import Data.UUID (UUID)+import Data.UUID qualified as UUID import Katip qualified as K import Paths_cachix (getBinDir) import Protolude hiding (onException, toS, (<.>))@@ -47,6 +51,7 @@     host :: URI,     websocket :: ServiceWebSocket,     agentState :: IORef (Maybe WSS.AgentInformation),+    seenDeployments :: IORef (Set.Set UUID),     logOptions :: Log.Options,     withLog :: Log.WithLog,     lockFile :: FilePath,@@ -57,6 +62,7 @@ mkAgent :: Log.WithLog -> Log.Options -> Maybe FilePath -> Config.CachixOptions -> CLI.AgentOptions -> Text -> IO Agent mkAgent withLog logOptions mlockDirectory cachixOptions agentOptions agentToken = do   agentState <- newIORef Nothing+  seenDeployments <- newIORef Set.empty   pid <- Posix.getProcessID   lockDirectory <- maybe Lock.getLockDirectory return mlockDirectory @@ -93,6 +99,7 @@         profileName = profileName,         bootstrap = CLI.bootstrap agentOptions,         agentState = agentState,+        seenDeployments = seenDeployments,         pid = pid,         pidFile = lockDirectory </> lockFilename <.> Lock.pidExtension,         lockFile = lockDirectory </> lockFilename <.> Lock.lockExtension,@@ -225,7 +232,16 @@   atomicWriteIORef agentState (Just agentInformation)  launchDeployment :: Agent -> WSS.DeploymentDetails -> IO ()-launchDeployment agent@Agent {..} deploymentDetails = do+launchDeployment = launchDeploymentWith runDeployment+  where+    runDeployment deployment = do+      binDir <- toS <$> getBinDir+      StdinProcess.readProcess (binDir <> "/.cachix-deployment") [] $+        toS $+          Aeson.encode deployment++launchDeploymentWith :: (Deployment.Deployment -> IO ExitCode) -> Agent -> WSS.DeploymentDetails -> IO ()+launchDeploymentWith runDeployment agent@Agent {..} deploymentDetails = do   agentRegistered <- readIORef agentState    case agentRegistered of@@ -233,23 +249,42 @@     -- we should re-register here as a precaution.     Nothing -> pure ()     Just agentInformation -> do-      binDir <- toS <$> getBinDir-      exitCode <--        StdinProcess.readProcess (binDir <> "/.cachix-deployment") [] $-          toS . Aeson.encode $-            Deployment.Deployment-              { Deployment.agentName = name,-                Deployment.agentToken = token,-                Deployment.profileName = profileName,-                Deployment.host = host,-                Deployment.deploymentDetails = deploymentDetails,-                Deployment.agentInformation = agentInformation,-                Deployment.logOptions = logOptions-              }+      let deploymentID = DeploymentDetails.id deploymentDetails+      isNewDeployment <-+        atomicModifyIORef' seenDeployments $ \deploymentIDs ->+          if Set.member deploymentID deploymentIDs+            then (deploymentIDs, False)+            else (Set.insert deploymentID deploymentIDs, True) -      when-        (bootstrap && exitCode == ExitSuccess)-        (verifyBootstrapSuccess agent)+      if isNewDeployment+        then do+          exitCode <-+            runDeployment $+              Deployment.Deployment+                { Deployment.agentName = name,+                  Deployment.agentToken = token,+                  Deployment.profileName = profileName,+                  Deployment.host = host,+                  Deployment.deploymentDetails = deploymentDetails,+                  Deployment.agentInformation = agentInformation,+                  Deployment.logOptions = logOptions+                }++          -- A process failure can happen before the deployment reports a status.+          -- Let a subsequent backend delivery retry it.+          when (exitCode /= ExitSuccess) $+            atomicModifyIORef' seenDeployments $ \deploymentIDs ->+              (Set.delete deploymentID deploymentIDs, ())++          when+            (bootstrap && exitCode == ExitSuccess)+            (verifyBootstrapSuccess agent)+        else+          withLog . K.logLocM K.InfoS . K.ls $+            unwords+              [ "Ignoring duplicate deployment",+                UUID.toText deploymentID <> "."+              ]  verifyBootstrapSuccess :: Agent -> IO () verifyBootstrapSuccess agent@(Agent {name, withLog}) = do
src/Cachix/Deploy/Deployment.hs view
@@ -6,6 +6,12 @@ import Data.Aeson qualified as Aeson import Protolude +-- | The deployment helper could not acquire the profile lock and did not+-- deploy. The agent must keep the delivery retryable while the backend+-- reschedules it.+lockContentionExitCode :: ExitCode+lockContentionExitCode = ExitFailure 75+ -- | Everything required for the standalone deployment binary to complete a -- deployment. data Deployment = Deployment
+ test/CommandListSpec.hs view
@@ -0,0 +1,83 @@+module CommandListSpec (spec) where++import Cachix.Client.Command.List (Entry (..), cachixCacheName, configuredEntries)+import Cachix.Client.NixConf qualified as NixConf+import Cachix.Client.OptionsParser (ListOptions (..))+import Cachix.Client.URI (URI)+import Protolude+import Test.Hspec++spec :: Spec+spec = do+  describe "cachixCacheName" $ do+    it "extracts the cache name from a subdomain of the default Cachix host" $+      cachixCacheName defaultHost "https://corestory.cachix.org" `shouldBe` Just "corestory"++    it "ignores a trailing path on the substituter URI" $+      cachixCacheName defaultHost "https://corestory.cachix.org/" `shouldBe` Just "corestory"++    it "returns Nothing for a non-Cachix substituter" $+      cachixCacheName defaultHost "https://cache.nixos.org" `shouldBe` Nothing++    it "returns Nothing for the bare Cachix host (no subdomain)" $+      cachixCacheName defaultHost "https://cachix.org" `shouldBe` Nothing++    it "matches a cache on a self-hosted Cachix instance" $+      cachixCacheName selfHosted "https://foo.cachix.example.com" `shouldBe` Just "foo"++    it "does not match a cache on the default host when using a self-hosted instance" $+      cachixCacheName selfHosted "https://foo.cachix.org" `shouldBe` Nothing++    it "returns Nothing for an unparseable URI" $+      cachixCacheName defaultHost "not a url" `shouldBe` Nothing++    it "matches case-insensitively on the substituter host" $+      cachixCacheName defaultHost "https://FOO.Cachix.Org" `shouldBe` Just "foo"++    it "matches case-insensitively on the Cachix host" $+      cachixCacheName "https://Cachix.Org" "https://foo.cachix.org" `shouldBe` Just "foo"++    it "returns Nothing for a URI with no authority (urn:…) instead of throwing" $+      cachixCacheName defaultHost "urn:isbn:0451450523" `shouldBe` Nothing++    it "returns Nothing for a mailto: URI instead of throwing" $+      cachixCacheName defaultHost "mailto:ops@example.com" `shouldBe` Nothing++    it "ignores an explicit default port on the substituter URI" $+      cachixCacheName defaultHost "https://foo.cachix.org:443" `shouldBe` Just "foo"++  describe "configuredEntries" $ do+    it "lists active caches but excludes trusted-only and commented caches" $ do+      let source = parsedSource "nix.conf" "substituters = https://live.cachix.org # https://disabled.cachix.org\nextra-substituters = https://extra.cachix.org # https://comment.cachix.org\ntrusted-substituters = https://allowed.cachix.org\nextra-trusted-substituters = https://also-allowed.cachix.org\n"+      map entryUri (configuredEntries defaultHost (ListOptions False) [source])+        `shouldBe` ["https://live.cachix.org", "https://extra.cachix.org"]++    it "includes non-Cachix caches only with --all" $ do+      let source = parsedSource "nix.conf" "substituters = https://foo.cachix.org https://cache.nixos.org\n"+      map entryUri (configuredEntries defaultHost (ListOptions False) [source])+        `shouldBe` ["https://foo.cachix.org"]+      map entryUri (configuredEntries defaultHost (ListOptions True) [source])+        `shouldBe` ["https://foo.cachix.org", "https://cache.nixos.org"]++    it "retains distinct S3 endpoints" $ do+      let urls = ["s3://cache?endpoint=minio-a.example.com", "s3://cache?endpoint=minio-b.example.com"]+          source = NixConf.NixConfSource "nix.conf" (NixConf.NixConf [NixConf.Substituters urls])+      map entryUri (configuredEntries defaultHost (ListOptions True) [source]) `shouldBe` urls++    it "deduplicates equivalent hosts and ports while retaining the first source and spelling" $ do+      let first = parsedSource "global.conf" "substituters = https://FOO.Cachix.Org:443\n"+          second = parsedSource "local.conf" "extra-substituters = https://foo.cachix.org\n"+      map (\entry -> (entrySource entry, entryUri entry)) (configuredEntries defaultHost (ListOptions False) [first, second])+        `shouldBe` [("global.conf", "https://FOO.Cachix.Org:443")]++parsedSource :: FilePath -> Text -> NixConf.NixConfSource+parsedSource path contents =+  case NixConf.parse contents of+    Right conf -> NixConf.NixConfSource path conf+    Left err -> panic (show err)++defaultHost :: URI+defaultHost = "https://cachix.org"++selfHosted :: URI+selfHosted = "https://cachix.example.com"
test/DeploySpec.hs view
@@ -1,13 +1,18 @@ module DeploySpec where +import Cachix.API.WebSocketSubprotocol qualified as DeploymentDetails (DeploymentDetails (..))+import Cachix.API.WebSocketSubprotocol qualified as WSS import Cachix.Client.Config qualified as Config-import Cachix.Deploy.Agent (Agent (..), mkAgent, waitForAgent)+import Cachix.Deploy.Agent (Agent (..), launchDeploymentWith, mkAgent, registerAgent, waitForAgent)+import Cachix.Deploy.Deployment (lockContentionExitCode) import Cachix.Deploy.Lock (withTryLock, withTryLockAndPid) import Cachix.Deploy.Log qualified as Log import Cachix.Deploy.OptionsParser qualified as CLI import Cachix.Deploy.Websocket qualified as WebSocket import Control.Concurrent.MVar qualified as MVar import Control.Retry qualified as Retry+import Data.IORef+import Data.UUID qualified as UUID import Protolude import System.IO.Temp (withSystemTempDirectory) import Test.Hspec@@ -49,6 +54,62 @@         WebSocket.withConnectionTimeout (10 * 1000) (pure ((), MVar.putMVar closed ())) pure         MVar.tryTakeMVar closed `shouldReturn` Just () +    describe "deployment commands" $ do+      it "runs a deployment ID only once" $+        withSystemTempDirectory "cachix-deploy-test" $ \tempDir ->+          withTestAgent tempDir $ \agent -> do+            registerTestAgent agent+            launches <- newIORef (0 :: Int)+            let runDeployment _ = modifyIORef' launches (+ 1) $> ExitSuccess+                deployment = testDeployment UUID.nil++            launchDeploymentWith runDeployment agent deployment+            launchDeploymentWith runDeployment agent deployment++            readIORef launches `shouldReturn` 1++      it "runs a new deployment ID" $+        withSystemTempDirectory "cachix-deploy-test" $ \tempDir ->+          withTestAgent tempDir $ \agent -> do+            registerTestAgent agent+            launches <- newIORef (0 :: Int)+            let runDeployment _ = modifyIORef' launches (+ 1) $> ExitSuccess++            launchDeploymentWith runDeployment agent (testDeployment UUID.nil)+            launchDeploymentWith runDeployment agent (testDeployment (UUID.fromWords 0 0 0 1))++            readIORef launches `shouldReturn` 2++      it "ignores an older deployment ID after a newer deployment" $+        withSystemTempDirectory "cachix-deploy-test" $ \tempDir ->+          withTestAgent tempDir $ \agent -> do+            registerTestAgent agent+            launches <- newIORef (0 :: Int)+            let runDeployment _ = modifyIORef' launches (+ 1) $> ExitSuccess+                firstDeployment = testDeployment UUID.nil+                secondDeployment = testDeployment (UUID.fromWords 0 0 0 1)++            launchDeploymentWith runDeployment agent firstDeployment+            launchDeploymentWith runDeployment agent secondDeployment+            launchDeploymentWith runDeployment agent firstDeployment++            readIORef launches `shouldReturn` 2++      it "retries a deployment after the child process skips on lock contention" $+        withSystemTempDirectory "cachix-deploy-test" $ \tempDir ->+          withTestAgent tempDir $ \agent -> do+            registerTestAgent agent+            results <- newIORef [lockContentionExitCode, ExitSuccess]+            let runDeployment _ = atomicModifyIORef' results $ \case+                  result : remaining -> (remaining, result)+                  [] -> ([], ExitSuccess)+                deployment = testDeployment UUID.nil++            launchDeploymentWith runDeployment agent deployment+            launchDeploymentWith runDeployment agent deployment++            readIORef results `shouldReturn` []+ withTestAgent :: FilePath -> (Agent -> IO ()) -> IO () withTestAgent tempDir action = do   let logOptions =@@ -73,3 +134,16 @@  retryPolicy :: Retry.RetryPolicyM IO retryPolicy = Retry.limitRetries 1 <> Retry.constantDelay 10++registerTestAgent :: Agent -> IO ()+registerTestAgent agent =+  registerAgent agent WSS.AgentInformation {WSS.cache = Nothing, WSS.id = UUID.nil}++testDeployment :: UUID.UUID -> WSS.DeploymentDetails+testDeployment deploymentID =+  DeploymentDetails.DeploymentDetails+    { DeploymentDetails.storePath = "/nix/store/test",+      DeploymentDetails.id = deploymentID,+      DeploymentDetails.index = 1,+      DeploymentDetails.rollbackScript = Nothing+    }
test/NixConfSpec.hs view
@@ -41,6 +41,27 @@     it "random content" $       property "blabla = foobar\nfoo = bar\n" +  describe "reading active substituters" $ do+    it "preserves inline comments when rendering" $ do+      let contents = "extra-substituters = https://live.cachix.org # https://disabled.cachix.org\n"+      property contents+      (NixConf.readLines NixConf.isAnySubstituter <$> parse contents)+        `shouldBe` Right ["https://live.cachix.org"]++    it "strips comments from regular substituters without changing rendering" $ do+      let contents = "substituters = https://live.cachix.org # https://disabled.cachix.org\n"+      property contents+      (NixConf.readLines NixConf.isAnySubstituter <$> parse contents)+        `shouldBe` Right ["https://live.cachix.org"]++    it "excludes trusted-only caches" $+      (NixConf.readLines NixConf.isAnySubstituter <$> parse "trusted-substituters = https://allowed.cachix.org\nextra-trusted-substituters = https://also-allowed.cachix.org\n")+        `shouldBe` Right []++    it "handles tabs and adjacent comment markers" $+      (NixConf.readLines NixConf.isAnySubstituter <$> parse "extra-substituters\t=\thttps://live.cachix.org#comment\n")+        `shouldBe` Right ["https://live.cachix.org"]+   describe "add" $ do     it "merges binary caches from both files" $       let globalConf =
+ test/RetrySpec.hs view
@@ -0,0 +1,14 @@+module RetrySpec where++import Cachix.Client.Retry (shouldRetryHttpStatusCode)+import Network.HTTP.Types.Status qualified as HTTP+import Protolude+import Test.Hspec++spec :: Spec+spec = describe "HTTP retry status selection" $+  it "retries timeouts, rate limits and server errors, but not other client errors" $ do+    forM_ [408, 429, 500, 502, 503, 504, 520, 524] $ \code ->+      shouldRetryHttpStatusCode (HTTP.mkStatus code "") `shouldBe` True+    forM_ [400, 401, 403, 404, 409, 413, 421, 425] $ \code ->+      shouldRetryHttpStatusCode (HTTP.mkStatus code "") `shouldBe` False
+ test/TerminalSpec.hs view
@@ -0,0 +1,19 @@+module TerminalSpec (spec) where++import Cachix.Client.Terminal (isCI, useInteractiveProgress)+import Protolude+import Test.Hspec++spec :: Spec+spec = do+  describe "isCI" $ do+    it "recognizes supported CI values" $ do+      fmap isCI [Just "true", Just "1", Just "TRUE"] `shouldBe` [True, True, True]++    it "does not treat other values as CI" $ do+      fmap isCI [Nothing, Just "false", Just "0", Just ""] `shouldBe` [False, False, False, False]++  describe "useInteractiveProgress" $ do+    it "rejects non-terminal handles" $ do+      withFile "/dev/null" WriteMode $ \hdl ->+        useInteractiveProgress hdl `shouldReturn` False
test/URISpec.hs view
@@ -38,6 +38,23 @@ spec :: Spec spec =   describe "URI" $ do+    describe "canonicalForm" $ do+      it "normalizes scheme, hostname and default port" $+        URI.canonicalForm "HTTPS://FOO.Cachix.Org:443" `shouldBe` Just "https://foo.cachix.org"++      it "preserves credentials, path, query parameters and fragment" $+        URI.canonicalForm "https://user:pass@FOO.cachix.org:443/cache/?priority=10#fragment"+          `shouldBe` Just "https://user:pass@foo.cachix.org/cache/?priority=10#fragment"++      it "preserves non-default ports" $+        URI.canonicalForm "https://foo.cachix.org:8443" `shouldBe` Just "https://foo.cachix.org:8443"++      it "preserves the trailing slash on a nonempty path" $+        URI.canonicalForm "https://foo.cachix.org/cache/" `shouldNotBe` URI.canonicalForm "https://foo.cachix.org/cache"++      it "returns Nothing without an authority" $+        URI.canonicalForm "urn:isbn:0451450523" `shouldBe` Nothing+     it "parses a URI" $       URI.parseURI secureUri `shouldSatisfy` isRight