├── .envrc ├── .prettierignore ├── .release-please-manifest.json ├── test ├── spec │ └── Spec.hs └── doctest │ └── doctest.hs ├── cabal.project ├── .gitignore ├── .editorconfig ├── app └── Main.hs ├── weeder.toml ├── .prettierrc.json ├── fourmolu.yaml ├── release-please-config.json ├── .github └── workflows │ ├── check.yaml │ └── release.yml ├── nix ├── read-yaml.nix └── dev-test-build.sh ├── src ├── Zamazingo │ └── Text.hs └── Opsops │ ├── Nix.hs │ ├── Meta.hs │ ├── Render.hs │ ├── Cli.hs │ └── Spec.hs ├── LICENSE.md ├── .hlint.yaml ├── package.yaml ├── flake.lock ├── spec.example.yaml ├── CHANGELOG.md ├── flake.nix └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist-newstyle/ 2 | dist/ 3 | nix/ 4 | *.md 5 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.0.7" 3 | } 4 | -------------------------------------------------------------------------------- /test/spec/Spec.hs: -------------------------------------------------------------------------------- 1 | 2 | main :: IO () 3 | main = putStrLn "Not implemented yet..." 4 | -------------------------------------------------------------------------------- /cabal.project: -------------------------------------------------------------------------------- 1 | packages: 2 | *.cabal 3 | 4 | package * 5 | ghc-options: -fwrite-ide-info 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.cabal 2 | *~ 3 | /.direnv 4 | /dist 5 | /dist-newstyle 6 | /result 7 | /tmp 8 | spec.yaml 9 | -------------------------------------------------------------------------------- /test/doctest/doctest.hs: -------------------------------------------------------------------------------- 1 | import Test.DocTest (doctest) 2 | 3 | 4 | main :: IO () 5 | main = doctest ["-isrc", "src"] 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | 3 | indent_style = space 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import qualified Opsops.Cli as Cli 4 | import System.Exit (exitWith) 5 | 6 | 7 | main :: IO () 8 | main = Cli.cli >>= exitWith 9 | -------------------------------------------------------------------------------- /weeder.toml: -------------------------------------------------------------------------------- 1 | roots = [ 2 | ## Definitions we always need: 3 | "^Main.main$", 4 | "^Opsops.Cli.*$", 5 | "^Paths_opsops.*", 6 | 7 | ## Temporary suspensions: 8 | "^Zamazingo.*", 9 | "^Opsops.Spec._testJsonRoundtrip$" 10 | ] 11 | type-class-roots = true 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": false, 4 | "trailingComma": "es5", 5 | "printWidth": 120, 6 | "overrides": [ 7 | { 8 | "files": "package.yaml", 9 | "options": { 10 | "singleQuote": true 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /fourmolu.yaml: -------------------------------------------------------------------------------- 1 | indentation: 2 2 | column-limit: none 3 | function-arrows: leading 4 | comma-style: leading 5 | import-export-style: diff-friendly 6 | indent-wheres: true 7 | record-brace-space: true 8 | newlines-between-decls: 2 9 | haddock-style: single-line 10 | haddock-style-module: single-line 11 | let-style: newline 12 | in-style: right-align 13 | single-constraint-parens: never 14 | unicode: never 15 | respectful: false 16 | fixities: [] 17 | reexports: [] 18 | single-deriving-parens: always 19 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 3 | "packages": { 4 | ".": { 5 | "release-type": "simple", 6 | "changelog-path": "CHANGELOG.md", 7 | "include-v-in-tag": true, 8 | "bump-minor-pre-major": true, 9 | "bump-patch-for-minor-pre-major": true, 10 | "draft": false, 11 | "prerelease": false, 12 | "initial-version": "0.0.1", 13 | "extra-files": ["package.yaml"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/check.yaml: -------------------------------------------------------------------------------- 1 | name: "Check, Test and Build Codebase" 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | check: 9 | runs-on: "ubuntu-latest" 10 | 11 | steps: 12 | - name: "Checkout Codebase" 13 | uses: "actions/checkout@v5" 14 | 15 | - name: "Install Nix" 16 | uses: "DeterminateSystems/nix-installer-action@v20" 17 | 18 | - name: "Check, Test and Build" 19 | run: | 20 | nix develop --command bash -c "cabal update --ignore-project && cabal dev-test-build" 21 | 22 | - name: "Build Docker Image" 23 | run: | 24 | nix build .#docker 25 | 26 | - name: "Load Docker Image" 27 | run: | 28 | docker load <./result 29 | -------------------------------------------------------------------------------- /nix/read-yaml.nix: -------------------------------------------------------------------------------- 1 | ## This file is a verbatim copy of: 2 | ## 3 | ## https://github.com/cdepillabout/stacklock2nix/blob/8408f57e929ca713e508f45dc3d846eca20c3379/nix/build-support/stacklock2nix/read-yaml.nix 4 | 5 | { runCommand, remarshal }: 6 | 7 | # Read a YAML file into a Nix datatype using IFD. 8 | # 9 | # Similar to: 10 | # 11 | # > builtins.fromJSON (builtins.readFile ./somefile) 12 | # 13 | # but takes an input file in YAML instead of JSON. 14 | # 15 | # readYAML :: Path -> a 16 | # 17 | # where `a` is the Nixified version of the input file. 18 | path: 19 | 20 | let 21 | jsonOutputDrv = 22 | runCommand 23 | "from-yaml" 24 | { nativeBuildInputs = [ remarshal ]; } 25 | "remarshal -if yaml -i \"${path}\" -of json -o \"$out\""; 26 | in 27 | builtins.fromJSON (builtins.readFile jsonOutputDrv) 28 | -------------------------------------------------------------------------------- /src/Zamazingo/Text.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | -- | This module provides auxiliary definitions for textual values. 4 | module Zamazingo.Text where 5 | 6 | import qualified Data.Text as T 7 | 8 | 9 | -- $setup 10 | -- 11 | -- >>> :set -XOverloadedStrings 12 | 13 | 14 | -- | Like 'show' but produces 'T.Text'. 15 | tshow :: Show a => a -> T.Text 16 | tshow = T.pack . show 17 | 18 | 19 | -- | Sanitizes a given string by replacing consecutive whitespace with 20 | -- a single space. 21 | -- 22 | -- >>> sanitize "" 23 | -- "" 24 | -- >>> sanitize " " 25 | -- "" 26 | -- >>> sanitize " a c b " 27 | -- "a c b" 28 | sanitize :: T.Text -> T.Text 29 | sanitize = T.unwords . T.words 30 | 31 | 32 | -- | Returns a 'Maybe' of non empty string. 33 | -- 34 | -- >>> nonempty "" 35 | -- Nothing 36 | -- >>> nonempty " " 37 | -- Just " " 38 | nonempty :: T.Text -> Maybe T.Text 39 | nonempty "" = Nothing 40 | nonempty x = Just x 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vehbi Sinan Tunalioglu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | permissions: 9 | contents: "write" 10 | pull-requests: "write" 11 | 12 | jobs: 13 | release-please: 14 | runs-on: "ubuntu-latest" 15 | 16 | steps: 17 | - id: "release" 18 | name: "Release" 19 | uses: "googleapis/release-please-action@v4" 20 | 21 | - name: "Checkout Codebase" 22 | if: "${{ steps.release.outputs.release_created }}" 23 | uses: "actions/checkout@v5" 24 | 25 | - name: "Install Nix" 26 | if: "${{ steps.release.outputs.release_created }}" 27 | uses: "DeterminateSystems/nix-installer-action@v20" 28 | 29 | - name: "Build Statically Compiled Executable" 30 | if: "${{ steps.release.outputs.release_created }}" 31 | run: | 32 | nix develop --command bash build-static.sh 33 | 34 | - name: "Upload Release Artifact" 35 | if: "${{ steps.release.outputs.release_created }}" 36 | env: 37 | GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 38 | run: | 39 | gh release upload "${{ steps.release.outputs.tag_name }}" /tmp/opsops-static-linux-x86_64 40 | -------------------------------------------------------------------------------- /.hlint.yaml: -------------------------------------------------------------------------------- 1 | ############################ 2 | # HLint Configuration File # 3 | ############################ 4 | 5 | # See https://github.com/ndmitchell/hlint 6 | 7 | ####################### 8 | # MODULE RESTRICTIONS # 9 | ####################### 10 | 11 | - modules: 12 | - { name: Control.Monad.Error, within: [] } 13 | - { name: [Data.Aeson], as: Aeson } 14 | - { name: Data.ByteString, as: B } 15 | - { name: Data.ByteString.Char8, as: BC } 16 | - { name: Data.ByteString.Lazy, as: BL } 17 | - { name: Data.ByteString.Lazy.Char8, as: BLC } 18 | - { name: Data.Text, as: T } 19 | - { name: Data.Text.Lazy, as: TL } 20 | - { name: Data.Text.Encoding, as: TE } 21 | - { name: Zamazingo.Text, as: Z.Text, importStyle: qualified, asRequired: true } 22 | - { name: Data.Text.IO, as: TIO } 23 | - { name: Options.Applicative, as: OA } 24 | 25 | ########################## 26 | # EXTENSION RESTRICTIONS # 27 | ########################## 28 | 29 | - extensions: 30 | - default: false # All extension are banned by default. 31 | - name: 32 | - OverloadedStrings 33 | - QuasiQuotes 34 | - RecordWildCards 35 | - TemplateHaskell 36 | - TupleSections 37 | 38 | ################ 39 | # CUSTOM RULES # 40 | ################ 41 | 42 | # Replace a $ b $ c with a . b $ c 43 | - group: { name: dollar, enabled: true } 44 | 45 | # Generalise map to fmap, ++ to <> 46 | - group: { name: generalise, enabled: true } 47 | 48 | # Use tshow 49 | - warn: { lhs: Data.Text.pack . show, rhs: Zamazingo.Text.tshow } 50 | - warn: { lhs: Data.Text.pack (show a), rhs: Zamazingo.Text.tshow a } 51 | -------------------------------------------------------------------------------- /package.yaml: -------------------------------------------------------------------------------- 1 | name: opsops 2 | version: 0.0.7 3 | github: vst/opsops 4 | license: MIT 5 | author: Vehbi Sinan Tunalioglu 6 | maintainer: vst@vsthost.com 7 | copyright: Copyright (c) 2024 Vehbi Sinan Tunalioglu 8 | extra-source-files: 9 | - README.md 10 | - CHANGELOG.md 11 | description: Please see the README on GitHub at 12 | dependencies: 13 | - base >= 4.7 && < 5 14 | library: 15 | source-dirs: src 16 | ghc-options: 17 | - '-Wall' 18 | - '-Werror' 19 | - '-Wunused-packages' 20 | dependencies: 21 | - aeson 22 | - bytestring 23 | - containers 24 | - exceptions 25 | - githash 26 | - optparse-applicative 27 | - path 28 | - path-io 29 | - string-interpolate 30 | - template-haskell 31 | - text 32 | - time 33 | - typed-process 34 | - yaml 35 | executables: 36 | opsops: 37 | main: Main.hs 38 | source-dirs: app 39 | ghc-options: 40 | - '-Wall' 41 | - '-Werror' 42 | - '-Wunused-packages' 43 | - '-threaded' 44 | - '-rtsopts' 45 | - '-with-rtsopts=-N' 46 | dependencies: 47 | - opsops 48 | tests: 49 | opsops-test: 50 | main: Spec.hs 51 | source-dirs: test/spec 52 | ghc-options: 53 | - '-Wall' 54 | - '-Werror' 55 | - '-Wunused-packages' 56 | - '-threaded' 57 | - '-rtsopts' 58 | - '-with-rtsopts=-N' 59 | dependencies: [] 60 | opsops-doctest: 61 | main: doctest.hs 62 | source-dirs: test/doctest 63 | ghc-options: 64 | - '-Wall' 65 | - '-Werror' 66 | - '-threaded' 67 | dependencies: 68 | - opsops 69 | - doctest 70 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1761468971, 24 | "narHash": "sha256-vY2OLVg5ZTobdroQKQQSipSIkHlxOTrIF1fsMzPh8w8=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "78e34d1667d32d8a0ffc3eba4591ff256e80576e", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-25.05", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /spec.example.yaml: -------------------------------------------------------------------------------- 1 | secrets: 2 | github: 3 | token1: 4 | type: "process" 5 | value: 6 | command: "gh" 7 | arguments: 8 | - "auth" 9 | - "token" 10 | token2: 11 | type: "script" 12 | value: 13 | content: | 14 | printf "%s" "$(gh auth token)" 15 | zamazingo: 16 | secret: 17 | type: "script" 18 | value: 19 | content: | 20 | printf "this is me: %s" "${USER}" 21 | example.com: 22 | password: 23 | type: "script" 24 | value: 25 | interpreter: "python3" 26 | content: | 27 | import netrc 28 | import sys 29 | 30 | _login, _account, password = netrc.netrc().authenticators("example.com") 31 | 32 | sys.stdout.write("password") 33 | dockerhub: 34 | username: 35 | type: "op" 36 | value: 37 | account: "PAIT5BAHSH7DAPEING3EEDIE2E" 38 | vault: "Cloud Accounts" 39 | item: "yies1Ahl4ahqu1afao4nahshoo" 40 | field: "username" 41 | password: 42 | type: "op" 43 | value: 44 | account: "PAIT5BAHSH7DAPEING3EEDIE2E" 45 | vault: "Cloud Accounts" 46 | item: "yies1Ahl4ahqu1afao4nahshoo" 47 | field: "password" 48 | influxdb: 49 | url: 50 | type: "op-read" 51 | value: 52 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 53 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url" 54 | organization: 55 | type: "op-read" 56 | value: 57 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 58 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/organization" 59 | bucket: 60 | type: "op-read" 61 | value: 62 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 63 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/bucket" 64 | token: 65 | type: "op-read" 66 | value: 67 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 68 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/API Tokens/write-only" 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.0.7](https://github.com/vst/opsops/compare/v0.0.6...v0.0.7) (2025-10-28) 4 | 5 | 6 | ### Features 7 | 8 | * add version command ([14a3261](https://github.com/vst/opsops/commit/14a3261679d8e6fb3d3ab2e2436ff209f1729b88)) 9 | 10 | ## [0.0.6](https://github.com/vst/opsops/compare/v0.0.5...v0.0.6) (2024-04-12) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * fix GitHub release upload command ([773e811](https://github.com/vst/opsops/commit/773e81148ed07863e7470ee3ee56747262e9532f)) 16 | 17 | ## [0.0.5](https://github.com/vst/opsops/compare/v0.0.4...v0.0.5) (2024-04-12) 18 | 19 | 20 | ### Features 21 | 22 | * add script to build statically compiled executables ([82e29c6](https://github.com/vst/opsops/commit/82e29c61eadb74b8237df5bebed0bcc29470bd28)) 23 | * allow leading/trailing whitespace and trailing newline treatment ([c5b27e4](https://github.com/vst/opsops/commit/c5b27e4f5af07b9cd4718db23fa14c1d69f8049c)) 24 | * auto-build+upload statically compiled executable during release ([efe6006](https://github.com/vst/opsops/commit/efe6006d28d82901c6a7e497e1682d78820eed67)) 25 | 26 | ## [0.0.4](https://github.com/vst/opsops/compare/v0.0.3...v0.0.4) (2024-03-19) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * **nix:** fix shell completion installation ([c0a20df](https://github.com/vst/opsops/commit/c0a20df627d30d50d94f063154385276b231fd48)) 32 | 33 | ## [0.0.3](https://github.com/vst/opsops/compare/v0.0.2...v0.0.3) (2024-03-12) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **pkg:** add interactive bash to PATH on Nix-based installations ([9818798](https://github.com/vst/opsops/commit/98187988a99efe9603b836f4dc84ac42619d5054)) 39 | 40 | ## [0.0.2](https://github.com/vst/opsops/compare/v0.0.1...v0.0.2) (2024-03-11) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * mark CLI options with relevant action when applicable ([81ce5ec](https://github.com/vst/opsops/commit/81ce5ec99a1de80ae724dee171e72ee6d0bd1c0a)) 46 | * **pkg:** install shell completions in Nix builds ([ae2b0af](https://github.com/vst/opsops/commit/ae2b0afd223f73511738fea4eeed6c08b3db32ee)) 47 | 48 | ## 0.0.1 (2024-01-09) 49 | 50 | 51 | ### Features 52 | 53 | * implement initial functionality ([bd1424b](https://github.com/vst/opsops/commit/bd1424ba34d7bc92a302ce5c54b6ab5f5a001a0e)) 54 | 55 | ## Changelog 56 | -------------------------------------------------------------------------------- /src/Opsops/Nix.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | 5 | -- | This module provides sops-nix definitions. 6 | module Opsops.Nix where 7 | 8 | import qualified Data.List as List 9 | import qualified Data.Map.Strict as Map 10 | import Data.String.Interpolate (i) 11 | import qualified Data.Text as T 12 | import Opsops.Spec ( 13 | SecretNode (..), 14 | SecretNodes (..), 15 | Spec (..), 16 | ) 17 | 18 | 19 | -- | Produces a snippet for including into sops-nix configuration from 20 | -- the given optional path prefix and specification. 21 | sopsNixSnippet 22 | :: Maybe T.Text 23 | -- ^ Optional path prefix. 24 | -> Spec 25 | -- ^ Specification to generate snippet for. 26 | -> T.Text 27 | sopsNixSnippet mP = 28 | T.intercalate "\n" . List.sort . itemsFromNodes mP [] . specSecrets 29 | 30 | 31 | -- | Produces secrets snippet for a given optional path prefix, 32 | -- (reversed) path so far and secret nodes. 33 | itemsFromNodes 34 | :: Maybe T.Text 35 | -- ^ Optional path prefix. 36 | -> [T.Text] 37 | -- ^ Path (reversed). 38 | -> SecretNodes 39 | -- ^ Secret nodes. 40 | -> [T.Text] 41 | itemsFromNodes mP p MkSecretNodes {..} = 42 | concatMap (\(k, v) -> itemsFromNode mP (k : p) v) (Map.toList unSecretNodes) 43 | 44 | 45 | -- | Produces secrets snippet for a given optional path prefix, 46 | -- (reversed) path so far and secret node. 47 | itemsFromNode 48 | :: Maybe T.Text 49 | -- ^ Optional path prefix. 50 | -> [T.Text] 51 | -- ^ Path (reversed). 52 | -> SecretNode 53 | -- ^ Secret node. 54 | -> [T.Text] 55 | itemsFromNode mP p (SecretNodeNodes ns) = itemsFromNodes mP p ns 56 | itemsFromNode mP p (SecretNodeSecret _) = [itemFromPath mP p] 57 | 58 | 59 | -- | Produces single secret snippet for a given optional path prefix 60 | -- and the (reversed) path of the secret as per specification. 61 | -- 62 | -- >>> itemFromPath Nothing ["b", "a"] 63 | -- "\"a/b\" = {};" 64 | -- >>> itemFromPath (Just "a") ["c", "b"] 65 | -- "\"a/b/c\" = { key = \"b/c\"; };" 66 | itemFromPath 67 | :: Maybe T.Text 68 | -- ^ Optional path prefix. 69 | -> [T.Text] 70 | -- ^ Path (reversed). 71 | -> T.Text 72 | itemFromPath mP p = 73 | let 74 | key = T.intercalate "/" (List.reverse p) 75 | in 76 | case mP of 77 | Nothing -> [i|"#{key}" = {};|] 78 | Just sp -> [i|"#{sp}/#{key}" = { key = "#{key}"; };|] 79 | -------------------------------------------------------------------------------- /src/Opsops/Meta.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | {-# LANGUAGE TemplateHaskell #-} 5 | 6 | -- | This module provides project metadata information definitions. 7 | module Opsops.Meta where 8 | 9 | import Data.Aeson (ToJSON (toEncoding)) 10 | import qualified Data.Aeson as Aeson 11 | import Data.Maybe (fromMaybe) 12 | import Data.String.Interpolate (__i) 13 | import qualified Data.Text as T 14 | import qualified Data.Time as Time 15 | import Data.Version (Version, showVersion) 16 | import qualified GitHash as Githash 17 | import qualified Language.Haskell.TH as TH 18 | import qualified Paths_opsops as Paths 19 | import qualified System.Info 20 | 21 | 22 | -- | Application name. 23 | -- 24 | -- >>> name 25 | -- "opsops" 26 | name :: T.Text 27 | name = "opsops" 28 | 29 | 30 | -- | Application title. 31 | -- 32 | -- >>> title 33 | -- "SOPS(-Nix) Goodies" 34 | title :: T.Text 35 | title = "SOPS(-Nix) Goodies" 36 | 37 | 38 | -- | Application version. 39 | -- 40 | -- > version 41 | -- Version {versionBranch = [0,0,0], versionTags = []} 42 | version :: Version 43 | version = Paths.version 44 | 45 | 46 | -- | Application version as a 'String' value. 47 | -- 48 | -- > versionString 49 | -- "0.0.0" 50 | versionString :: String 51 | versionString = showVersion version 52 | 53 | 54 | -- | Application version as a 'T.Text' value. 55 | -- 56 | -- > versionText 57 | -- "0.0.0" 58 | versionText :: T.Text 59 | versionText = T.pack versionString 60 | 61 | 62 | -- | Website homepage URL. 63 | -- 64 | -- >>> homepage 65 | -- "https://github.com/vst/opsops" 66 | homepage :: T.Text 67 | homepage = "https://github.com/vst/opsops" 68 | 69 | 70 | -- | Data definition for build information. 71 | data BuildInfo = BuildInfo 72 | { _buildInfoName :: !T.Text 73 | , _buildInfoTitle :: !T.Text 74 | , _buildInfoVersion :: !T.Text 75 | , _buildInfoTimestamp :: !T.Text 76 | , _buildInfoGitTag :: !(Maybe T.Text) 77 | , _buildInfoGitHash :: !(Maybe T.Text) 78 | , _buildInfoCompilerName :: !T.Text 79 | , _buildInfoCompilerVersion :: !T.Text 80 | } 81 | deriving (Eq, Show) 82 | 83 | 84 | instance Aeson.ToJSON BuildInfo where 85 | toJSON BuildInfo {..} = 86 | Aeson.object 87 | [ "name" Aeson..= _buildInfoName 88 | , "title" Aeson..= _buildInfoTitle 89 | , "version" Aeson..= _buildInfoVersion 90 | , "timestamp" Aeson..= _buildInfoTimestamp 91 | , "gitTag" Aeson..= _buildInfoGitTag 92 | , "gitHash" Aeson..= _buildInfoGitHash 93 | , "compilerName" Aeson..= _buildInfoCompilerName 94 | , "compilerVersion" Aeson..= _buildInfoCompilerVersion 95 | ] 96 | 97 | 98 | toEncoding BuildInfo {..} = 99 | Aeson.pairs 100 | ( "name" Aeson..= _buildInfoName 101 | <> "title" Aeson..= _buildInfoTitle 102 | <> "version" Aeson..= _buildInfoVersion 103 | <> "timestamp" Aeson..= _buildInfoTimestamp 104 | <> "gitTag" Aeson..= _buildInfoGitTag 105 | <> "gitHash" Aeson..= _buildInfoGitHash 106 | <> "compilerName" Aeson..= _buildInfoCompilerName 107 | <> "compilerVersion" Aeson..= _buildInfoCompilerVersion 108 | ) 109 | 110 | 111 | -- | Returns the build information generated as per compile time. 112 | buildInfo :: BuildInfo 113 | buildInfo = 114 | BuildInfo 115 | { _buildInfoName = name 116 | , _buildInfoTitle = title 117 | , _buildInfoVersion = versionText 118 | , _buildInfoTimestamp = $(TH.stringE . Time.formatTime Time.defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" =<< TH.runIO Time.getCurrentTime) 119 | , _buildInfoGitTag = either (const Nothing) (Just . T.pack . Githash.giTag) gitInfo 120 | , _buildInfoGitHash = either (const Nothing) (Just . T.pack . Githash.giHash) gitInfo 121 | , _buildInfoCompilerName = T.pack System.Info.compilerName 122 | , _buildInfoCompilerVersion = T.pack (showVersion System.Info.fullCompilerVersion) 123 | } 124 | 125 | 126 | -- | Builds a pretty text from a given 'BuildInfo' value. 127 | prettyBuildInfo :: BuildInfo -> T.Text 128 | prettyBuildInfo BuildInfo {..} = 129 | [__i| 130 | Name: #{_buildInfoName} 131 | Title: #{_buildInfoTitle} 132 | Version: #{_buildInfoVersion} 133 | Timestamp: #{_buildInfoTimestamp} 134 | Git Tag: #{fromMaybe "N/A" _buildInfoGitTag} 135 | Git Hash: #{fromMaybe "N/A" _buildInfoGitHash} 136 | Compiler Name: #{_buildInfoCompilerName} 137 | Compiler Version: #{_buildInfoCompilerVersion} 138 | |] 139 | 140 | 141 | -- | Git information if any. 142 | gitInfo :: Either String Githash.GitInfo 143 | gitInfo = $$Githash.tGitInfoCwdTry 144 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Haskell Project Template"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils, ... }: 10 | flake-utils.lib.eachDefaultSystem (system: 11 | let 12 | ## Import nixpkgs: 13 | pkgs = import nixpkgs { inherit system; }; 14 | 15 | ## Load readYAML helper: 16 | readYAML = pkgs.callPackage ./nix/read-yaml.nix { }; 17 | 18 | ## Read package information: 19 | package = readYAML ./package.yaml; 20 | 21 | ## Get our Haskell: 22 | thisHaskell = pkgs.haskellPackages.override { 23 | overrides = self: super: { 24 | ${package.name} = self.callCabal2nix package.name ./. { }; 25 | }; 26 | }; 27 | 28 | ## Prepare dev-test-build script: 29 | dev-test-build = pkgs.writeShellApplication { 30 | name = "cabal-dev-test-build"; 31 | text = builtins.readFile ./nix/dev-test-build.sh; 32 | runtimeInputs = [ pkgs.bash pkgs.bc pkgs.moreutils ]; 33 | }; 34 | 35 | ## Prepare Nix shell: 36 | thisShell = thisHaskell.shellFor { 37 | ## Define packages for the shell: 38 | packages = p: [ p.${package.name} ]; 39 | 40 | ## Enable Hoogle: 41 | withHoogle = false; 42 | 43 | ## Build inputs for development shell: 44 | buildInputs = [ 45 | ## Haskell related build inputs: 46 | thisHaskell.apply-refact 47 | thisHaskell.cabal-fmt 48 | thisHaskell.cabal-install 49 | thisHaskell.cabal2nix 50 | thisHaskell.fourmolu 51 | thisHaskell.haskell-language-server 52 | thisHaskell.hlint 53 | thisHaskell.hpack 54 | thisHaskell.weeder 55 | 56 | ## Our development scripts: 57 | dev-test-build 58 | 59 | ## Other build inputs for various development requirements: 60 | pkgs.docker-client 61 | pkgs.git 62 | pkgs.nil 63 | pkgs.nixpkgs-fmt 64 | pkgs.nodePackages.prettier 65 | pkgs.upx 66 | ]; 67 | }; 68 | 69 | thisPackage = pkgs.haskell.lib.justStaticExecutables ( 70 | thisHaskell.${package.name}.overrideAttrs (oldAttrs: { 71 | nativeBuildInputs = (oldAttrs.nativeBuildInputs or [ ]) ++ [ 72 | pkgs.git 73 | pkgs.installShellFiles 74 | pkgs.makeWrapper 75 | pkgs.ronn 76 | ]; 77 | 78 | postFixup = (oldAttrs.postFixup or "") + '' 79 | ## Create output directories: 80 | mkdir -p $out/{bin} 81 | 82 | ## Wrap program to add PATHs to dependencies: 83 | wrapProgram $out/bin/${package.name} --prefix PATH : ${pkgs.lib.makeBinPath [ 84 | pkgs.bashInteractive ## Added for bash-based CLI option completions 85 | ]} 86 | 87 | ## Install completion scripts: 88 | installShellCompletion --bash --name ${package.name}.bash <($out/bin/${package.name} --bash-completion-script "$out/bin/${package.name}") 89 | installShellCompletion --fish --name ${package.name}.fish <($out/bin/${package.name} --fish-completion-script "$out/bin/${package.name}") 90 | installShellCompletion --zsh --name _${package.name} <($out/bin/${package.name} --zsh-completion-script "$out/bin/${package.name}") 91 | ''; 92 | }) 93 | ); 94 | 95 | thisDocker = pkgs.dockerTools.buildImage { 96 | name = "${package.name}"; 97 | tag = "v${package.version}"; 98 | created = "now"; 99 | 100 | copyToRoot = pkgs.buildEnv { 101 | name = "image-root"; 102 | paths = [ pkgs.cacert ]; 103 | pathsToLink = [ "/etc" ]; 104 | }; 105 | 106 | runAsRoot = '' 107 | #!${pkgs.runtimeShell} 108 | ${pkgs.dockerTools.shadowSetup} 109 | groupadd -r users 110 | useradd -r -g users patron 111 | ''; 112 | 113 | config = { 114 | User = "patron"; 115 | Entrypoint = [ "${thisPackage}/bin/${package.name}" ]; 116 | Cmd = null; 117 | }; 118 | }; 119 | in 120 | { 121 | ## Project packages output: 122 | packages = { 123 | "${package.name}" = thisPackage; 124 | docker = thisDocker; 125 | default = self.packages.${system}.${package.name}; 126 | }; 127 | 128 | ## Project development shell output: 129 | devShells = { 130 | default = thisShell; 131 | }; 132 | }); 133 | } 134 | -------------------------------------------------------------------------------- /nix/dev-test-build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ## Purpose: This script is used to run all the necessary checks and 4 | ## tests for the project. 5 | 6 | ## Fail on any error: 7 | set -e 8 | 9 | ## Declare default styles: 10 | _sty_bold="" 11 | _sty_underline="" 12 | _sty_standout="" 13 | _sty_normal="" 14 | _sty_black="" 15 | _sty_red="" 16 | _sty_green="" 17 | _sty_yellow="" 18 | _sty_blue="" 19 | _sty_magenta="" 20 | _sty_cyan="" 21 | _sty_white="" 22 | 23 | ## Set styles if we are on terminal: 24 | if test -t 1; then 25 | ## Check if the terminal supports colors: 26 | ncolors=$(tput colors) 27 | 28 | ## Defines styles: 29 | if test -n "$ncolors" && test "${ncolors}" -ge 8; then 30 | _sty_bold="$(tput bold)" 31 | _sty_underline="$(tput smul)" 32 | _sty_standout="$(tput smso)" 33 | _sty_normal="$(tput sgr0)" 34 | _sty_black="$(tput setaf 0)" 35 | _sty_red="$(tput setaf 1)" 36 | _sty_green="$(tput setaf 2)" 37 | _sty_yellow="$(tput setaf 3)" 38 | _sty_blue="$(tput setaf 4)" 39 | _sty_magenta="$(tput setaf 5)" 40 | _sty_cyan="$(tput setaf 6)" 41 | _sty_white="$(tput setaf 7)" 42 | fi 43 | fi 44 | 45 | _clean="" 46 | 47 | ## Are we being run via cabal command? 48 | if [ -n "${CABAL:-}" ] && [ "${1}" = "dev-test-build" ]; then 49 | shift 50 | fi 51 | 52 | while getopts ":c" opt; do 53 | case ${opt} in 54 | c) 55 | _clean="true" 56 | ;; 57 | ?) 58 | echo "Invalid option: -${OPTARG}." 59 | exit 1 60 | ;; 61 | esac 62 | done 63 | 64 | _get_now() { 65 | t=${EPOCHREALTIME} # remove the decimal separator (s → µs) 66 | t=${t%???} # remove the last three digits (µs → ms) 67 | echo "${t}" 68 | } 69 | 70 | _get_diff() { 71 | printf "scale=3; %s - %s\n" "${2}" "${1}" | bc 72 | } 73 | 74 | _print_header() { 75 | printf "${_sty_bold}${_sty_blue}🔵 Running %s${_sty_normal}" "${1}" 76 | } 77 | 78 | _print_success() { 79 | _start="${1}" 80 | _until="${2}" 81 | _elapsed=$(_get_diff "${_start}" "${_until}") 82 | printf "${_sty_bold}${_sty_green} ✅ %ss${_sty_normal}\n" "${_elapsed}" 83 | } 84 | 85 | _clean() { 86 | _print_header "clean" 87 | _start=$(_get_now) 88 | chronic -- cabal clean && chronic -- cabal v1-clean 89 | _print_success "${_start}" "$(_get_now)" 90 | } 91 | 92 | _hpack() { 93 | _print_header "hpack (v$(hpack --numeric-version))" 94 | _start=$(_get_now) 95 | chronic -- hpack 96 | _print_success "${_start}" "$(_get_now)" 97 | } 98 | 99 | _fourmolu() { 100 | _print_header "fourmolu (v$(fourmolu --version | head -n1 | cut -d' ' -f2))" 101 | _start=$(_get_now) 102 | chronic -- fourmolu --quiet --mode check app/ src/ test/ 103 | _print_success "${_start}" "$(_get_now)" 104 | } 105 | 106 | _prettier() { 107 | _print_header "prettier (v$(prettier --version))" 108 | _start=$(_get_now) 109 | chronic -- prettier --check . 110 | _print_success "${_start}" "$(_get_now)" 111 | } 112 | 113 | _nixpkgs_fmt() { 114 | _print_header "nixpkgs-fmt (v$(nixpkgs-fmt --version 2>&1 | cut -d' ' -f2))" 115 | _start=$(_get_now) 116 | chronic -- find . -iname "*.nix" -exec nixpkgs-fmt --check {} \; 117 | _print_success "${_start}" "$(_get_now)" 118 | } 119 | 120 | _hlint() { 121 | _print_header "hlint (v$(hlint --numeric-version))" 122 | _start=$(_get_now) 123 | chronic -- hlint app/ src/ test/ 124 | _print_success "${_start}" "$(_get_now)" 125 | } 126 | 127 | _cabal_build() { 128 | _print_header "cabal build (v$(cabal --numeric-version))" 129 | _start=$(_get_now) 130 | chronic -- cabal build -O0 131 | _print_success "${_start}" "$(_get_now)" 132 | } 133 | 134 | _cabal_run() { 135 | _print_header "cabal run (v$(cabal --numeric-version))" 136 | _start=$(_get_now) 137 | chronic -- cabal run -O0 opsops -- --version 138 | _print_success "${_start}" "$(_get_now)" 139 | } 140 | 141 | _cabal_test() { 142 | _print_header "cabal test (v$(cabal --numeric-version))" 143 | _start=$(_get_now) 144 | chronic -- cabal v1-test --ghc-options=-O0 145 | _print_success "${_start}" "$(_get_now)" 146 | } 147 | 148 | _weeder() { 149 | _print_header "weeder (v$(weeder --version | head -n1 | cut -d' ' -f3))" 150 | _start=$(_get_now) 151 | chronic -- weeder 152 | _print_success "${_start}" "$(_get_now)" 153 | } 154 | 155 | _cabal_haddock() { 156 | _print_header "cabal haddock (v$(cabal --numeric-version))" 157 | _start=$(_get_now) 158 | chronic -- cabal haddock -O0 \ 159 | --haddock-quickjump \ 160 | --haddock-hyperlink-source \ 161 | --haddock-html-location="https://hackage.haskell.org/package/\$pkg-\$version/docs" 162 | _print_success "${_start}" "$(_get_now)" 163 | } 164 | 165 | _scr_start=$(_get_now) 166 | if [ -n "${_clean}" ]; then 167 | _clean 168 | fi 169 | _hpack 170 | _fourmolu 171 | _prettier 172 | _nixpkgs_fmt 173 | _hlint 174 | _cabal_build 175 | _cabal_run 176 | _cabal_test 177 | _weeder 178 | _cabal_haddock 179 | printf "Finished all in %ss\n" "$(_get_diff "${_scr_start}" "$(_get_now)")" 180 | -------------------------------------------------------------------------------- /src/Opsops/Render.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | {-# LANGUAGE TupleSections #-} 4 | 5 | -- | This module provides definitions to render "opsops" specification 6 | -- into clear secrets output. 7 | module Opsops.Render where 8 | 9 | import Control.Monad.IO.Class (MonadIO (liftIO)) 10 | import qualified Data.Aeson as Aeson 11 | import Data.Bifunctor (bimap) 12 | import qualified Data.Map.Strict as Map 13 | import qualified Data.Text as T 14 | import qualified Data.Text.Lazy as TL 15 | import qualified Data.Text.Lazy.Encoding as TLE 16 | import Opsops.Spec ( 17 | Newline (..), 18 | OpRead (..), 19 | Process (..), 20 | Script (..), 21 | Secret (..), 22 | SecretNode (..), 23 | SecretNodes (..), 24 | Spec (..), 25 | Strip (..), 26 | opToOpRead, 27 | ) 28 | import System.Environment (getEnvironment) 29 | import System.Exit (ExitCode (..), die) 30 | import qualified System.Process.Typed as TP 31 | 32 | 33 | -- | Data definition of a forest encoding clear secrets and their 34 | -- paths. 35 | type Forest = Map.Map T.Text Tree 36 | 37 | 38 | -- | Data definition of a tree encoding clear secrets and their 39 | -- paths. 40 | data Tree 41 | = TreeSecret T.Text 42 | | TreeChildren Forest 43 | 44 | 45 | instance Aeson.ToJSON Tree where 46 | toJSON (TreeSecret o) = Aeson.toJSON o 47 | toJSON (TreeChildren o) = Aeson.toJSON o 48 | 49 | 50 | -- | Renders the given specification into a 'Forest' of clear secrets 51 | -- and their paths. 52 | renderSpec 53 | :: MonadIO m 54 | => Spec 55 | -> m Forest 56 | renderSpec Spec {..} = 57 | renderSecretNodes specSecrets 58 | 59 | 60 | -- | Renders given secrets nodes into a 'Forest' of clear secrets and 61 | -- their paths. 62 | renderSecretNodes 63 | :: MonadIO m 64 | => SecretNodes 65 | -> m Forest 66 | renderSecretNodes MkSecretNodes {..} = 67 | Map.fromList <$> mapM (\(k, v) -> (k,) <$> renderSecretNode v) (Map.toList unSecretNodes) 68 | 69 | 70 | -- | Renders given secrets node into a 'Tree' of clear secrets and 71 | -- their paths. 72 | renderSecretNode 73 | :: MonadIO m 74 | => SecretNode 75 | -> m Tree 76 | renderSecretNode (SecretNodeNodes nodes) = TreeChildren <$> renderSecretNodes nodes 77 | renderSecretNode (SecretNodeSecret secret) = TreeSecret <$> renderSecret secret 78 | 79 | 80 | -- | Renders given secret node into a 'Tree' of clear secrets and 81 | -- their paths. 82 | -- 83 | -- This is the function that performs the 'MonadIO' operations as per 84 | -- secret type. 85 | renderSecret 86 | :: MonadIO m 87 | => Secret 88 | -> m T.Text 89 | renderSecret (SecretProcess p) = renderProcess p 90 | renderSecret (SecretScript s) = renderScript s 91 | renderSecret (SecretOp o) = renderSecret (SecretOpRead (opToOpRead o)) 92 | renderSecret (SecretOpRead OpRead {..}) = 93 | renderSecret . SecretProcess $ 94 | Process 95 | { processCommand = "op" 96 | , processArguments = foldMap (\x -> ["--account", x]) opReadAccount <> ["read"] <> ["--no-newline" | not opReadNewline] <> [opReadUri] 97 | , processEnvironment = mempty 98 | , processStrip = opReadStrip 99 | , processTrailingNewline = opReadTrailingNewline 100 | } 101 | 102 | 103 | -- | Attempts to run a 'Process' and return the secret. 104 | renderProcess 105 | :: MonadIO m 106 | => Process 107 | -> m T.Text 108 | renderProcess Process {..} = do 109 | curenv <- liftIO getEnvironment 110 | let 111 | envars = curenv <> fmap (bimap T.unpack T.unpack) (Map.toList processEnvironment) 112 | command = T.unpack processCommand 113 | arguments = fmap T.unpack processArguments 114 | process = TP.setEnv envars (TP.proc command arguments) 115 | postprocess = maybe id applyNewline processTrailingNewline . maybe id applyStrip processStrip 116 | (ec, out) <- TP.readProcessStdout process 117 | case ec of 118 | ExitFailure _ -> liftIO (die "Error running process. Exiting...") 119 | ExitSuccess -> pure (postprocess (TL.toStrict (TLE.decodeUtf8 out))) 120 | 121 | 122 | -- | Attempts to run a 'Script' and return the secret. 123 | renderScript 124 | :: MonadIO m 125 | => Script 126 | -> m T.Text 127 | renderScript Script {..} = do 128 | let 129 | interpreter = T.unpack scriptInterpreter 130 | arguments = fmap T.unpack scriptArguments 131 | content = TLE.encodeUtf8 (TL.fromStrict scriptContent) 132 | process = TP.setEnvInherit (TP.setStdin (TP.byteStringInput content) (TP.proc interpreter arguments)) 133 | postprocess = maybe id applyNewline scriptTrailingNewline . maybe id applyStrip scriptStrip 134 | (ec, out) <- TP.readProcessStdout process 135 | case ec of 136 | ExitFailure _ -> liftIO (die "Error running script. Exiting...") 137 | ExitSuccess -> pure (postprocess (TL.toStrict (TLE.decodeUtf8 out))) 138 | 139 | 140 | -- | Applies given 'Strip' to the given 'T.Text'. 141 | -- 142 | -- >>> applyStrip StripLeft " \t\n hello \t\n " 143 | -- "hello \t\n " 144 | -- >>> applyStrip StripRight " \t\n hello \t\n " 145 | -- " \t\n hello" 146 | -- >>> applyStrip StripBoth " \t\n hello \t\n " 147 | -- "hello" 148 | applyStrip :: Strip -> T.Text -> T.Text 149 | applyStrip StripLeft = T.stripStart 150 | applyStrip StripRight = T.stripEnd 151 | applyStrip StripBoth = T.strip 152 | 153 | 154 | -- | Applies given 'Newline' to the given 'T.Text'. 155 | -- 156 | -- >>> applyNewline NewlineLf "hello" 157 | -- "hello\n" 158 | -- >>> applyNewline NewlineCrlf "hello" 159 | -- "hello\r\n" 160 | applyNewline :: Newline -> T.Text -> T.Text 161 | applyNewline NewlineLf = (<> "\n") 162 | applyNewline NewlineCrlf = (<> "\r\n") 163 | -------------------------------------------------------------------------------- /src/Opsops/Cli.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE QuasiQuotes #-} 3 | 4 | -- | This module provides top-level definitions for the CLI program. 5 | module Opsops.Cli where 6 | 7 | import Control.Applicative ((<**>), (<|>)) 8 | import Control.Monad (join) 9 | import qualified Data.Aeson as Aeson 10 | import qualified Data.ByteString.Char8 as BC 11 | import qualified Data.ByteString.Lazy.Char8 as BLC 12 | import Data.String.Interpolate (i) 13 | import qualified Data.Text as T 14 | import qualified Data.Text.IO as TIO 15 | import qualified Data.Yaml as Yaml 16 | import qualified Opsops.Meta 17 | import qualified Opsops.Meta as Meta 18 | import qualified Opsops.Nix 19 | import qualified Opsops.Render 20 | import qualified Opsops.Spec 21 | import qualified Options.Applicative as OA 22 | import qualified Path.IO as PIO 23 | import System.Exit (ExitCode (..)) 24 | 25 | 26 | -- * Entrypoint 27 | 28 | 29 | -- | CLI program entrypoint. 30 | cli :: IO ExitCode 31 | cli = 32 | join (OA.execParser (OA.info opts desc)) 33 | where 34 | opts = optProgram <**> infoOptVersion <**> OA.helper 35 | desc = 36 | OA.fullDesc 37 | <> OA.progDesc [i|Visit <#{Opsops.Meta.homepage}> for more information.|] 38 | <> infoModHeader 39 | <> infoModFooter 40 | 41 | 42 | -- * Program 43 | 44 | 45 | -- | Option parser for top-level commands. 46 | optProgram :: OA.Parser (IO ExitCode) 47 | optProgram = 48 | commandNormalize 49 | <|> commandRender 50 | <|> commandSnippet 51 | <|> commandVersion 52 | 53 | 54 | -- * Commands 55 | 56 | 57 | -- ** normalize 58 | 59 | 60 | -- | Definition for @normalize@ CLI command. 61 | commandNormalize :: OA.Parser (IO ExitCode) 62 | commandNormalize = 63 | OA.hsubparser (OA.command "normalize" (OA.info parser infomod) <> OA.metavar "normalize") 64 | where 65 | infomod = 66 | OA.fullDesc 67 | <> infoModHeader 68 | <> OA.progDesc "Normalize specification" 69 | <> OA.footer "This command prints the canonical specification." 70 | parser = 71 | doNormalize 72 | <$> OA.strOption (OA.short 'i' <> OA.long "input" <> OA.action "file" <> OA.help "Path to the specification file.") 73 | 74 | 75 | -- | @normalize@ CLI command program. 76 | doNormalize :: FilePath -> IO ExitCode 77 | doNormalize f = do 78 | path <- PIO.resolveFile' f 79 | spec <- Opsops.Spec.readSpecFile path 80 | BC.putStrLn (Yaml.encode spec) 81 | pure ExitSuccess 82 | 83 | 84 | -- ** render 85 | 86 | 87 | -- | Definition for @render@ CLI command. 88 | commandRender :: OA.Parser (IO ExitCode) 89 | commandRender = 90 | OA.hsubparser (OA.command "render" (OA.info parser infomod) <> OA.metavar "render") 91 | where 92 | infomod = 93 | OA.fullDesc 94 | <> infoModHeader 95 | <> OA.progDesc "Render specification" 96 | <> OA.footer "This command renders the specification into clear secrets." 97 | parser = 98 | doRender 99 | <$> OA.strOption (OA.short 'i' <> OA.long "input" <> OA.action "file" <> OA.help "Path to the specification file.") 100 | 101 | 102 | -- | @render@ CLI command program. 103 | doRender :: FilePath -> IO ExitCode 104 | doRender f = do 105 | path <- PIO.resolveFile' f 106 | spec <- Opsops.Spec.readSpecFile path 107 | clear <- Opsops.Render.renderSpec spec 108 | BC.putStrLn (Yaml.encode clear) 109 | pure ExitSuccess 110 | 111 | 112 | -- ** snippet 113 | 114 | 115 | -- | Definition for @snippet@ CLI command. 116 | commandSnippet :: OA.Parser (IO ExitCode) 117 | commandSnippet = 118 | OA.hsubparser (OA.command "snippet" (OA.info parser infomod) <> OA.metavar "snippet") 119 | where 120 | infomod = 121 | OA.fullDesc 122 | <> infoModHeader 123 | <> OA.progDesc "Show snippets" 124 | <> OA.footer "This command prints snippets." 125 | parser = 126 | commandSnippetSopsNix 127 | 128 | 129 | -- *** sops-nix 130 | 131 | 132 | -- | Definition for @snippet sops-nix@ CLI command. 133 | commandSnippetSopsNix :: OA.Parser (IO ExitCode) 134 | commandSnippetSopsNix = 135 | OA.hsubparser (OA.command "sops-nix" (OA.info parser infomod) <> OA.metavar "sops-nix") 136 | where 137 | infomod = 138 | OA.fullDesc 139 | <> infoModHeader 140 | <> OA.progDesc "Show sops-nix snippet" 141 | <> OA.footer "This command prints sample sops-nix snippet." 142 | parser = 143 | doSnippetSopsNix 144 | <$> OA.strOption (OA.short 'i' <> OA.long "input" <> OA.action "file" <> OA.help "Path to the specification file.") 145 | <*> OA.optional (OA.strOption (OA.short 'p' <> OA.long "prefix" <> OA.help "Optional prefix for sops-nix path.")) 146 | 147 | 148 | -- | @snippet sops-nix@ CLI command program. 149 | doSnippetSopsNix :: FilePath -> Maybe T.Text -> IO ExitCode 150 | doSnippetSopsNix f mP = do 151 | path <- PIO.resolveFile' f 152 | spec <- Opsops.Spec.readSpecFile path 153 | TIO.putStrLn (Opsops.Nix.sopsNixSnippet mP spec) 154 | pure ExitSuccess 155 | 156 | 157 | -- | Definition for @version@ CLI command. 158 | commandVersion :: OA.Parser (IO ExitCode) 159 | commandVersion = OA.hsubparser (OA.command "version" (OA.info parser infomod) <> OA.metavar "version") 160 | where 161 | infomod = 162 | OA.fullDesc 163 | <> infoModHeader 164 | <> OA.progDesc "Show version and build information." 165 | <> OA.footer "This command shows version and build information." 166 | parser = 167 | doVersion 168 | <$> OA.switch (OA.short 'j' <> OA.long "json" <> OA.help "Format output in JSON.") 169 | doVersion json = do 170 | if json 171 | then BLC.putStrLn (Aeson.encode Meta.buildInfo) 172 | else TIO.putStrLn (Meta.prettyBuildInfo Meta.buildInfo) 173 | pure ExitSuccess 174 | 175 | 176 | -- * Helpers 177 | 178 | 179 | -- | Version option parser. 180 | infoOptVersion :: OA.Parser (a -> a) 181 | infoOptVersion = 182 | OA.infoOption Opsops.Meta.versionString $ 183 | OA.short 'v' 184 | <> OA.long "version" 185 | <> OA.help "Show application version and exit" 186 | 187 | 188 | -- | Header 'OA.InfoMod'. 189 | infoModHeader :: OA.InfoMod a 190 | infoModHeader = 191 | OA.header (T.unpack (Opsops.Meta.name <> " - " <> Opsops.Meta.title <> " v" <> Opsops.Meta.versionText)) 192 | 193 | 194 | -- | Footer 'OA.InfoMod'. 195 | infoModFooter :: OA.InfoMod a 196 | infoModFooter = 197 | OA.footer [i|See <#{Opsops.Meta.homepage}> for help and feedback.|] 198 | 199 | 200 | -- | Tests a parser with given arguments. 201 | runParserTest :: OA.Parser a -> [String] -> OA.ParserResult a 202 | runParserTest parser = 203 | OA.execParserPure (OA.prefs prefs) (OA.info (parser <**> OA.helper) infomod) 204 | where 205 | prefs = OA.showHelpOnError <> OA.helpLongEquals <> OA.helpShowGlobals 206 | infomod = OA.fullDesc <> OA.progDesc "Test Parser" <> OA.header "testparser - especially for doctests" 207 | 208 | 209 | -- | Tests an IO parser with given arguments. 210 | runParserTestIO :: OA.Parser (IO a) -> [String] -> IO (Either String ()) 211 | runParserTestIO p as = 212 | case runParserTest p as of 213 | OA.Success _ -> pure (Right ()) 214 | OA.Failure f -> pure (Left (show f)) 215 | OA.CompletionInvoked _ -> pure (Right ()) 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # opsops: SOPS(-Nix) Goodies 2 | 3 | ![GitHub Release](https://img.shields.io/github/v/release/vst/opsops) 4 | ![GitHub issues](https://img.shields.io/github/issues/vst/opsops) 5 | ![GitHub last commit (branch)](https://img.shields.io/github/last-commit/vst/opsops/main) 6 | ![GitHub License](https://img.shields.io/github/license/vst/opsops) 7 | 8 | `opsops` is a command-line application to generate clear [SOPS] 9 | secrets from a given specification and generate [sops-nix] snippets 10 | for it. 11 | 12 | The specification is a YAML/JSON file representing a tree-like 13 | structure where terminal nodes represent how the clear secrets will be 14 | generated, and internal nodes represent the "path" to the clear 15 | secret. 16 | 17 | Currently, system processes, scripts and 1password field reference 18 | URIs are supported: 19 | 20 | ```yaml 21 | secrets: 22 | zamazingo: 23 | secret: 24 | type: "process" 25 | value: 26 | command: "zamazingo" 27 | arguments: ["--hip", "hop"] 28 | github: 29 | token: 30 | type: "script" 31 | value: 32 | content: "printf \"%s\" \"$(gh auth token)\"" 33 | example.com: 34 | password: 35 | type: "script" 36 | value: 37 | interpreter: "python3" 38 | content: | 39 | import netrc 40 | import sys 41 | 42 | _login, _account, password = netrc.netrc().authenticators("example.com") 43 | 44 | sys.stdout.write("password") 45 | dockerhub: 46 | password: 47 | type: "op" 48 | value: 49 | account: "PAIT5BAHSH7DAPEING3EEDIE2E" 50 | vault: "Cloud Accounts" 51 | item: "yies1Ahl4ahqu1afao4nahshoo" 52 | field: "password" 53 | influxdb: 54 | token: 55 | type: "op-read" 56 | value: 57 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 58 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/API Tokens/write-only" 59 | ``` 60 | 61 | 62 | - [opsops: SOPS(-Nix) Goodies](#opsops-sops-nix-goodies) 63 | - [Installation](#installation) 64 | - [Usage](#usage) 65 | - [Specification](#specification) 66 | - [See Canonical Specification](#see-canonical-specification) 67 | - [Render Clear Secrets](#render-clear-secrets) 68 | - [Create Snippet for `sops-nix`](#create-snippet-for-sops-nix) 69 | - [Development](#development) 70 | - [License](#license) 71 | 72 | 73 | ## Installation 74 | 75 | > [!WARNING] 76 | > 77 | > If 1Password is used, 1Password CLI application (`op`) must be on 78 | > `PATH` when running `opsops`. 79 | 80 | Install `opsops` into your Nix profile: 81 | 82 | ```sh 83 | nix profile install github:vst/opsops 84 | ``` 85 | 86 | Alternatively, you can run `opsops` via `nix run` without installing it: 87 | 88 | ```sh 89 | nix run github:vst/opsops -- --help 90 | ``` 91 | 92 | Finally, you can download pre-built binaries from releases page: 93 | 94 | 95 | 96 | ## Usage 97 | 98 | ### Specification 99 | 100 | A specification is a YAML (or JSON) file. Here is an example: 101 | 102 |
103 | See Example 104 | 105 | ```yaml 106 | ## File: opsops.yaml 107 | secrets: 108 | zamazingo: 109 | secret: 110 | type: "process" 111 | value: 112 | command: "zamazingo" 113 | arguments: ["--hip", "hop"] 114 | strip: "both" 115 | trailingNewline: "crlf" 116 | github: 117 | token: 118 | type: "script" 119 | value: 120 | content: "printf \"%s\" \"$(gh auth token)\"" 121 | example.com: 122 | password: 123 | type: "script" 124 | value: 125 | interpreter: "python3" 126 | content: | 127 | import netrc 128 | import sys 129 | 130 | _login, _account, password = netrc.netrc().authenticators("example.com") 131 | 132 | sys.stdout.write("password") 133 | dockerhub: 134 | password: 135 | type: "op" 136 | value: 137 | account: "PAIT5BAHSH7DAPEING3EEDIE2E" 138 | vault: "Cloud Accounts" 139 | item: "yies1Ahl4ahqu1afao4nahshoo" 140 | field: "password" 141 | influxdb: 142 | token: 143 | type: "op-read" 144 | value: 145 | account: "IPAEPH0JI3REE8FICHOOVU4CHA" 146 | uri: "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/API Tokens/write-only" 147 | ``` 148 |
149 | 150 | ### See Canonical Specification 151 | 152 | To see canonical/normalized specification: 153 | 154 | ```sh 155 | opsops normalize --input opsops.yaml 156 | ``` 157 | 158 |
159 | See Output 160 | 161 | ```yaml 162 | secrets: 163 | dockerhub: 164 | password: 165 | type: op 166 | value: 167 | account: PAIT5BAHSH7DAPEING3EEDIE2E 168 | field: password 169 | item: yies1Ahl4ahqu1afao4nahshoo 170 | newline: false 171 | section: null 172 | strip: null 173 | trailingNewline: null 174 | vault: Cloud Accounts 175 | example.com: 176 | password: 177 | type: script 178 | value: 179 | arguments: [] 180 | content: | 181 | import netrc 182 | import sys 183 | 184 | _login, _account, password = netrc.netrc().authenticators("example.com") 185 | 186 | sys.stdout.write("password") 187 | interpreter: python3 188 | strip: null 189 | trailingNewline: null 190 | github: 191 | token: 192 | type: script 193 | value: 194 | arguments: [] 195 | content: printf "%s" "$(gh auth token)" 196 | interpreter: bash 197 | strip: null 198 | trailingNewline: null 199 | influxdb: 200 | token: 201 | type: op-read 202 | value: 203 | account: IPAEPH0JI3REE8FICHOOVU4CHA 204 | newline: false 205 | strip: null 206 | trailingNewline: null 207 | uri: op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/API Tokens/write-only 208 | zamazingo: 209 | secret: 210 | type: process 211 | value: 212 | arguments: 213 | - --hip 214 | - hop 215 | command: zamazingo 216 | environment: {} 217 | strip: both 218 | trailingNewline: crlf 219 | ``` 220 |
221 | 222 | ### Render Clear Secrets 223 | 224 | > [!WARNING] 225 | > 226 | > If 1Password is used, 1Password CLI application (`op`) should be 227 | > authenticated first: 228 | > 229 | > ```sh 230 | > eval $(op signin -f [--account ]) 231 | > ``` 232 | 233 | To render clear secrets: 234 | 235 | ```sh 236 | opsops render --input opsops.yaml 237 | ``` 238 | 239 |
240 | See Output 241 | 242 | ```yaml 243 | example.com: 244 | password: password 245 | github: 246 | token: gho_meecubier5dinohSh3tohphaekuo5Phahpei 247 | zamazingo: 248 | secret: hebelehubele 249 | dockerhub: 250 | password: ohbauy5eing8pheSh6iigooweeZee6ch 251 | influxdb: 252 | token: mu9aephabeadi7zi8goo9peYo8yae7ge 253 | ``` 254 |
255 | 256 | ### Create Snippet for `sops-nix` 257 | 258 | To create snippet for `sops-nix` that can be copied/pasted inside the 259 | `sops-nix` module configuration: 260 | 261 | ```sh 262 | opsops snippet sops-nix --input opsops.yaml 263 | ``` 264 | 265 |
266 | See Output 267 | 268 | ```nix 269 | "dockerhub/password" = {}; 270 | "example.com/password" = {}; 271 | "github/token" = {}; 272 | "influxdb/token" = {}; 273 | "zamazingo/secret" = {}; 274 | ``` 275 |
284 | See Output 285 | 286 | ```nix 287 | "my_namespace/dockerhub/password" = { key = "dockerhub/password"; }; 288 | "my_namespace/example.com/password" = { key = "example.com/password"; }; 289 | "my_namespace/github/token" = { key = "github/token"; }; 290 | "my_namespace/influxdb/token" = { key = "influxdb/token"; }; 291 | "my_namespace/zamazingo/secret" = { key = "zamazingo/secret"; }; 292 | ``` 293 | 294 | 295 | ## Development 296 | 297 | Provision `direnv`: 298 | 299 | ```sh 300 | direnv allow 301 | ``` 302 | 303 | Big, long build command for the impatient: 304 | 305 | ```sh 306 | hpack && 307 | direnv reload && 308 | fourmolu -i app/ src/ test/ && 309 | prettier --write . && 310 | find . -iname "*.nix" -print0 | xargs --null nixpkgs-fmt && 311 | hlint app/ src/ test/ && 312 | cabal build -O0 && 313 | cabal run -O0 opsops -- --version && 314 | cabal v1-test --ghc-options=-O0 && 315 | cabal haddock -O0 316 | ``` 317 | 318 | To check and build: 319 | 320 | ```sh 321 | cabal dev-test-build [-c] 322 | ``` 323 | 324 | ## License 325 | 326 | See [LICENSE]. 327 | 328 | 329 | 330 | [LICENSE]: ./LICENSE.md 331 | [SOPS]: https://github.com/getsops/sops 332 | [sops-nix]: https://github.com/Mic92/sops-nix 333 | -------------------------------------------------------------------------------- /src/Opsops/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | -- | This module provides data definitions for and functions to work 5 | -- with "opsops" specification. 6 | module Opsops.Spec where 7 | 8 | import Control.Applicative ((<|>)) 9 | import Control.Monad.Catch (MonadThrow (throwM)) 10 | import Control.Monad.IO.Class (MonadIO (..)) 11 | import Data.Aeson ((.!=), (.:), (.:?), (.=)) 12 | import qualified Data.Aeson as Aeson 13 | import qualified Data.Map as Map 14 | import qualified Data.Text as T 15 | import qualified Data.Yaml as Yaml 16 | import qualified Path as P 17 | 18 | 19 | -- $setup 20 | -- 21 | -- >>> :set -XOverloadedStrings 22 | -- >>> :set -XTypeApplications 23 | 24 | 25 | -- * Definitions 26 | 27 | 28 | -- | Data definition for "opsops" specification. 29 | newtype Spec = Spec 30 | { specSecrets :: SecretNodes 31 | } 32 | deriving (Eq, Ord, Show) 33 | 34 | 35 | -- | 'Aeson.FromJSON' instance for 'Spec'. 36 | instance Aeson.FromJSON Spec where 37 | parseJSON = Aeson.withObject "Spec" $ \o -> Spec <$> o .: "secrets" 38 | 39 | 40 | -- | 'Aeson.ToJSON' instance for 'Spec'. 41 | instance Aeson.ToJSON Spec where 42 | toJSON Spec {..} = 43 | Aeson.object 44 | [ "secrets" .= specSecrets 45 | ] 46 | 47 | 48 | -- | Data definition for secret nodes. 49 | newtype SecretNodes = MkSecretNodes 50 | { unSecretNodes :: Map.Map T.Text SecretNode 51 | } 52 | deriving (Eq, Ord, Show) 53 | 54 | 55 | -- | 'Aeson.FromJSON' instance for 'SecretNodes'. 56 | instance Aeson.FromJSON SecretNodes where 57 | parseJSON = fmap MkSecretNodes . Aeson.parseJSON 58 | 59 | 60 | -- | 'Aeson.ToJSON' instance for 'SecretNodes'. 61 | instance Aeson.ToJSON SecretNodes where 62 | toJSON (MkSecretNodes v) = Aeson.toJSON v 63 | 64 | 65 | -- | Data definition for a secret node. 66 | -- 67 | -- It can be a container for more secret nodes or a secret. 68 | data SecretNode 69 | = SecretNodeNodes SecretNodes 70 | | SecretNodeSecret Secret 71 | deriving (Eq, Ord, Show) 72 | 73 | 74 | -- | 'Aeson.FromJSON' instance for 'SecretNode'. 75 | instance Aeson.FromJSON SecretNode where 76 | parseJSON v = (SecretNodeSecret <$> Aeson.parseJSON v) <|> (SecretNodeNodes <$> Aeson.parseJSON v) 77 | 78 | 79 | -- | 'Aeson.ToJSON' instance for 'SecretNode'. 80 | instance Aeson.ToJSON SecretNode where 81 | toJSON (SecretNodeNodes ns) = Aeson.toJSON ns 82 | toJSON (SecretNodeSecret s) = Aeson.toJSON s 83 | 84 | 85 | -- | Data definition for secret specifications. 86 | data Secret 87 | = SecretProcess Process 88 | | SecretScript Script 89 | | SecretOp Op 90 | | SecretOpRead OpRead 91 | deriving (Eq, Ord, Show) 92 | 93 | 94 | -- | 'Aeson.FromJSON' instance for 'Secret'. 95 | instance Aeson.FromJSON Secret where 96 | parseJSON = Aeson.withObject "Secret" $ \o -> do 97 | ctype <- o .: "type" 98 | case ctype of 99 | "process" -> o .: "value" >>= fmap SecretProcess . Aeson.parseJSON 100 | "script" -> o .: "value" >>= fmap SecretScript . Aeson.parseJSON 101 | "op" -> o .: "value" >>= fmap SecretOp . Aeson.parseJSON 102 | "op-read" -> o .: "value" >>= fmap SecretOpRead . Aeson.parseJSON 103 | _ -> fail ("Unknown secret type: " <> T.unpack ctype) 104 | 105 | 106 | -- | 'Aeson.ToJSON' instance for 'Secret'. 107 | instance Aeson.ToJSON Secret where 108 | toJSON v = 109 | case v of 110 | SecretProcess vx -> pack "process" vx 111 | SecretScript vx -> pack "script" vx 112 | SecretOp vx -> pack "op" vx 113 | SecretOpRead vx -> pack "op-read" vx 114 | where 115 | pack t a = Aeson.object ["type" .= (t :: T.Text), "value" .= a] 116 | 117 | 118 | -- | Data definition to read secrets from 1Password using @op://@ 119 | -- URIs. 120 | data OpRead = OpRead 121 | { opReadAccount :: !(Maybe T.Text) 122 | , opReadUri :: !T.Text 123 | , opReadNewline :: !Bool 124 | , opReadStrip :: !(Maybe Strip) 125 | , opReadTrailingNewline :: !(Maybe Newline) 126 | } 127 | deriving (Eq, Ord, Show) 128 | 129 | 130 | -- | 'Aeson.FromJSON' instance for 'OpRead'. 131 | -- 132 | -- >>> Aeson.eitherDecode @OpRead "{\"uri\":\"op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url\"}" 133 | -- Right (OpRead {opReadAccount = Nothing, opReadUri = "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url", opReadNewline = False, opReadStrip = Nothing, opReadTrailingNewline = Nothing}) 134 | -- >>> Aeson.eitherDecode @OpRead "{\"account\":\"IPAEPH0JI3REE8FICHOOVU4CHA\",\"newline\":false,\"uri\":\"op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url\"}" 135 | -- Right (OpRead {opReadAccount = Just "IPAEPH0JI3REE8FICHOOVU4CHA", opReadUri = "op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url", opReadNewline = False, opReadStrip = Nothing, opReadTrailingNewline = Nothing}) 136 | instance Aeson.FromJSON OpRead where 137 | parseJSON = Aeson.withObject "OpRead" $ \o -> 138 | OpRead 139 | <$> (o .:? "account") 140 | <*> (o .: "uri") 141 | <*> (o .:? "newline" .!= False) 142 | <*> (o .:? "strip") 143 | <*> (o .:? "trailingNewline") 144 | 145 | 146 | -- | 'Aeson.ToJSON' instance for 'OpRead'. 147 | -- 148 | -- >>> let opRead1 = OpRead {opReadAccount=Nothing, opReadUri="op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url", opReadNewline=False, opReadStrip=Nothing, opReadTrailingNewline=Nothing} 149 | -- >>> Aeson.encode opRead1 150 | -- "{\"account\":null,\"newline\":false,\"strip\":null,\"trailingNewline\":null,\"uri\":\"op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url\"}" 151 | -- >>> _testJsonRoundtrip opRead1 152 | -- True 153 | -- 154 | -- >>> let opRead2 = OpRead {opReadAccount=Just "IPAEPH0JI3REE8FICHOOVU4CHA", opReadUri="op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url", opReadNewline=False, opReadStrip=Nothing, opReadTrailingNewline=Nothing} 155 | -- >>> Aeson.encode opRead2 156 | -- "{\"account\":\"IPAEPH0JI3REE8FICHOOVU4CHA\",\"newline\":false,\"strip\":null,\"trailingNewline\":null,\"uri\":\"op://Devops/OokahCuZ4fo8ahphie1aiFa0ei/Config/url\"}" 157 | -- >>> _testJsonRoundtrip opRead2 158 | -- True 159 | instance Aeson.ToJSON OpRead where 160 | toJSON OpRead {..} = 161 | Aeson.object 162 | [ "account" .= opReadAccount 163 | , "uri" .= opReadUri 164 | , "newline" .= opReadNewline 165 | , "strip" .= opReadStrip 166 | , "trailingNewline" .= opReadTrailingNewline 167 | ] 168 | 169 | 170 | -- | Data definition to read secrets from 1Password items using a more 171 | -- explicit specification encoding. 172 | -- 173 | -- An 'Op' is identical to an 'OpRead' but the encoding of field 174 | -- properties are explicit. Under the hood, it is converted to 175 | -- 'OpRead' URIs to work with. 176 | data Op = Op 177 | { opAccount :: !(Maybe T.Text) 178 | , opVault :: !T.Text 179 | , opItem :: !T.Text 180 | , opSection :: !(Maybe T.Text) 181 | , opField :: !T.Text 182 | , opNewline :: !Bool 183 | , opStrip :: !(Maybe Strip) 184 | , opTrailingNewline :: !(Maybe Newline) 185 | } 186 | deriving (Eq, Ord, Show) 187 | 188 | 189 | -- | 'Aeson.FromJSON' instance for 'Op'. 190 | -- 191 | -- >>> Aeson.eitherDecode @Op "{\"account\":null,\"field\":\"username\",\"item\":\"yies1Ahl4ahqu1afao4nahshoo\",\"newline\":false,\"section\":null,\"vault\":\"Cloud Accounts\"}" 192 | -- Right (Op {opAccount = Nothing, opVault = "Cloud Accounts", opItem = "yies1Ahl4ahqu1afao4nahshoo", opSection = Nothing, opField = "username", opNewline = False, opStrip = Nothing, opTrailingNewline = Nothing}) 193 | -- >>> Aeson.eitherDecode @Op "{\"account\":\"PAIT5BAHSH7DAPEING3EEDIE2E\",\"field\":\"token1\",\"item\":\"yies1Ahl4ahqu1afao4nahshoo\",\"newline\":false,\"section\":\"API Tokens\",\"vault\":\"Cloud Accounts\"}" 194 | -- Right (Op {opAccount = Just "PAIT5BAHSH7DAPEING3EEDIE2E", opVault = "Cloud Accounts", opItem = "yies1Ahl4ahqu1afao4nahshoo", opSection = Just "API Tokens", opField = "token1", opNewline = False, opStrip = Nothing, opTrailingNewline = Nothing}) 195 | instance Aeson.FromJSON Op where 196 | parseJSON = Aeson.withObject "Op" $ \o -> 197 | Op 198 | <$> (o .:? "account") 199 | <*> (o .: "vault") 200 | <*> (o .: "item") 201 | <*> (o .:? "section") 202 | <*> (o .: "field") 203 | <*> (o .:? "newline" .!= False) 204 | <*> (o .:? "strip") 205 | <*> (o .:? "trailingNewline") 206 | 207 | 208 | -- | 'Aeson.ToJSON' instance for 'Op'. 209 | -- 210 | -- >>> let op1 = Op {opAccount=Nothing, opVault="Cloud Accounts", opItem="yies1Ahl4ahqu1afao4nahshoo", opSection=Nothing, opField="username", opNewline=False, opStrip=Nothing, opTrailingNewline=Nothing} 211 | -- >>> Aeson.encode op1 212 | -- "{\"account\":null,\"field\":\"username\",\"item\":\"yies1Ahl4ahqu1afao4nahshoo\",\"newline\":false,\"section\":null,\"strip\":null,\"trailingNewline\":null,\"vault\":\"Cloud Accounts\"}" 213 | -- >>> _testJsonRoundtrip op1 214 | -- True 215 | -- 216 | -- >>> let op2 = Op {opAccount=Just "PAIT5BAHSH7DAPEING3EEDIE2E", opVault="Cloud Accounts", opItem="yies1Ahl4ahqu1afao4nahshoo", opSection=Just "API Tokens", opField="token1", opNewline=False, opStrip=Nothing, opTrailingNewline=Nothing} 217 | -- >>> Aeson.encode op2 218 | -- "{\"account\":\"PAIT5BAHSH7DAPEING3EEDIE2E\",\"field\":\"token1\",\"item\":\"yies1Ahl4ahqu1afao4nahshoo\",\"newline\":false,\"section\":\"API Tokens\",\"strip\":null,\"trailingNewline\":null,\"vault\":\"Cloud Accounts\"}" 219 | -- >>> _testJsonRoundtrip op2 220 | -- True 221 | instance Aeson.ToJSON Op where 222 | toJSON Op {..} = 223 | Aeson.object 224 | [ "account" .= opAccount 225 | , "vault" .= opVault 226 | , "item" .= opItem 227 | , "section" .= opSection 228 | , "field" .= opField 229 | , "newline" .= opNewline 230 | , "strip" .= opStrip 231 | , "trailingNewline" .= opTrailingNewline 232 | ] 233 | 234 | 235 | -- | Converts an 'Op' into an 'OpRead'. 236 | opToOpRead :: Op -> OpRead 237 | opToOpRead Op {..} = 238 | OpRead 239 | { opReadUri = "op://" <> opVault <> "/" <> opItem <> "/" <> maybe "" (<> "/") opSection <> opField 240 | , opReadNewline = opNewline 241 | , opReadAccount = opAccount 242 | , opReadStrip = opStrip 243 | , opReadTrailingNewline = opTrailingNewline 244 | } 245 | 246 | 247 | -- | Data definition for a process that outputs secret of interest. 248 | -- 249 | -- A 'Process' is the command (executable with absolute path OR an 250 | -- executable in our @PATH@), optional list of of arguments to the 251 | -- executable and optional list of environment variables to run the 252 | -- process with. 253 | data Process = Process 254 | { processCommand :: !T.Text -- TODO: Shall we use "P.SomeBase P.File"? 255 | , processArguments :: ![T.Text] 256 | , processEnvironment :: !(Map.Map T.Text T.Text) 257 | , processStrip :: !(Maybe Strip) 258 | , processTrailingNewline :: !(Maybe Newline) 259 | } 260 | deriving (Eq, Ord, Show) 261 | 262 | 263 | -- | 'Aeson.FromJSON' instance for 'Process'. 264 | -- 265 | -- >>> Aeson.eitherDecode @Process "{\"arguments\":[\"auth\",\"token\"],\"command\":\"gh\",\"environment\":{}}" 266 | -- Right (Process {processCommand = "gh", processArguments = ["auth","token"], processEnvironment = fromList [], processStrip = Nothing, processTrailingNewline = Nothing}) 267 | -- >>> Aeson.eitherDecode @Process "{\"arguments\":[],\"command\":\"some-secret\",\"environment\":{\"SECRET\":\"THIS\"}}" 268 | -- Right (Process {processCommand = "some-secret", processArguments = [], processEnvironment = fromList [("SECRET","THIS")], processStrip = Nothing, processTrailingNewline = Nothing}) 269 | instance Aeson.FromJSON Process where 270 | parseJSON = Aeson.withObject "Process" $ \o -> 271 | Process 272 | <$> (o .: "command") 273 | <*> (o .:? "arguments" .!= mempty) 274 | <*> (o .:? "environment" .!= mempty) 275 | <*> (o .:? "strip") 276 | <*> (o .:? "trailingNewline") 277 | 278 | 279 | -- | 'Aeson.ToJSON' instance for 'Process'. 280 | -- 281 | -- >>> let process1 = Process {processCommand="gh", processArguments=["auth", "token"], processEnvironment=mempty, processStrip=Nothing, processTrailingNewline=Nothing} 282 | -- >>> Aeson.encode process1 283 | -- "{\"arguments\":[\"auth\",\"token\"],\"command\":\"gh\",\"environment\":{},\"strip\":null,\"trailingNewline\":null}" 284 | -- >>> _testJsonRoundtrip process1 285 | -- True 286 | -- 287 | -- >>> let process2 = Process {processCommand="some-secret", processArguments=[], processEnvironment=Map.fromList [("SECRET", "THIS")], processStrip=Nothing, processTrailingNewline=Nothing} 288 | -- >>> Aeson.encode process2 289 | -- "{\"arguments\":[],\"command\":\"some-secret\",\"environment\":{\"SECRET\":\"THIS\"},\"strip\":null,\"trailingNewline\":null}" 290 | -- >>> _testJsonRoundtrip process2 291 | -- True 292 | instance Aeson.ToJSON Process where 293 | toJSON Process {..} = 294 | Aeson.object 295 | [ "command" .= processCommand 296 | , "arguments" .= processArguments 297 | , "environment" .= processEnvironment 298 | , "strip" .= processStrip 299 | , "trailingNewline" .= processTrailingNewline 300 | ] 301 | 302 | 303 | -- | Data definition for a script that outputs secret of interest. 304 | -- 305 | -- A 'Script' is an interpreter, optional list of arguments to the 306 | -- interpreter and content to be passed to the interpreter from the 307 | -- standard input. 308 | data Script = Script 309 | { scriptInterpreter :: !T.Text 310 | , scriptArguments :: ![T.Text] 311 | , scriptContent :: !T.Text 312 | , scriptStrip :: !(Maybe Strip) 313 | , scriptTrailingNewline :: !(Maybe Newline) 314 | } 315 | deriving (Eq, Ord, Show) 316 | 317 | 318 | -- | 'Aeson.FromJSON' instance for 'Script'. 319 | -- 320 | -- >>> Aeson.eitherDecode @Script "{\"content\": \"echo hebele\"}" 321 | -- Right (Script {scriptInterpreter = "bash", scriptArguments = [], scriptContent = "echo hebele", scriptStrip = Nothing, scriptTrailingNewline = Nothing}) 322 | -- >>> Aeson.eitherDecode @Script "{\"interpreter\": \"python3\", \"content\": \"print(\\\"hebele\\\")\"}" 323 | -- Right (Script {scriptInterpreter = "python3", scriptArguments = [], scriptContent = "print(\"hebele\")", scriptStrip = Nothing, scriptTrailingNewline = Nothing}) 324 | -- >>> Aeson.eitherDecode @Script "{\"interpreter\": \"bash\", \"arguments\": [\"--noprofile\"], \"content\": \"echo hebele\"}" 325 | -- Right (Script {scriptInterpreter = "bash", scriptArguments = ["--noprofile"], scriptContent = "echo hebele", scriptStrip = Nothing, scriptTrailingNewline = Nothing}) 326 | instance Aeson.FromJSON Script where 327 | parseJSON = Aeson.withObject "Script" $ \o -> 328 | Script 329 | <$> (o .:? "interpreter" .!= "bash") 330 | <*> (o .:? "arguments" .!= mempty) 331 | <*> (o .: "content") 332 | <*> (o .:? "strip") 333 | <*> (o .:? "trailingNewline") 334 | 335 | 336 | -- | 'Aeson.ToJSON' instance for 'Script'. 337 | -- 338 | -- >>> let script1 = Script {scriptInterpreter = "bash", scriptArguments = [], scriptContent = "echo hebele", scriptStrip = Nothing, scriptTrailingNewline = Nothing} 339 | -- >>> Aeson.encode script1 340 | -- "{\"arguments\":[],\"content\":\"echo hebele\",\"interpreter\":\"bash\",\"strip\":null,\"trailingNewline\":null}" 341 | -- >>> _testJsonRoundtrip script1 342 | -- True 343 | -- 344 | -- >>> let script2 = Script {scriptInterpreter = "python3", scriptArguments = [], scriptContent = "print(\"hebele\")", scriptStrip = Nothing, scriptTrailingNewline = Nothing} 345 | -- >>> Aeson.encode script2 346 | -- "{\"arguments\":[],\"content\":\"print(\\\"hebele\\\")\",\"interpreter\":\"python3\",\"strip\":null,\"trailingNewline\":null}" 347 | -- >>> _testJsonRoundtrip script2 348 | -- True 349 | -- 350 | -- >>> let script3 = Script {scriptInterpreter = "bash", scriptArguments = ["--noprofile"], scriptContent = "echo hebele", scriptStrip = Nothing, scriptTrailingNewline = Nothing} 351 | -- >>> Aeson.encode script3 352 | -- "{\"arguments\":[\"--noprofile\"],\"content\":\"echo hebele\",\"interpreter\":\"bash\",\"strip\":null,\"trailingNewline\":null}" 353 | -- >>> _testJsonRoundtrip script3 354 | -- True 355 | instance Aeson.ToJSON Script where 356 | toJSON Script {..} = 357 | Aeson.object 358 | [ "interpreter" .= scriptInterpreter 359 | , "arguments" .= scriptArguments 360 | , "content" .= scriptContent 361 | , "strip" .= scriptStrip 362 | , "trailingNewline" .= scriptTrailingNewline 363 | ] 364 | 365 | 366 | -- | Data definition for whitespace-trimming options. 367 | data Strip 368 | = StripLeft 369 | | StripRight 370 | | StripBoth 371 | deriving (Eq, Ord, Show) 372 | 373 | 374 | -- | 'Aeson.FromJSON' instance for 'Strip'. 375 | -- 376 | -- >>> Aeson.eitherDecode @Strip "\"left\"" 377 | -- Right StripLeft 378 | -- >>> Aeson.eitherDecode @Strip "\"right\"" 379 | -- Right StripRight 380 | -- >>> Aeson.eitherDecode @Strip "\"both\"" 381 | -- Right StripBoth 382 | -- >>> Aeson.eitherDecode @Strip "\"unknown\"" 383 | -- Left "Error in $: Unknown strip value: unknown" 384 | instance Aeson.FromJSON Strip where 385 | parseJSON = Aeson.withText "Strip" $ \t -> case t of 386 | "left" -> pure StripLeft 387 | "right" -> pure StripRight 388 | "both" -> pure StripBoth 389 | _ -> fail ("Unknown strip value: " <> T.unpack t) 390 | 391 | 392 | -- | 'Aeson.ToJSON' instance for 'Strip'. 393 | -- 394 | -- >>> Aeson.encode StripLeft 395 | -- "\"left\"" 396 | -- >>> Aeson.encode StripRight 397 | -- "\"right\"" 398 | -- >>> Aeson.encode StripBoth 399 | -- "\"both\"" 400 | instance Aeson.ToJSON Strip where 401 | toJSON v = case v of 402 | StripLeft -> "left" 403 | StripRight -> "right" 404 | StripBoth -> "both" 405 | 406 | 407 | -- | Data definition for newline options. 408 | data Newline 409 | = NewlineLf 410 | | NewlineCrlf 411 | deriving (Eq, Ord, Show) 412 | 413 | 414 | -- | 'Aeson.FromJSON' instance for 'Newline'. 415 | -- 416 | -- >>> Aeson.eitherDecode @Newline "\"lf\"" 417 | -- Right NewlineLf 418 | -- >>> Aeson.eitherDecode @Newline "\"crlf\"" 419 | -- Right NewlineCrlf 420 | -- >>> Aeson.eitherDecode @Newline "\"unknown\"" 421 | -- Left "Error in $: Unknown strip value: unknown" 422 | instance Aeson.FromJSON Newline where 423 | parseJSON = Aeson.withText "Newline" $ \t -> case t of 424 | "lf" -> pure NewlineLf 425 | "crlf" -> pure NewlineCrlf 426 | _ -> fail ("Unknown strip value: " <> T.unpack t) 427 | 428 | 429 | -- | 'Aeson.ToJSON' instance for 'Newline'. 430 | -- 431 | -- >>> Aeson.encode NewlineLf 432 | -- "\"lf\"" 433 | -- >>> Aeson.encode NewlineCrlf 434 | -- "\"crlf\"" 435 | instance Aeson.ToJSON Newline where 436 | toJSON v = case v of 437 | NewlineLf -> "lf" 438 | NewlineCrlf -> "crlf" 439 | 440 | 441 | -- * Readers 442 | 443 | 444 | -- | Attempts to read and return the 'Spec' from the given file. 445 | readSpecFile 446 | :: MonadIO m 447 | => MonadThrow m 448 | => P.Path P.Abs P.File 449 | -> m Spec 450 | readSpecFile p = do 451 | eSpec <- liftIO (Yaml.decodeFileEither (P.toFilePath p)) 452 | either throwM pure eSpec -- TODO: throw more meaningful exception. 453 | 454 | 455 | -- * Helpers 456 | 457 | 458 | -- ** Testing 459 | 460 | 461 | -- | Tests JSON encode-decode roundtrip. 462 | _testJsonRoundtrip 463 | :: Aeson.FromJSON a 464 | => Aeson.ToJSON a 465 | => Eq a 466 | => a 467 | -> Bool 468 | _testJsonRoundtrip a = 469 | Aeson.decode (Aeson.encode a) == Just a 470 | --------------------------------------------------------------------------------