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 +14/−0
- LICENSE +28/−0
- settei-kubernetes.cabal +74/−0
- src/Settei/Kubernetes.hs +483/−0
- src/Settei/Kubernetes/Bindings.hs +58/−0
- test/Main.hs +7/−0
- test/Settei/KubernetesTest.hs +560/−0
+ 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