packages feed

settei-kubernetes (empty) → 0.2.0.0

raw patch · 7 files changed

+1224/−0 lines, 7 filesdep +basedep +bytestringdep +containers

Dependencies added: base, bytestring, containers, directory, filepath, generic-lens, settei, settei-env, settei-kubernetes, tasty, tasty-hunit, temporary, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,14 @@+# Changelog for settei-kubernetes++## 0.2.0.0 — 2026-07-19++- Initial experimental release.+- Add a mounted-directory source for projected ConfigMap and Secret volumes with+  explicit per-file key bindings, atomic-writer symlink handling, per-file+  provenance, and secret-safe categorized errors with text renderers.+- Add `Settei.Kubernetes.Bindings`: derive validated environment bindings from a+  ConfigMap or Secret reference in one construction, so each binding's provenance+  annotation is generated from the same data key that feeds it.+- Mounted-directory origins now carry freshness identity: `kubernetes.mount-path`,+  per-key `kubernetes.file-modified`, and source-wide `kubernetes.read-at`+  (ISO-8601 UTC). Descriptive only; precedence is unchanged.
+ LICENSE view
@@ -0,0 +1,28 @@+Copyright (c) 2026, shinzui+All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ settei-kubernetes.cabal view
@@ -0,0 +1,74 @@+cabal-version:   3.8+name:            settei-kubernetes+version:         0.2.0.0+synopsis:        Kubernetes mounted-directory sources for Settei+description:+  Translate a projected ConfigMap or Secret volume - a directory with one file+  per data key - into a provenance-aware Settei source through explicit+  per-file key bindings, handling the kubelet's atomic-writer symlink layout.++homepage:        https://github.com/shinzui/settei+bug-reports:     https://github.com/shinzui/settei/issues+license:         BSD-3-Clause+license-file:    LICENSE+author:          shinzui+maintainer:      shinzui+category:        Configuration+build-type:      Simple+tested-with:     GHC ==9.12.4+extra-doc-files: CHANGELOG.md++source-repository head+  type:     git+  location: https://github.com/shinzui/settei.git++common common+  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++  ghc-options:        -Wall -Wcompat++library+  import:          common+  hs-source-dirs:  src+  exposed-modules:+    Settei.Kubernetes+    Settei.Kubernetes.Bindings++  build-depends:+    , base          >=4.21    && <5+    , bytestring    >=0.12    && <0.13+    , containers    >=0.6.8   && <0.8+    , directory     >=1.3.8   && <1.4+    , filepath      >=1.5.4   && <1.6+    , generic-lens  >=2.2     && <2.4+    , settei        ==0.2.0.0+    , settei-env    ==0.2.0.0+    , text          >=2.1     && <2.2+    , time          >=1.14    && <1.17++test-suite settei-kubernetes-tests+  import:         common+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs+  other-modules:  Settei.KubernetesTest+  build-depends:+    , base               >=4.21    && <5+    , bytestring         >=0.12    && <0.13+    , containers         >=0.6.8   && <0.8+    , directory          >=1.3.8   && <1.4+    , filepath           >=1.5.4   && <1.6+    , generic-lens       >=2.2     && <2.4+    , settei             ==0.2.0.0+    , settei-env         ==0.2.0.0+    , settei-kubernetes  ==0.2.0.0+    , tasty              >=1.5     && <1.6+    , tasty-hunit        >=0.10.2  && <0.11+    , temporary          >=1.3     && <1.4+    , text               >=2.1     && <2.2+    , time               >=1.14    && <1.17
+ src/Settei/Kubernetes.hs view
@@ -0,0 +1,483 @@+{-# LANGUAGE ImportQualifiedPost #-}++-- |+-- Module: Settei.Kubernetes+-- Description: Kubernetes mounted-directory sources for Settei.+--+-- Kubernetes ConfigMap and Secret volumes normally appear as a directory with one+-- visible file per object key. Kubelet's atomic writer implements those files as+-- symlinks through a hidden @..data@ directory; this module reads the visible names and+-- follows those links, while 'unboundMountedFiles' ignores the hidden writer entries.+-- File names are bound explicitly because Kubernetes data keys commonly contain dots,+-- which must not be confused with Settei's structural dotted-key syntax.+--+-- By default one trailing newline is removed from every decoded UTF-8 value. Use+-- 'keepTrailingNewline' for byte-faithful text. A single-file @subPath@ mount can use a+-- format adapter such as @settei-yaml@, or a one-entry 'FileBindings' value here.+--+-- A typical Secret mount can be loaded as follows:+--+-- @+-- passwordKey <- either fail pure (parseKey "database.password")+-- validated <- either (fail . show) pure (fileBindings [fileBinding "password" passwordKey])+-- let reference = kubernetesRef SecretObject (Just "prod") "app-credentials" Nothing+-- readMountedDirectorySource+--   (mountedDirectoryOptions "app-secrets" reference)+--   validated+--   "/etc/app-secrets"+-- @+module Settei.Kubernetes+  ( FileBinding,+    FileBindings,+    KubernetesErrorCategory (..),+    KubernetesSourceError,+    MountedDirectoryOptions,+    annotateFileBinding,+    annotateMountedDirectoryOptions,+    fileBinding,+    fileBindingAnnotations,+    fileBindingKey,+    fileBindingName,+    fileBindings,+    fileBindingsList,+    keepTrailingNewline,+    kubernetesErrorCategory,+    kubernetesErrorMessage,+    kubernetesErrorName,+    kubernetesErrorPath,+    mountedDirectoryAnnotations,+    mountedDirectoryKeepsTrailingNewline,+    mountedDirectoryName,+    mountedDirectoryOptions,+    mountedDirectoryRef,+    readMountedDirectorySource,+    renderKubernetesErrorText,+    renderKubernetesErrorsText,+    unboundMountedFiles,+  )+where++import Control.Exception (IOException, displayException, try)+import Data.ByteString qualified as ByteString+import Data.Generics.Labels ()+import Data.List (sort)+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as TextEncoding+import Data.Time.Clock (UTCTime, getCurrentTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+import Settei+import Settei.Prelude+import System.Directory qualified as Directory+import System.FilePath ((</>))+import System.IO.Error qualified as IOError++-- | One explicit mapping from a visible mounted file to a structural Settei key.+data FileBinding = FileBinding+  { fileName :: !Text,+    key :: !Key,+    annotations :: !(Map Text Text)+  }+  deriving stock (Generic, Eq)++-- | A collection of file bindings proven valid at construction.+--+-- The constructor is private. 'fileBindings' rejects invalid or reserved names,+-- duplicates, and target keys whose tree positions overlap.+newtype FileBindings = ValidatedFileBindings [FileBinding]+  deriving stock (Eq)++-- | Stable classes of mounted-directory and binding failure.+--+-- No constructor retains raw file content.+data KubernetesErrorCategory+  = KubernetesInvalidFileName+  | KubernetesDuplicateFileName+  | KubernetesDuplicateTargetKey+  | KubernetesConflictingTargetKeys+  | KubernetesNotADirectory+  | KubernetesIoError+  | KubernetesInvalidUtf8+  deriving stock (Generic, Eq, Ord, Show)++-- | A secret-safe mounted-directory input failure.+data KubernetesSourceError = KubernetesSourceError+  { category :: !KubernetesErrorCategory,+    name :: !(Maybe Text),+    path :: !(Maybe FilePath),+    message :: !Text+  }+  deriving stock (Generic, Eq, Show)++-- | Naming, provenance, annotations, and text policy for one mounted directory.+--+-- The optional key already present in the 'KubernetesRef' is ignored: each present+-- mounted file supplies its own @kubernetes.object-key@ annotation.+data MountedDirectoryOptions = MountedDirectoryOptions+  { name :: !Text,+    ref :: !KubernetesRef,+    annotations :: !(Map Text Text),+    keepsTrailingNewline :: !Bool+  }+  deriving stock (Generic, Eq)++-- | Construct one file binding with no caller annotations.+fileBinding :: Text -> Key -> FileBinding+fileBinding fileName key = FileBinding {fileName, key, annotations = Map.empty}++-- | Add trusted descriptive metadata to one binding.+--+-- Adapter-owned @kubernetes.*@ annotations take precedence on name collisions.+annotateFileBinding :: Map Text Text -> FileBinding -> FileBinding+annotateFileBinding annotations bindingValue =+  bindingValue & #annotations %~ (annotations <>)++-- | Return the visible mounted file name.+fileBindingName :: FileBinding -> Text+fileBindingName value = value ^. #fileName++-- | Return the structural Settei key targeted by a binding.+fileBindingKey :: FileBinding -> Key+fileBindingKey value = value ^. #key++-- | Return trusted annotations supplied by the caller.+fileBindingAnnotations :: FileBinding -> Map Text Text+fileBindingAnnotations value = value ^. #annotations++-- | Validate a static binding list, accumulating every independent problem.+--+-- An empty list is valid and produces a source with no leaves.+fileBindings :: [FileBinding] -> Either (NonEmpty KubernetesSourceError) FileBindings+fileBindings values =+  case NonEmpty.nonEmpty (bindingErrors values) of+    Just errors -> Left errors+    Nothing -> Right (ValidatedFileBindings values)++-- | Inspect validated bindings without exposing the collection constructor.+fileBindingsList :: FileBindings -> [FileBinding]+fileBindingsList (ValidatedFileBindings values) = values++-- | Start options for one asserted Kubernetes object and stable source name.+mountedDirectoryOptions :: Text -> KubernetesRef -> MountedDirectoryOptions+mountedDirectoryOptions name ref =+  MountedDirectoryOptions+    { name,+      ref,+      annotations = Map.empty,+      keepsTrailingNewline = False+    }++-- | Add trusted source-wide annotations.+annotateMountedDirectoryOptions :: Map Text Text -> MountedDirectoryOptions -> MountedDirectoryOptions+annotateMountedDirectoryOptions annotations options =+  options & #annotations %~ (annotations <>)++-- | Preserve file text verbatim instead of removing one trailing newline.+keepTrailingNewline :: MountedDirectoryOptions -> MountedDirectoryOptions+keepTrailingNewline options = options & #keepsTrailingNewline .~ True++-- | Return the stable source name used in reports.+mountedDirectoryName :: MountedDirectoryOptions -> Text+mountedDirectoryName options = options ^. #name++-- | Return the asserted Kubernetes object reference.+mountedDirectoryRef :: MountedDirectoryOptions -> KubernetesRef+mountedDirectoryRef options = options ^. #ref++-- | Return caller-supplied source-wide annotations.+mountedDirectoryAnnotations :: MountedDirectoryOptions -> Map Text Text+mountedDirectoryAnnotations options = options ^. #annotations++-- | Report whether trailing newlines are preserved.+mountedDirectoryKeepsTrailingNewline :: MountedDirectoryOptions -> Bool+mountedDirectoryKeepsTrailingNewline options = options ^. #keepsTrailingNewline++-- | Return an error's stable category.+kubernetesErrorCategory :: KubernetesSourceError -> KubernetesErrorCategory+kubernetesErrorCategory problem = problem ^. #category++-- | Return the source name associated with an error, when construction reached a source.+kubernetesErrorName :: KubernetesSourceError -> Maybe Text+kubernetesErrorName problem = problem ^. #name++-- | Return the mounted directory or file path associated with an error, when known.+kubernetesErrorPath :: KubernetesSourceError -> Maybe FilePath+kubernetesErrorPath problem = problem ^. #path++-- | Return the concise secret-safe error message.+kubernetesErrorMessage :: KubernetesSourceError -> Text+kubernetesErrorMessage problem = problem ^. #message++-- | Read explicitly bound UTF-8 values from a projected-volume directory.+--+-- Visible file symlinks are followed normally. Bound files that do not exist contribute+-- no leaf. All other I/O and UTF-8 failures are accumulated, and no raw content enters+-- an error. Each present leaf receives its exact file path and Kubernetes object/key+-- annotations. Source-wide annotations retain the requested mount path and read time;+-- each present key also records the modification time of the file that was read.+readMountedDirectorySource ::+  MountedDirectoryOptions ->+  FileBindings ->+  FilePath ->+  IO (Either (NonEmpty KubernetesSourceError) Source)+readMountedDirectorySource options (ValidatedFileBindings bindingsValue) directory = do+  isDirectory <- Directory.doesDirectoryExist directory+  if not isDirectory+    then pure (Left (NonEmpty.singleton (notDirectoryError options directory)))+    else do+      readAt <- getCurrentTime+      outcomes <- traverse (readBinding options directory) bindingsValue+      let errors = [problem | Left problem <- outcomes]+          present = [value | Right (Just value) <- outcomes]+      pure $ case NonEmpty.nonEmpty errors of+        Just found -> Left found+        Nothing -> Right (buildSource options directory readAt present)++-- | List visible mounted entries that have no explicit binding.+--+-- Hidden atomic-writer entries beginning with @..@ are omitted without being statted or+-- traversed. Directory-listing 'IOException's propagate. This helper is intended for a+-- deliberate startup diagnostic rather than source construction.+unboundMountedFiles :: FileBindings -> FilePath -> IO [Text]+unboundMountedFiles (ValidatedFileBindings bindingsValue) directory = do+  entries <- fmap Text.pack <$> Directory.listDirectory directory+  let boundNames = Map.fromList [(bindingValue ^. #fileName, ()) | bindingValue <- bindingsValue]+  pure+    ( sort+        [ entry+        | entry <- entries,+          not (".." `Text.isPrefixOf` entry),+          Map.notMember entry boundNames+        ]+    )++-- | Render one mounted-directory failure as a single operator-readable line.+renderKubernetesErrorText :: KubernetesSourceError -> Text+renderKubernetesErrorText problem =+  prefix <> problem ^. #message+  where+    prefix = case (problem ^. #name, problem ^. #path) of+      (Just mountedName, Just sourcePath) ->+        mountedName <> " (" <> Text.pack sourcePath <> "): "+      (Just mountedName, Nothing) -> mountedName <> ": "+      (Nothing, Just sourcePath) -> Text.pack sourcePath <> ": "+      (Nothing, Nothing) -> "file bindings: "++-- | Render every mounted-directory failure, one line per problem and a trailing newline.+renderKubernetesErrorsText :: NonEmpty KubernetesSourceError -> Text+renderKubernetesErrorsText =+  Text.unlines . fmap renderKubernetesErrorText . NonEmpty.toList++type PresentBinding = (FileBinding, Text, FilePath, UTCTime)++readBinding ::+  MountedDirectoryOptions ->+  FilePath ->+  FileBinding ->+  IO (Either KubernetesSourceError (Maybe PresentBinding))+readBinding options directory bindingValue = do+  let filePath = directory </> Text.unpack (bindingValue ^. #fileName)+  result <-+    try @IOException $ do+      bytes <- ByteString.readFile filePath+      modifiedAt <- Directory.getModificationTime filePath+      pure (bytes, modifiedAt)+  pure $ case result of+    Left exception+      | IOError.isDoesNotExistError exception -> Right Nothing+      | otherwise ->+          Left+            ( sourceError+                options+                KubernetesIoError+                (Just filePath)+                (Text.pack (displayException exception))+            )+    Right (bytes, modifiedAt) -> case TextEncoding.decodeUtf8' bytes of+      Left _ ->+        Left+          ( sourceError+              options+              KubernetesInvalidUtf8+              (Just filePath)+              "file content is not valid UTF-8"+          )+      Right decoded ->+        Right+          ( Just+              ( bindingValue,+                applyNewlinePolicy options decoded,+                filePath,+                modifiedAt+              )+          )++applyNewlinePolicy :: MountedDirectoryOptions -> Text -> Text+applyNewlinePolicy options value+  | options ^. #keepsTrailingNewline = value+  | otherwise = fromMaybe value (Text.stripSuffix "\n" value)++buildSource :: MountedDirectoryOptions -> FilePath -> UTCTime -> [PresentBinding] -> Source+buildSource options directory readAt present =+  annotateSource freshnessAnnotations+    . annotateSource (options ^. #annotations)+    . annotateSourceAt freshnessFor+    . annotateSourceAt annotationsFor+    . locateSource locationFor+    $ source+      (options ^. #name)+      (CustomSource "kubernetes-mounted-directory")+      root+  where+    root =+      foldl+        (\tree (bindingValue, value, _, _) -> insertRawValue (bindingValue ^. #key) (RawText value) tree)+        (RawObject Map.empty)+        present+    locations =+      Map.fromList+        [ ( bindingValue ^. #key,+            SourceLocation+              { path = Text.pack filePath,+                line = Nothing,+                column = Nothing+              }+          )+        | (bindingValue, _, filePath, _) <- present+        ]+    annotationsByKey =+      Map.fromList+        [ ( bindingValue ^. #key,+            kubernetesAnnotations (options ^. #ref & #key .~ Just (bindingValue ^. #fileName))+              <> bindingValue ^. #annotations+          )+        | (bindingValue, _, _, _) <- present+        ]+    modifiedByKey =+      Map.fromList+        [ (bindingValue ^. #key, modifiedAt)+        | (bindingValue, _, _, modifiedAt) <- present+        ]+    freshnessAnnotations =+      Map.fromList+        [ ("kubernetes.mount-path", Text.pack directory),+          ("kubernetes.read-at", renderUtcTime readAt)+        ]+    locationFor target = Map.lookup target locations+    annotationsFor target = Map.findWithDefault Map.empty target annotationsByKey+    freshnessFor target =+      maybe+        Map.empty+        (Map.singleton "kubernetes.file-modified" . renderUtcTime)+        (Map.lookup target modifiedByKey)++renderUtcTime :: UTCTime -> Text+renderUtcTime = Text.pack . formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ"++bindingErrors :: [FileBinding] -> [KubernetesSourceError]+bindingErrors values =+  [ bindingError+      KubernetesInvalidFileName+      ("file name " <> quoted fileName <> " contains a path separator or is reserved")+  | bindingValue <- values,+    let fileName = bindingValue ^. #fileName,+    not (validFileName fileName)+  ]+    <> fmap duplicateFileNameError (duplicates (fmap (^. #fileName) values))+    <> fmap duplicateTargetKeyError (duplicates (fmap (^. #key) values))+    <> overlapErrors (fmap (^. #key) values)++validFileName :: Text -> Bool+validFileName value =+  not (Text.null value)+    && not (Text.any (\character -> character == '/' || character == '\NUL') value)+    && value /= "."+    && not (".." `Text.isPrefixOf` value)++duplicates :: (Ord a) => [a] -> [a]+duplicates values =+  Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(value, 1) | value <- values]))++overlapErrors :: [Key] -> [KubernetesSourceError]+overlapErrors keys =+  [ bindingError+      KubernetesConflictingTargetKeys+      ("target keys " <> renderKey lower <> " and " <> renderKey higher <> " overlap")+  | (positionIndex, lower) <- zip [0 :: Int ..] keys,+    higher <- drop (positionIndex + 1) keys,+    lower /= higher,+    isPrefixKey lower higher || isPrefixKey higher lower+  ]++duplicateFileNameError :: Text -> KubernetesSourceError+duplicateFileNameError fileName =+  bindingError+    KubernetesDuplicateFileName+    ("file name " <> quoted fileName <> " is bound more than once")++duplicateTargetKeyError :: Key -> KubernetesSourceError+duplicateTargetKeyError target =+  bindingError+    KubernetesDuplicateTargetKey+    ("target key " <> renderKey target <> " is bound more than once")++bindingError :: KubernetesErrorCategory -> Text -> KubernetesSourceError+bindingError category message =+  KubernetesSourceError+    { category,+      name = Nothing,+      path = Nothing,+      message+    }++notDirectoryError :: MountedDirectoryOptions -> FilePath -> KubernetesSourceError+notDirectoryError options directory =+  sourceError+    options+    KubernetesNotADirectory+    (Just directory)+    "mounted path is not a directory"++sourceError ::+  MountedDirectoryOptions ->+  KubernetesErrorCategory ->+  Maybe FilePath ->+  Text ->+  KubernetesSourceError+sourceError options category path message =+  KubernetesSourceError+    { category,+      name = Just (options ^. #name),+      path,+      message+    }++quoted :: Text -> Text+quoted value = "\"" <> value <> "\""++isPrefixKey :: Key -> Key -> Bool+isPrefixKey prefix candidateKey =+  NonEmpty.toList (keySegments prefix)+    `isListPrefixOf` NonEmpty.toList (keySegments candidateKey)++isListPrefixOf :: (Eq a) => [a] -> [a] -> Bool+isListPrefixOf [] _ = True+isListPrefixOf _ [] = False+isListPrefixOf (left : leftRest) (right : rightRest) =+  left == right && isListPrefixOf leftRest rightRest++insertRawValue :: Key -> RawValue -> RawValue -> RawValue+insertRawValue key value = go (NonEmpty.toList (keySegments key))+  where+    go [] _ = value+    go (segment : rest) (RawObject object) =+      RawObject+        ( object+            & at segment+            %~ Just+            . go rest+            . fromMaybe (RawObject Map.empty)+        )+    go _ _ = error "validated file-binding keys cannot overlap"
+ src/Settei/Kubernetes/Bindings.hs view
@@ -0,0 +1,58 @@+-- |+-- Module: Settei.Kubernetes.Bindings+-- Description: Derive validated environment bindings from Kubernetes object references.+module Settei.Kubernetes.Bindings+  ( ObjectKeyBinding (..),+    bindingsFromConfigMap,+    bindingsFromSecret,+    objectKeyBinding,+  )+where++import Data.Generics.Labels ()+import Settei+import Settei.Env+import Settei.Prelude++-- | One row of a derivation: this data key feeds this variable at this target key.+--+-- Holds only names and keys, never configuration values.+data ObjectKeyBinding = ObjectKeyBinding+  { objectKey :: !Text,+    envName :: !EnvName,+    targetKey :: !Key+  }+  deriving stock (Generic, Eq, Show)++-- | Construct one derivation row.+objectKeyBinding :: Text -> EnvName -> Key -> ObjectKeyBinding+objectKeyBinding objectKey envName targetKey =+  ObjectKeyBinding {objectKey, envName, targetKey}++-- | Derive validated bindings from one Secret: namespace, object name, key rows.+--+-- Every generated binding carries the annotations of a per-key reference whose+-- @kubernetes.object-key@ is the row's data key, so the provenance annotation cannot+-- drift from the binding. Validation is settei-env's: invalid or duplicate variable+-- names and duplicate or overlapping target keys are rejected as 'EnvError's.+bindingsFromSecret ::+  Maybe Text -> Text -> [ObjectKeyBinding] -> Either (NonEmpty EnvError) Bindings+bindingsFromSecret = bindingsFromObject SecretObject++-- | 'bindingsFromSecret' for a ConfigMap reference.+bindingsFromConfigMap ::+  Maybe Text -> Text -> [ObjectKeyBinding] -> Either (NonEmpty EnvError) Bindings+bindingsFromConfigMap = bindingsFromObject ConfigMapObject++bindingsFromObject ::+  KubernetesObjectKind ->+  Maybe Text ->+  Text ->+  [ObjectKeyBinding] ->+  Either (NonEmpty EnvError) Bindings+bindingsFromObject kind namespace objectName rows = bindings (fmap derive rows)+  where+    derive row =+      fromKubernetesObject+        (kubernetesRef kind namespace objectName (Just (row ^. #objectKey)))+        (binding (row ^. #envName) (row ^. #targetKey))
+ test/Main.hs view
@@ -0,0 +1,7 @@+module Main (main) where++import Settei.KubernetesTest qualified as KubernetesTest+import Test.Tasty (defaultMain, testGroup)++main :: IO ()+main = defaultMain (testGroup "settei-kubernetes" [KubernetesTest.tests])
+ test/Settei/KubernetesTest.hs view
@@ -0,0 +1,560 @@+{-# LANGUAGE ImportQualifiedPost #-}++module Settei.KubernetesTest (tests) where++import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.ByteString.Char8 qualified as ByteString8+import Data.Generics.Labels ()+import Data.List.NonEmpty qualified as NonEmpty+import Data.Map.Strict qualified as Map+import Data.Text qualified as Text+import Data.Time.Clock (UTCTime)+import Data.Time.Format (defaultTimeLocale, parseTimeM)+import Settei+import Settei.Env+import Settei.Kubernetes+import Settei.Kubernetes.Bindings+import Settei.Prelude+import System.Directory qualified as Directory+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++tests :: TestTree+tests =+  testGroup+    "Settei.Kubernetes"+    [ atomicWriterTests,+      bindingsDerivationTests,+      bindingValidationTests,+      missingAndErrorTests,+      newlineTests,+      provenanceTests,+      rendererTests+    ]++bindingsDerivationTests :: TestTree+bindingsDerivationTests =+  testGroup+    "bindings derivation"+    [ testCase "matches the hand-written Secret binding exactly" $ do+        derived <-+          expectDerivedBindings+            ( bindingsFromSecret+                Nothing+                "settei-example-service-database"+                [objectKeyBinding "password" (EnvName "DATABASE_PASSWORD") passwordKey]+            )+        let reference =+              kubernetesRef+                SecretObject+                Nothing+                "settei-example-service-database"+                (Just "password")+            manual =+              fromKubernetesObject+                reference+                (binding (EnvName "DATABASE_PASSWORD") passwordKey)+        assertBool+          "derived Secret binding differed from the hand-written idiom"+          (bindingsList derived == [manual])+        case bindingsList derived of+          [derivedBinding] ->+            bindingAnnotations derivedBinding @?= kubernetesAnnotations reference+          _ -> fail "expected exactly one derived Secret binding",+      testCase "derives ConfigMap namespace and object annotations" $ do+        derived <-+          expectDerivedBindings+            ( bindingsFromConfigMap+                (Just "payments")+                "service-config"+                [objectKeyBinding "host" (EnvName "SERVICE_HOST") hostKey]+            )+        case bindingsList derived of+          [derivedBinding] -> do+            bindingAnnotations derivedBinding+              ^. at "kubernetes.object-kind"+              @?= Just "ConfigMap"+            bindingAnnotations derivedBinding+              ^. at "kubernetes.namespace"+              @?= Just "payments"+            bindingAnnotations derivedBinding+              ^. at "kubernetes.object-name"+              @?= Just "service-config"+            bindingAnnotations derivedBinding+              ^. at "kubernetes.object-key"+              @?= Just "host"+          _ -> fail "expected exactly one derived ConfigMap binding",+      testCase "allows one object key to feed multiple variables" $ do+        derived <-+          expectDerivedBindings+            ( bindingsFromSecret+                Nothing+                "shared-secret"+                [ objectKeyBinding "shared" (EnvName "DATABASE_PASSWORD") passwordKey,+                  objectKeyBinding "shared" (EnvName "SERVICE_HOST") hostKey+                ]+            )+        fmap ((^. at "kubernetes.object-key") . bindingAnnotations) (bindingsList derived)+          @?= [Just "shared", Just "shared"],+      testCase "rejects duplicate variables and overlapping target keys" $ do+        let duplicateName =+              bindingsFromSecret+                Nothing+                "service-secret"+                [ objectKeyBinding "first" (EnvName "SERVICE_VALUE") databaseKey,+                  objectKeyBinding "second" (EnvName "SERVICE_VALUE") hostKey+                ]+            overlappingTargets =+              bindingsFromConfigMap+                Nothing+                "service-config"+                [ objectKeyBinding "database" (EnvName "DATABASE") databaseKey,+                  objectKeyBinding "password" (EnvName "DATABASE_PASSWORD") passwordKey+                ]+        assertEnvLeftContains isDuplicateEnvName duplicateName+        assertEnvLeftContains isConflictingEnvKey overlappingTargets,+      testCase "merges derived and manual binding collections" $ do+        derived <-+          expectDerivedBindings+            ( bindingsFromSecret+                Nothing+                "service-secret"+                [objectKeyBinding "password" (EnvName "DATABASE_PASSWORD") passwordKey]+            )+        manual <- expectEnvBindings [binding (EnvName "MANUAL_VALUE") manualKey]+        merged <- expectDerivedBindings (mergeBindings [derived, manual])+        assertBool+          "derived and manual collections did not compose"+          (bindingsList merged == bindingsList derived <> bindingsList manual)++        duplicateManual <- expectEnvBindings [binding (EnvName "DATABASE_PASSWORD") manualKey]+        assertEnvLeftContains isDuplicateEnvName (mergeBindings [derived, duplicateManual])+    ]++atomicWriterTests :: TestTree+atomicWriterTests =+  testGroup+    "atomic writer layout"+    [ testCase "reads bound keys through ..data symlinks" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          atomicWriterFixture+            root+            [ ("password", ByteString8.pack "s3cr3t"),+              ("http-host", ByteString8.pack "api.internal")+            ]+          validated <- expectBindings [fileBinding "password" passwordKey, fileBinding "http-host" hostKey]+          input <- expectSource =<< readMountedDirectorySource defaultOptions validated root+          value <- expectAnswer (resolve defaultResolveOptions [input] serviceConfig)+          value @?= ("s3cr3t", "api.internal"),+      testCase "unboundMountedFiles skips atomic-writer entries" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          atomicWriterFixture+            root+            [ ("password", ByteString8.pack "s3cr3t"),+              ("http-host", ByteString8.pack "api.internal")+            ]+          validated <- expectBindings [fileBinding "password" passwordKey]+          unbound <- unboundMountedFiles validated root+          unbound @?= ["http-host"]+    ]++bindingValidationTests :: TestTree+bindingValidationTests =+  testGroup+    "binding validation"+    [ invalidNameTest "" "rejects empty file names",+      invalidNameTest "a/b" "rejects path separators",+      invalidNameTest "." "rejects the current-directory name",+      invalidNameTest ".." "rejects the parent-directory name",+      invalidNameTest "..data" "rejects atomic-writer names",+      invalidNameTest (Text.pack ['a', '\NUL']) "rejects NUL in file names",+      testCase "rejects duplicate file names" $ do+        errors <-+          expectBindingErrors+            [fileBinding "value" passwordKey, fileBinding "value" hostKey]+        categories errors @?= [KubernetesDuplicateFileName],+      testCase "rejects duplicate target keys" $ do+        errors <-+          expectBindingErrors+            [fileBinding "first" passwordKey, fileBinding "second" passwordKey]+        categories errors @?= [KubernetesDuplicateTargetKey],+      testCase "rejects prefix-overlapping target keys" $ do+        errors <-+          expectBindingErrors+            [fileBinding "database" databaseKey, fileBinding "password" passwordKey]+        categories errors @?= [KubernetesConflictingTargetKeys],+      testCase "accumulates independent binding problems" $ do+        errors <-+          expectBindingErrors+            [ fileBinding "a/b" databaseKey,+              fileBinding "same" passwordKey,+              fileBinding "same" passwordKey+            ]+        categories errors+          @?= [ KubernetesInvalidFileName,+                KubernetesDuplicateFileName,+                KubernetesDuplicateTargetKey,+                KubernetesConflictingTargetKeys,+                KubernetesConflictingTargetKeys+              ],+      testCase "accepts an empty binding collection" $ do+        validated <- expectBindings []+        assertBool "empty bindings were not retained" (null (fileBindingsList validated))+    ]++missingAndErrorTests :: TestTree+missingAndErrorTests =+  testGroup+    "missing files and input failures"+    [ testCase "an absent bound file stays a resolver concern" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          validated <- expectBindings [fileBinding "missing" passwordKey]+          input <- expectSource =<< readMountedDirectorySource defaultOptions validated root+          case resolve defaultResolveOptions [input] (required passwordSetting) ^. #answer of+            Left errors -> case NonEmpty.toList errors of+              [MissingRequired problem] -> problem ^. #key @?= passwordKey+              _ -> fail "expected one missing-required error"+            Right _ -> fail "expected the required setting to be absent"+          optionalValue <- expectAnswer (resolve defaultResolveOptions [input] (optional passwordSetting))+          optionalValue @?= Nothing,+      testCase "invalid UTF-8 is categorized without retaining bytes" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          atomicWriterFixture root [("password", ByteString.pack [255, 254, 1])]+          validated <- expectBindings [fileBinding "password" passwordKey]+          problem <- expectSingleError =<< readMountedDirectorySource defaultOptions validated root+          kubernetesErrorCategory problem @?= KubernetesInvalidUtf8+          kubernetesErrorPath problem @?= Just (root </> "password")+          kubernetesErrorMessage problem @?= "file content is not valid UTF-8"+          let safeMessage = Text.toLower (kubernetesErrorMessage problem)+          assertBool "error message exposed hexadecimal content" (not ("ff" `Text.isInfixOf` safeMessage))+          assertBool "error message exposed decimal content" (not ("255" `Text.isInfixOf` safeMessage)),+      testCase "a regular file cannot be used as the mount directory" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          let regularFile = root </> "mount"+          ByteString8.writeFile regularFile "value"+          validated <- expectBindings []+          problem <- expectSingleError =<< readMountedDirectorySource defaultOptions validated regularFile+          kubernetesErrorCategory problem @?= KubernetesNotADirectory+          kubernetesErrorPath problem @?= Just regularFile,+      testCase "an unreadable entry is an accumulated IO error" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          Directory.createDirectory (root </> "password")+          validated <- expectBindings [fileBinding "password" passwordKey]+          problem <- expectSingleError =<< readMountedDirectorySource defaultOptions validated root+          kubernetesErrorCategory problem @?= KubernetesIoError+          kubernetesErrorPath problem @?= Just (root </> "password"),+      testCase "a dangling visible symlink behaves as an absent file" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          Directory.createFileLink ("..data" </> "missing") (root </> "broken")+          validated <- expectBindings [fileBinding "broken" passwordKey]+          input <- expectSource =<< readMountedDirectorySource defaultOptions validated root+          case lookupSource passwordKey input of+            Right Nothing -> pure ()+            _ -> fail "expected a dangling symlink to contribute no leaf"+    ]++newlineTests :: TestTree+newlineTests =+  testGroup+    "trailing newline policy"+    [ newlineCase "strips exactly one newline by default" defaultOptions "s3cr3t\n\n" "s3cr3t\n",+      newlineCase "preserves a trailing newline when requested" (keepTrailingNewline defaultOptions) "s3cr3t\n" "s3cr3t\n",+      newlineCase "leaves text without a newline unchanged" defaultOptions "s3cr3t" "s3cr3t"+    ]++provenanceTests :: TestTree+provenanceTests =+  testGroup+    "provenance"+    [ testCase "retains file location, Kubernetes identity, and caller annotations" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          atomicWriterFixture root [("password", ByteString8.pack "s3cr3t")]+          validated <-+            expectBindings+              [ annotateFileBinding+                  (Map.fromList [("owner", "payments"), ("kubernetes.object-name", "wrong")])+                  (fileBinding "password" passwordKey)+              ]+          input <- expectSource =<< readMountedDirectorySource annotatedOptions validated root+          let result = resolve defaultResolveOptions [input] (required passwordSetting)+          _ <- expectAnswer result+          origin <- case result ^. #report . #nodes . at passwordKey of+            Just node -> case node ^. #origin of+              Just value -> pure value+              Nothing -> fail "expected a chosen origin"+            Nothing -> fail "expected the password report node"+          origin ^. #kind @?= CustomSource "kubernetes-mounted-directory"+          origin ^? #location . _Just . #path @?= Just (Text.pack (root </> "password"))+          origin ^? #location . _Just . #line @?= Just Nothing+          origin ^? #location . _Just . #column @?= Just Nothing+          origin ^. #annotations . at "kubernetes.object-kind" @?= Just "Secret"+          origin ^. #annotations . at "kubernetes.namespace" @?= Just "prod"+          origin ^. #annotations . at "kubernetes.object-name" @?= Just "app-credentials"+          origin ^. #annotations . at "kubernetes.object-key" @?= Just "password"+          origin ^. #annotations . at "owner" @?= Just "payments"+          origin ^. #annotations . at "deployment" @?= Just "blue"+          assertBool+            "rendered report omitted Kubernetes mounted-directory provenance"+            ( "kubernetes-mounted-directory source app-secrets from Kubernetes Secret prod/app-credentials key password"+                `Text.isInfixOf` renderResolutionText (result ^. #report)+            ),+      testCase "option and binding accessors expose construction choices" $ do+        let bindingValue = annotateFileBinding (Map.singleton "owner" "payments") (fileBinding "password" passwordKey)+            options = annotateMountedDirectoryOptions (Map.singleton "deployment" "blue") defaultOptions+        fileBindingName bindingValue @?= "password"+        fileBindingKey bindingValue @?= passwordKey+        fileBindingAnnotations bindingValue @?= Map.singleton "owner" "payments"+        mountedDirectoryName options @?= "app-secrets"+        mountedDirectoryRef options @?= secretReference+        mountedDirectoryAnnotations options @?= Map.singleton "deployment" "blue"+        mountedDirectoryKeepsTrailingNewline options @?= False+        mountedDirectoryKeepsTrailingNewline (keepTrailingNewline options) @?= True,+      testCase "records source-wide identity and per-file modification times" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          atomicWriterFixture+            root+            [ ("password", ByteString8.pack "s3cr3t"),+              ("http-host", ByteString8.pack "api.internal")+            ]+          passwordModified <- expectUtc "2026-07-19T12:00:00Z"+          hostModified <- expectUtc "2026-07-19T12:05:00Z"+          Directory.setModificationTime (root </> "password") passwordModified+          Directory.setModificationTime (root </> "http-host") hostModified+          let freshnessOptions =+                annotateMountedDirectoryOptions+                  ( Map.fromList+                      [ ("kubernetes.mount-path", "wrong"),+                        ("kubernetes.read-at", "wrong")+                      ]+                  )+                  defaultOptions+          validated <-+            expectBindings+              [ annotateFileBinding+                  (Map.singleton "kubernetes.file-modified" "wrong")+                  (fileBinding "password" passwordKey),+                fileBinding "http-host" hostKey+              ]+          input <- expectSource =<< readMountedDirectorySource freshnessOptions validated root+          passwordOrigin <- expectOriginAt passwordKey input+          hostOrigin <- expectOriginAt hostKey input++          passwordOrigin ^. #annotations . at "kubernetes.mount-path"+            @?= Just (Text.pack root)+          hostOrigin ^. #annotations . at "kubernetes.mount-path"+            @?= Just (Text.pack root)+          passwordReadAt <- expectOriginUtc "kubernetes.read-at" passwordOrigin+          hostReadAt <- expectOriginUtc "kubernetes.read-at" hostOrigin+          passwordReadAt @?= hostReadAt+          observedPasswordModified <-+            expectOriginUtc "kubernetes.file-modified" passwordOrigin+          observedHostModified <-+            expectOriginUtc "kubernetes.file-modified" hostOrigin+          observedPasswordModified @?= passwordModified+          observedHostModified @?= hostModified+          assertBool+            "per-file modification annotations unexpectedly matched"+            (observedPasswordModified /= observedHostModified)+          passwordOrigin ^. #annotations . at "kubernetes.object-kind"+            @?= Just "Secret"+          passwordOrigin ^. #annotations . at "kubernetes.object-name"+            @?= Just "app-credentials"+          passwordOrigin ^. #annotations . at "kubernetes.object-key"+            @?= Just "password"+          hostOrigin ^. #annotations . at "kubernetes.object-key"+            @?= Just "http-host"+    ]++rendererTests :: TestTree+rendererTests =+  testGroup+    "error renderers"+    [ testCase "renders each binding category exactly" $ do+        invalid <- expectOnlyError [fileBinding "a/b" passwordKey]+        duplicateName <- expectOnlyError [fileBinding "tls.crt" passwordKey, fileBinding "tls.crt" hostKey]+        duplicateTarget <- expectOnlyError [fileBinding "one" passwordKey, fileBinding "two" passwordKey]+        overlap <- expectOnlyError [fileBinding "database" databaseKey, fileBinding "password" passwordKey]+        renderKubernetesErrorText invalid+          @?= "file bindings: file name \"a/b\" contains a path separator or is reserved"+        renderKubernetesErrorText duplicateName+          @?= "file bindings: file name \"tls.crt\" is bound more than once"+        renderKubernetesErrorText duplicateTarget+          @?= "file bindings: target key database.password is bound more than once"+        renderKubernetesErrorText overlap+          @?= "file bindings: target keys database and database.password overlap",+      testCase "renders read-time categories with source and path" $+        withSystemTempDirectory "settei-kubernetes" $ \root -> do+          let regularFile = root </> "mount"+          ByteString8.writeFile regularFile "value"+          validatedEmpty <- expectBindings []+          notDirectory <- expectSingleError =<< readMountedDirectorySource defaultOptions validatedEmpty regularFile+          renderKubernetesErrorText notDirectory+            @?= Text.pack ("app-secrets (" <> regularFile <> "): mounted path is not a directory")++          Directory.createDirectory (root </> "unreadable")+          validatedIo <- expectBindings [fileBinding "unreadable" passwordKey]+          ioProblem <- expectSingleError =<< readMountedDirectorySource defaultOptions validatedIo root+          renderKubernetesErrorText ioProblem+            @?= Text.pack ("app-secrets (" <> root </> "unreadable" <> "): ")+              <> kubernetesErrorMessage ioProblem++          withSystemTempDirectory "settei-kubernetes-utf8" $ \utf8Root -> do+            atomicWriterFixture utf8Root [("password", ByteString.pack [255])]+            validatedUtf8 <- expectBindings [fileBinding "password" passwordKey]+            invalidUtf8 <- expectSingleError =<< readMountedDirectorySource defaultOptions validatedUtf8 utf8Root+            renderKubernetesErrorText invalidUtf8+              @?= Text.pack ("app-secrets (" <> utf8Root </> "password" <> "): file content is not valid UTF-8"),+      testCase "plural renderer emits one line per problem" $ do+        errors <-+          expectBindingErrors+            [fileBinding "same" passwordKey, fileBinding "same" passwordKey]+        renderKubernetesErrorsText errors+          @?= Text.unlines+            [ "file bindings: file name \"same\" is bound more than once",+              "file bindings: target key database.password is bound more than once"+            ]+    ]++atomicWriterFixture :: FilePath -> [(FilePath, ByteString)] -> IO ()+atomicWriterFixture root entries = do+  let payloadName = "..2026_07_19_12_00_00.123"+      payload = root </> payloadName+  Directory.createDirectory payload+  mapM_ (\(name, bytes) -> ByteString.writeFile (payload </> name) bytes) entries+  Directory.createDirectoryLink payloadName (root </> "..data")+  mapM_ (\(name, _) -> Directory.createFileLink ("..data" </> name) (root </> name)) entries++invalidNameTest :: Text -> String -> TestTree+invalidNameTest fileName label =+  testCase label $ do+    errors <- expectBindingErrors [fileBinding fileName passwordKey]+    categories errors @?= [KubernetesInvalidFileName]++newlineCase :: String -> MountedDirectoryOptions -> ByteString -> Text -> TestTree+newlineCase label options bytes expected =+  testCase label $+    withSystemTempDirectory "settei-kubernetes" $ \root -> do+      atomicWriterFixture root [("password", bytes)]+      validated <- expectBindings [fileBinding "password" passwordKey]+      input <- expectSource =<< readMountedDirectorySource options validated root+      value <- expectAnswer (resolve defaultResolveOptions [input] (required passwordSetting))+      value @?= expected++expectBindings :: [FileBinding] -> IO FileBindings+expectBindings values = case fileBindings values of+  Left errors -> fail (Text.unpack (renderKubernetesErrorsText errors))+  Right validated -> pure validated++expectBindingErrors :: [FileBinding] -> IO (NonEmpty KubernetesSourceError)+expectBindingErrors values = case fileBindings values of+  Left errors -> pure errors+  Right _ -> fail "expected invalid file bindings"++expectOnlyError :: [FileBinding] -> IO KubernetesSourceError+expectOnlyError values = do+  errors <- expectBindingErrors values+  case NonEmpty.toList errors of+    [problem] -> pure problem+    _ -> fail "expected exactly one binding error"++expectSource :: Either (NonEmpty KubernetesSourceError) Source -> IO Source+expectSource = \case+  Left errors -> fail (Text.unpack (renderKubernetesErrorsText errors))+  Right input -> pure input++expectSingleError :: Either (NonEmpty KubernetesSourceError) Source -> IO KubernetesSourceError+expectSingleError = \case+  Left errors -> case NonEmpty.toList errors of+    [problem] -> pure problem+    _ -> fail "expected exactly one source error"+  Right _ -> fail "expected source construction to fail"++expectAnswer :: ResolveResult a -> IO a+expectAnswer result = case result ^. #answer of+  Left _ -> fail "expected configuration resolution to succeed"+  Right value -> pure value++expectOriginAt :: Key -> Source -> IO Origin+expectOriginAt target input = case lookupSource target input of+  Right (Just found) -> pure (found ^. to candidateOrigin)+  _ -> fail "expected a candidate origin at the bound key"++expectOriginUtc :: Text -> Origin -> IO UTCTime+expectOriginUtc annotationName origin = case origin ^. #annotations . at annotationName of+  Nothing -> fail ("missing annotation " <> Text.unpack annotationName)+  Just rendered -> expectUtc rendered++expectUtc :: Text -> IO UTCTime+expectUtc rendered =+  maybe+    (fail ("invalid UTC timestamp " <> Text.unpack rendered))+    pure+    (parseUtc rendered)++parseUtc :: Text -> Maybe UTCTime+parseUtc =+  parseTimeM True defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" . Text.unpack++expectDerivedBindings :: Either (NonEmpty EnvError) Bindings -> IO Bindings+expectDerivedBindings = either (fail . Text.unpack . renderEnvErrorsText) pure++expectEnvBindings :: [EnvBinding] -> IO Bindings+expectEnvBindings = expectDerivedBindings . bindings++assertEnvLeftContains :: (EnvError -> Bool) -> Either (NonEmpty EnvError) a -> IO ()+assertEnvLeftContains predicate = \case+  Left errors ->+    assertBool+      "expected matching environment binding validation error"+      (any predicate (NonEmpty.toList errors))+  Right _ -> fail "expected environment binding validation to fail"++isDuplicateEnvName :: EnvError -> Bool+isDuplicateEnvName (DuplicateEnvironmentName _) = True+isDuplicateEnvName _ = False++isConflictingEnvKey :: EnvError -> Bool+isConflictingEnvKey (ConflictingTargetKeys _ _) = True+isConflictingEnvKey _ = False++categories :: NonEmpty KubernetesSourceError -> [KubernetesErrorCategory]+categories = fmap kubernetesErrorCategory . NonEmpty.toList++defaultOptions :: MountedDirectoryOptions+defaultOptions = mountedDirectoryOptions "app-secrets" secretReference++annotatedOptions :: MountedDirectoryOptions+annotatedOptions =+  annotateMountedDirectoryOptions (Map.singleton "deployment" "blue") defaultOptions++secretReference :: KubernetesRef+secretReference = kubernetesRef SecretObject (Just "prod") "app-credentials" (Just "ignored")++serviceConfig :: Config (Text, Text)+serviceConfig = (,) <$> required passwordSetting <*> required hostSetting++passwordSetting :: Setting Text+passwordSetting = secretSetting passwordKey "Database password" textDecoder++hostSetting :: Setting Text+hostSetting = publicSetting hostKey "HTTP host" textDecoder++databaseKey :: Key+databaseKey = unsafeKey "database"++passwordKey :: Key+passwordKey = unsafeKey "database.password"++hostKey :: Key+hostKey = unsafeKey "service.http.host"++manualKey :: Key+manualKey = unsafeKey "manual.value"++unsafeKey :: Text -> Key+unsafeKey value = case parseKey value of+  Left problem -> error (show problem)+  Right key -> key