diff --git a/.changelog/176.txt b/.changelog/176.txt new file mode 100644 index 00000000..21c622bf --- /dev/null +++ b/.changelog/176.txt @@ -0,0 +1,3 @@ +```release-note:feature +vault-secrets: Add support for managing vault-secrets integrations and rotating/dynamic secrets +``` diff --git a/go.mod b/go.mod index a4c048fc..501bc334 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/hashicorp/go-hclog v1.5.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.6.0 - github.com/hashicorp/hcl/v2 v2.19.1 + github.com/hashicorp/hcl/v2 v2.22.0 github.com/hashicorp/hcp-sdk-go v0.115.0 github.com/lithammer/dedent v1.1.0 github.com/manifoldco/promptui v0.9.0 @@ -27,17 +27,20 @@ require ( github.com/stretchr/testify v1.9.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/oauth2 v0.15.0 - golang.org/x/term v0.19.0 + golang.org/x/term v0.24.0 ) -require golang.org/x/sync v0.7.0 // indirect +require ( + golang.org/x/mod v0.21.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/tools v0.25.0 // indirect +) require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect - github.com/agext/levenshtein v1.2.1 // indirect - github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect @@ -72,7 +75,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect @@ -83,15 +86,15 @@ require ( github.com/spf13/cast v1.6.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/zclconf/go-cty v1.13.0 + github.com/zclconf/go-cty v1.15.0 go.mongodb.org/mongo-driver v1.15.0 // indirect go.opentelemetry.io/otel v1.25.0 // indirect go.opentelemetry.io/otel/metric v1.25.0 // indirect go.opentelemetry.io/otel/trace v1.25.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/net v0.24.0 // indirect - golang.org/x/sys v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.34.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ecb1c70b..78f0963a 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,8 @@ github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYr github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= -github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= -github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -93,8 +91,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= -github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/hcp-sdk-go v0.115.0 h1:q6viFNFPd4H4cHm/B9KGYvkpkT5ZSBQASh9KR/zYHEI= github.com/hashicorp/hcp-sdk-go v0.115.0/go.mod h1:vQ4fzdL1AmhIAbCw+4zmFe5Hbpajj3NvRWkJoVuxmAk= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -111,8 +109,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -144,8 +140,8 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= @@ -175,8 +171,6 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -201,8 +195,10 @@ github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= -github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= +github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.mongodb.org/mongo-driver v1.15.0 h1:rJCKC8eEliewXjZGf0ddURtl7tTVy1TK3bfl0gkUSLc= go.mongodb.org/mongo-driver v1.15.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= @@ -218,25 +214,27 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -252,23 +250,25 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= +golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/internal/commands/vaultsecrets/integrations/create.go b/internal/commands/vaultsecrets/integrations/create.go new file mode 100644 index 00000000..fab9d84a --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/create.go @@ -0,0 +1,387 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/manifoldco/promptui" + "github.com/zclconf/go-cty/cty" + "golang.org/x/exp/maps" + + "github.com/hashicorp/hcl/v2/hclsimple" + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type CreateOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + IntegrationName string + ConfigFilePath string + PreviewClient preview_secret_service.ClientService + Client secret_service.ClientService +} + +var IntegrationProviders = maps.Keys(providerToRequiredFields) + +func NewCmdCreate(ctx *cmd.Context, runF func(*CreateOpts) error) *cmd.Command { + opts := &CreateOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + Client: secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "create", + ShortHelp: "Create a new integration.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations create" }} command creates a new Vault Secrets integration. + When the {{ template "mdCodeOrBold" "--config-file" }} flag is specified, the configuration for your integration will be read + from the provided HCL config file. The following fields are required: [type details]. For help populating the details for an + integration type, please refer to the + {{ Link "API reference documentation" "https://developer.hashicorp.com/hcp/api-docs/vault-secrets/2023-11-28" }}. + When the {{ template "mdCodeOrBold" "--config-file" }} + flag is not specified, you will be prompted to create the integration interactively. + `), + Examples: []cmd.Example{ + { + Preamble: `Create a new Vault Secrets integration:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations create sample-integration --config-file=path-to-file/config.hcl + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the integration to create.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "config-file", + DisplayValue: "CONFIG_FILE", + Description: "File path to read integration config data.", + Value: flagvalue.Simple("", &opts.ConfigFilePath), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.IntegrationName = args[0] + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + return cmd +} + +type integrationConfigInternal struct { + Details map[string]any +} + +type IntegrationConfig struct { + Type IntegrationType `hcl:"type"` + Details cty.Value `hcl:"details"` +} + +var ( + TwilioKeys = []string{"account_sid", "api_key_secret", "api_key_sid"} + MongoKeys = []string{"private_key", "public_key"} + AWSKeys = []string{"access_keys", "federated_workload_identity"} + GCPKeys = []string{"service_account_key", "federated_workload_identity"} +) + +var providerToRequiredFields = map[string][]string{ + string(Twilio): TwilioKeys, + string(MongoDBAtlas): MongoKeys, + string(AWS): AWSKeys, + string(GCP): GCPKeys, +} + +var awsAuthMethodsToReqKeys = map[string][]string{ + "federated_workload_identity": {"audience", "role_arn"}, + "access_keys": {"access_key_id", "secret_access_key"}, +} + +var gcpAuthMethodsToReqKeys = map[string][]string{ + "federated_workload_identity": {"audience", "service_account_email"}, + "service_account_key": {"credentials"}, +} + +func createRun(opts *CreateOpts) error { + var ( + config IntegrationConfig + internalConfig integrationConfigInternal + err error + ) + + if opts.ConfigFilePath == "" { + config, internalConfig, err = promptUserForConfig(opts) + if err != nil { + return fmt.Errorf("failed to create integration via cli prompt: %w", err) + } + } else { + if err = hclsimple.DecodeFile(opts.ConfigFilePath, nil, &config); err != nil { + return fmt.Errorf("failed to decode config file: %w", err) + } + + detailsMap, err := CtyValueToMap(config.Details) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + internalConfig.Details = detailsMap + } + + switch config.Type { + case Twilio: + req := preview_secret_service.NewCreateTwilioIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + + var twilioBody preview_models.SecretServiceCreateTwilioIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = twilioBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &twilioBody + req.Body.Name = opts.IntegrationName + + _, err = opts.PreviewClient.CreateTwilioIntegration(req, nil) + if err != nil { + return fmt.Errorf("failed to create Twilio integration: %w", err) + } + + case MongoDBAtlas: + req := preview_secret_service.NewCreateMongoDBAtlasIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + + var mongoDBBody preview_models.SecretServiceCreateMongoDBAtlasIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = mongoDBBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &mongoDBBody + req.Body.Name = opts.IntegrationName + + _, err = opts.PreviewClient.CreateMongoDBAtlasIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to create MongoDB Atlas integration: %w", err) + } + + case AWS: + req := preview_secret_service.NewCreateAwsIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + + var awsBody preview_models.SecretServiceCreateAwsIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &awsBody + req.Body.Name = opts.IntegrationName + + _, err = opts.PreviewClient.CreateAwsIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to create AWS integration: %w", err) + } + + case GCP: + req := preview_secret_service.NewCreateGcpIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + + var gcpBody preview_models.SecretServiceCreateGcpIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &gcpBody + req.Body.Name = opts.IntegrationName + + _, err = opts.PreviewClient.CreateGcpIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to create GCP integration: %w", err) + } + } + + fmt.Fprintln(opts.IO.Err()) + fmt.Fprintf(opts.IO.Err(), "%s Successfully created integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + + return nil +} + +func promptUserForConfig(opts *CreateOpts) (IntegrationConfig, integrationConfigInternal, error) { + var ( + config IntegrationConfig + internalConfig integrationConfigInternal + ) + + if !opts.IO.CanPrompt() { + return config, internalConfig, fmt.Errorf("unable to create integration interactively") + } + + providerPrompt := promptui.Select{ + Label: "Please select the provider you would like to configure", + Items: IntegrationProviders, + Stdin: io.NopCloser(opts.IO.In()), + Stdout: iostreams.NopWriteCloser(opts.IO.Err()), + } + + _, provider, err := providerPrompt.Run() + if err != nil { + return config, internalConfig, fmt.Errorf("provider selection prompt failed: %w", err) + } + config.Type = IntegrationType(provider) + + var ( + fields []string + authMethod string + ) + if config.Type == AWS { + authPrompt := promptui.Select{ + Label: "Please select an authentication method", + Items: providerToRequiredFields[provider], + Stdin: io.NopCloser(opts.IO.In()), + Stdout: iostreams.NopWriteCloser(opts.IO.Err()), + } + + _, authMethod, err = authPrompt.Run() + if err != nil { + return config, internalConfig, fmt.Errorf("authentication method selection prompt failed: %w", err) + } + + fields = awsAuthMethodsToReqKeys[authMethod] + } else if config.Type == GCP { + authPrompt := promptui.Select{ + Label: "Please select an authentication method", + Items: providerToRequiredFields[provider], + Stdin: io.NopCloser(opts.IO.In()), + Stdout: iostreams.NopWriteCloser(opts.IO.Err()), + } + + _, authMethod, err = authPrompt.Run() + if err != nil { + return config, internalConfig, fmt.Errorf("authentication method selection prompt failed: %w", err) + } + fields = gcpAuthMethodsToReqKeys[authMethod] + + } else { + fields = providerToRequiredFields[provider] + } + + var fieldPrompt promptui.Prompt + fieldValues := make(map[string]any) + for _, field := range fields { + fieldPrompt = promptui.Prompt{ + Label: field, + Mask: '*', + } + + input, err := fieldPrompt.Run() + if err != nil { + return config, internalConfig, fmt.Errorf("prompt for field %s failed: %w", field, err) + } + + fieldValues[field] = input + } + + if config.Type == AWS || config.Type == GCP { + internalConfig.Details = map[string]any{authMethod: fieldValues} + return config, internalConfig, err + } + + internalConfig.Details = fieldValues + return config, internalConfig, err +} + +func CtyValueToMap(value cty.Value) (map[string]any, error) { + fieldsMap := make(map[string]any) + for k, v := range value.AsValueMap() { + if v.Type() == cty.String { + fieldsMap[k] = v.AsString() + } else if v.Type() == cty.Bool { + fieldsMap[k] = v.True() + } else if v.Type().IsObjectType() { + nestedMap, err := CtyValueToMap(v) + if err != nil { + return nil, err + } + fieldsMap[k] = nestedMap + } else if v.Type().IsTupleType() { + // Check the type of the first element in the slice + // (we will assume all other elements in slice are of the same type) + if v.AsValueSlice()[0].Type() == cty.String { + var items []string + for _, val := range v.AsValueSlice() { + items = append(items, val.AsString()) + } + fieldsMap[k] = items + } else { + var items []map[string]any + for _, val := range v.AsValueSlice() { + nestedMap, err := CtyValueToMap(val) + if err != nil { + return nil, err + } + items = append(items, nestedMap) + } + fieldsMap[k] = items + } + } else { + return nil, fmt.Errorf("found unsupported value type") + } + } + + return fieldsMap, nil +} diff --git a/internal/commands/vaultsecrets/integrations/create_test.go b/internal/commands/vaultsecrets/integrations/create_test.go new file mode 100644 index 00000000..9c4caadd --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/create_test.go @@ -0,0 +1,189 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func TestNewCmdCreate(t *testing.T) { + t.Parallel() + + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + return tp + } + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *CreateOpts + }{ + { + Name: "Good", + Profile: testProfile, + Args: []string{"sample-integration", "--config-file", "path/to/file"}, + Expect: &CreateOpts{ + IntegrationName: "sample-integration", + ConfigFilePath: "path/to/file", + }, + }, + { + Name: "Failed: No secret name arg specified", + Profile: testProfile, + Args: []string{"--config-file", "path/to/file"}, + Error: "ERROR: accepts 1 arg(s), received 0", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *CreateOpts + createCmd := NewCmdCreate(ctx, func(o *CreateOpts) error { + gotOpts = o + return nil + }) + createCmd.SetIO(io) + + code := createCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.IntegrationName, gotOpts.IntegrationName) + r.Equal(c.Expect.ConfigFilePath, gotOpts.ConfigFilePath) + }) + } +} + +func TestCreateRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + IntegrationName string + Input []byte + Error string + }{ + { + Name: "Good", + IntegrationName: "sample-integration", + Input: []byte(`type = "aws" +details = { + "federated_workload_identity" = { + "audience" = "abc", + "role_arn" = "def" + } + + "capabilities" = [ + "DYNAMIC" + ] +}`), + }, + } + + for _, c := range cases { + + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + tempDir := t.TempDir() + f, err := os.Create(filepath.Join(tempDir, "config.hcl")) + r.NoError(err) + _, err = f.Write(c.Input) + r.NoError(err) + + io := iostreams.Test() + vs := mock_preview_secret_service.NewMockClientService(t) + + opts := &CreateOpts{ + Ctx: context.Background(), + Profile: profile.TestProfile(t).SetOrgID("123").SetProjectID("abc"), + IO: io, + PreviewClient: vs, + Output: format.New(io), + IntegrationName: c.IntegrationName, + ConfigFilePath: f.Name(), + } + + if c.Error == "" { + vs.EXPECT().CreateAwsIntegration(&preview_secret_service.CreateAwsIntegrationParams{ + Context: opts.Ctx, + OrganizationID: "123", + ProjectID: "abc", + Body: &preview_models.SecretServiceCreateAwsIntegrationBody{ + Name: opts.IntegrationName, + FederatedWorkloadIdentity: &preview_models.Secrets20231128AwsFederatedWorkloadIdentityRequest{ + Audience: "abc", + RoleArn: "def", + }, + Capabilities: []*preview_models.Secrets20231128Capability{ + preview_models.Secrets20231128CapabilityDYNAMIC.Pointer(), + }, + }, + }, nil).Return(&preview_secret_service.CreateAwsIntegrationOK{ + Payload: &preview_models.Secrets20231128CreateAwsIntegrationResponse{ + Integration: &preview_models.Secrets20231128AwsIntegration{ + Name: opts.IntegrationName, + FederatedWorkloadIdentity: &preview_models.Secrets20231128AwsFederatedWorkloadIdentityResponse{ + Audience: "abc", + RoleArn: "def", + }, + Capabilities: []*preview_models.Secrets20231128Capability{ + preview_models.Secrets20231128CapabilityDYNAMIC.Pointer(), + }, + }, + }, + }, nil).Once() + } + + // Run the command + err = createRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + r.NoError(err) + r.Contains(io.Error.String(), fmt.Sprintf("✓ Successfully created integration with name %q\n", opts.IntegrationName)) + }) + } +} diff --git a/internal/commands/vaultsecrets/integrations/delete.go b/internal/commands/vaultsecrets/integrations/delete.go new file mode 100644 index 00000000..9190bc06 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/delete.go @@ -0,0 +1,150 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "fmt" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type DeleteOpts struct { + Ctx context.Context + Profile *profile.Profile + Output *format.Outputter + IO iostreams.IOStreams + + IntegrationName string + Type IntegrationType + Client secret_service.ClientService + PreviewClient preview_secret_service.ClientService +} + +func NewCmdDelete(ctx *cmd.Context, runF func(*DeleteOpts) error) *cmd.Command { + opts := &DeleteOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + Output: ctx.Output, + IO: ctx.IO, + Client: secret_service.New(ctx.HCP, nil), + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "delete", + ShortHelp: "Delete a Vault Secrets integration.", + LongHelp: heredoc.New(ctx.IO).Must(fmt.Sprintf(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations delete" }} command deletes a Vault Secrets integration. + The required {{ template "mdCodeOrBold" "--type" }} flag may be any of the following: %v + `, IntegrationProviders)), + Examples: []cmd.Example{ + { + Preamble: `Delete an integration:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations delete sample-integration --type twilio + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the integration to delete.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "type", + DisplayValue: "TYPE", + Description: "The type of the integration to delete.", + Value: flagvalue.Simple("", &opts.Type), + Required: true, + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.IntegrationName = args[0] + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + return cmd +} + +func deleteRun(opts *DeleteOpts) error { + switch opts.Type { + case Twilio: + _, err := opts.PreviewClient.DeleteTwilioIntegration(&preview_secret_service.DeleteTwilioIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to delete integration: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully deleted integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + return nil + + case MongoDBAtlas: + _, err := opts.PreviewClient.DeleteMongoDBAtlasIntegration(&preview_secret_service.DeleteMongoDBAtlasIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to delete integration: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully deleted integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + return nil + + case AWS: + _, err := opts.PreviewClient.DeleteAwsIntegration(&preview_secret_service.DeleteAwsIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to delete integration: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully deleted integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + return nil + + case GCP: + _, err := opts.PreviewClient.DeleteGcpIntegration(&preview_secret_service.DeleteGcpIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to delete integration: %w", err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully deleted integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + return nil + + default: + return fmt.Errorf("not a valid integration type") + } +} diff --git a/internal/commands/vaultsecrets/integrations/delete_test.go b/internal/commands/vaultsecrets/integrations/delete_test.go new file mode 100644 index 00000000..666d4045 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/delete_test.go @@ -0,0 +1,154 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/stretchr/testify/mock" +) + +func TestNewCmdDelete(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *DeleteOpts + }{ + { + Name: "Good", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("abc") + }, + Args: []string{"sample-integration", "--type", "twilio"}, + Expect: &DeleteOpts{ + IntegrationName: "sample-integration", + Type: "twilio", + }, + }, + { + Name: "Missing type flag", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("abc") + }, + Args: []string{"sample-integration"}, + Error: "ERROR: missing required flag: --type=TYPE", + }, + } + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + r := require.New(t) + io := iostreams.Test() + + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + ShutdownCtx: context.Background(), + HCP: &client.Runtime{}, + Output: format.New(io), + } + + var deleteOpts *DeleteOpts + readCmd := NewCmdDelete(ctx, func(o *DeleteOpts) error { + deleteOpts = o + return nil + }) + readCmd.SetIO(io) + + code := readCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(deleteOpts) + r.Equal(c.Expect.IntegrationName, deleteOpts.IntegrationName) + r.Equal(c.Expect.Type, deleteOpts.Type) + }) + } +} + +func TestDeleteRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + ErrMsg string + IntegrationName string + Type IntegrationType + }{ + { + Name: "Failed: Integration not found", + ErrMsg: "[DELETE /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/integrations/twilio/config/{integration_name}][404] DeleteTwilioIntegration", + Type: Twilio, + }, + { + Name: "Success: Delete integration", + IntegrationName: "sample-integration", + Type: Twilio, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + vs := mock_preview_secret_service.NewMockClientService(t) + + opts := &DeleteOpts{ + Ctx: context.Background(), + Profile: profile.TestProfile(t).SetOrgID("123").SetProjectID("abc"), + IO: io, + PreviewClient: vs, + Output: format.New(io), + IntegrationName: c.IntegrationName, + Type: c.Type, + } + + if c.ErrMsg != "" { + vs.EXPECT().DeleteTwilioIntegration(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + vs.EXPECT().DeleteTwilioIntegration(&preview_secret_service.DeleteTwilioIntegrationParams{ + OrganizationID: "123", + ProjectID: "abc", + Name: opts.IntegrationName, + Context: opts.Ctx, + }, nil).Return(&preview_secret_service.DeleteTwilioIntegrationOK{}, nil).Once() + } + + // Run the command + err := deleteRun(opts) + if c.ErrMsg != "" { + r.Contains(err.Error(), c.ErrMsg) + return + } + + r.NoError(err) + r.Contains(io.Error.String(), fmt.Sprintf("✓ Successfully deleted integration with name \"%s\"\n", c.IntegrationName)) + }) + } +} diff --git a/internal/commands/vaultsecrets/integrations/displayer.go b/internal/commands/vaultsecrets/integrations/displayer.go new file mode 100644 index 00000000..78287ddf --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/displayer.go @@ -0,0 +1,321 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/hcp/internal/pkg/format" +) + +type twilioDisplayer struct { + previewTwilioIntegrations []*preview_models.Secrets20231128TwilioIntegration + + single bool +} + +func newTwilioDisplayer(single bool, integrations ...*preview_models.Secrets20231128TwilioIntegration) *twilioDisplayer { + return &twilioDisplayer{ + previewTwilioIntegrations: integrations, + single: single, + } +} + +func (t *twilioDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (t *twilioDisplayer) Payload() any { + if t.previewTwilioIntegrations != nil { + return t.previewTwilioIntegrationsPayload() + } + + return nil +} + +func (t *twilioDisplayer) previewTwilioIntegrationsPayload() any { + if t.single { + if len(t.previewTwilioIntegrations) != 1 { + return nil + } + return t.previewTwilioIntegrations[0] + } + return t.previewTwilioIntegrations +} + +func (t *twilioDisplayer) FieldTemplates() []format.Field { + fields := []format.Field{ + { + Name: "Integration Name", + ValueFormat: "{{ .Name }}", + }, + } + + if t.single { + return append(fields, []format.Field{ + { + Name: "Account SID", + ValueFormat: "{{ .StaticCredentialDetails.AccountSid }}", + }, + { + Name: "API Key SID", + ValueFormat: "{{ .StaticCredentialDetails.APIKeySid }}", + }, + }...) + } else { + return fields + } +} + +type mongodbDisplayer struct { + previewMongoDBIntegrations []*preview_models.Secrets20231128MongoDBAtlasIntegration + + single bool +} + +func newMongoDBDisplayer(single bool, integrations ...*preview_models.Secrets20231128MongoDBAtlasIntegration) *mongodbDisplayer { + return &mongodbDisplayer{ + previewMongoDBIntegrations: integrations, + single: single, + } +} + +func (m *mongodbDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (m *mongodbDisplayer) Payload() any { + + if m.previewMongoDBIntegrations != nil { + return m.previewMongoDBIntegrationsPayload() + } + + return nil +} + +func (m *mongodbDisplayer) previewMongoDBIntegrationsPayload() any { + if m.single { + if len(m.previewMongoDBIntegrations) != 1 { + return nil + } + return m.previewMongoDBIntegrations[0] + } + return m.previewMongoDBIntegrations +} + +func (m *mongodbDisplayer) FieldTemplates() []format.Field { + fields := []format.Field{ + { + Name: "Integration Name", + ValueFormat: "{{ .Name }}", + }, + } + + if m.single { + return append(fields, []format.Field{ + { + Name: "API Public Key", + ValueFormat: "{{ .StaticCredentialDetails.APIPublicKey }}", + }, + }...) + } else { + return fields + } +} + +type awsDisplayer struct { + previewAwsIntegrations []*preview_models.Secrets20231128AwsIntegration + + single bool + federatedWorkloadIdentity bool +} + +func newAwsDisplayer(single bool, federatedWorkloadIdentity bool, integrations ...*preview_models.Secrets20231128AwsIntegration) *awsDisplayer { + return &awsDisplayer{ + previewAwsIntegrations: integrations, + single: single, + federatedWorkloadIdentity: federatedWorkloadIdentity, + } +} + +func (a *awsDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (a *awsDisplayer) Payload() any { + + if a.previewAwsIntegrations != nil { + return a.previewAwsIntegrationsPayload() + } + + return nil +} + +func (a *awsDisplayer) previewAwsIntegrationsPayload() any { + if a.single { + if len(a.previewAwsIntegrations) != 1 { + return nil + } + return a.previewAwsIntegrations[0] + } + return a.previewAwsIntegrations +} + +func (a *awsDisplayer) FieldTemplates() []format.Field { + fields := []format.Field{ + { + Name: "Integration Name", + ValueFormat: "{{ .Name }}", + }, + } + if !a.single { + return fields + } + + if a.federatedWorkloadIdentity { + return append(fields, []format.Field{ + { + Name: "Audience", + ValueFormat: "{{ .FederatedWorkloadIdentity.Audience }}", + }, + { + Name: "Role ARN", + ValueFormat: "{{ .FederatedWorkloadIdentity.RoleArn }}", + }, + }...) + + } else { + return append(fields, []format.Field{ + { + Name: "Access Key ID", + ValueFormat: "{{ .AccessKeys.AccessKeyID }}", + }, + }...) + } +} + +type gcpDisplayer struct { + previewGcpIntegrations []*preview_models.Secrets20231128GcpIntegration + + single bool + federatedWorkloadIdentity bool +} + +func newGcpDisplayer(single bool, federatedWorkloadIdentity bool, integrations ...*preview_models.Secrets20231128GcpIntegration) *gcpDisplayer { + return &gcpDisplayer{ + previewGcpIntegrations: integrations, + single: single, + federatedWorkloadIdentity: federatedWorkloadIdentity, + } +} + +func (g *gcpDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (g *gcpDisplayer) Payload() any { + + if g.previewGcpIntegrations != nil { + return g.previewGcpIntegrationsPayload() + } + + return nil +} + +func (g *gcpDisplayer) previewGcpIntegrationsPayload() any { + if g.single { + if len(g.previewGcpIntegrations) != 1 { + return nil + } + return g.previewGcpIntegrations[0] + } + return g.previewGcpIntegrations +} + +func (g *gcpDisplayer) FieldTemplates() []format.Field { + fields := []format.Field{ + { + Name: "Integration Name", + ValueFormat: "{{ .Name }}", + }, + } + if !g.single { + return fields + } + + if g.federatedWorkloadIdentity { + return append(fields, []format.Field{ + { + Name: "Audience", + ValueFormat: "{{ .FederatedWorkloadIdentity.Audience }}", + }, + { + Name: "Service Account Email", + ValueFormat: "{{ .FederatedWorkloadIdentity.ServiceAccountEmail }}", + }, + }...) + } else { + return append(fields, []format.Field{ + { + Name: "Client Email", + ValueFormat: "{{ .ServiceAccountKey.ClientEmail }}", + }, + { + Name: "Project ID", + ValueFormat: "{{ .ServiceAccountKey.ProjectID }}", + }, + }...) + } +} + +type genericDisplayer struct { + integrations []*preview_models.Secrets20231128Integration + + single bool +} + +func newGenericDisplayer(single bool, integrations ...*preview_models.Secrets20231128Integration) *genericDisplayer { + return &genericDisplayer{ + integrations: integrations, + single: single, + } +} + +func (g *genericDisplayer) DefaultFormat() format.Format { + return format.Table +} + +func (g *genericDisplayer) Payload() any { + if g.integrations != nil { + return g.integrationsPayload() + } + + return nil +} + +func (g *genericDisplayer) integrationsPayload() any { + if g.single { + if len(g.integrations) != 1 { + return nil + } + return g.integrations[0] + } + return g.integrations +} + +func (g *genericDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Integration Name", + ValueFormat: "{{ .Name }}", + }, + { + Name: "Provider", + ValueFormat: "{{ .Provider }}", + }, + { + Name: "Created", + ValueFormat: "{{ .CreatedAt }}", + }, + } +} diff --git a/internal/commands/vaultsecrets/integrations/integrations.go b/internal/commands/vaultsecrets/integrations/integrations.go new file mode 100644 index 00000000..b5814e02 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/integrations.go @@ -0,0 +1,39 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/heredoc" +) + +type IntegrationType string + +const ( + Twilio IntegrationType = "twilio" + MongoDBAtlas IntegrationType = "mongodb-atlas" + AWS IntegrationType = "aws" + GCP IntegrationType = "gcp" +) + +func NewCmdIntegrations(ctx *cmd.Context) *cmd.Command { + cmd := &cmd.Command{ + Name: "integrations", + ShortHelp: "Manage Vault Secrets integrations.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations" }} command group lets you + manage Vault Secrets integrations. + `), + PersistentPreRun: func(c *cmd.Command, args []string) error { + return cmd.RequireOrgAndProject(ctx) + }, + } + + cmd.AddChild(NewCmdRead(ctx, nil)) + cmd.AddChild(NewCmdDelete(ctx, nil)) + cmd.AddChild(NewCmdList(ctx, nil)) + cmd.AddChild(NewCmdCreate(ctx, nil)) + cmd.AddChild(NewCmdUpdate(ctx, nil)) + return cmd +} diff --git a/internal/commands/vaultsecrets/integrations/list.go b/internal/commands/vaultsecrets/integrations/list.go new file mode 100644 index 00000000..9faaad6b --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/list.go @@ -0,0 +1,215 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "fmt" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type ListOpts struct { + Ctx context.Context + Profile *profile.Profile + Output *format.Outputter + IO iostreams.IOStreams + + Type IntegrationType + Client secret_service.ClientService + PreviewClient preview_secret_service.ClientService +} + +func NewCmdList(ctx *cmd.Context, runF func(*ListOpts) error) *cmd.Command { + opts := &ListOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + Client: secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "list", + ShortHelp: "List Vault Secrets integrations.", + LongHelp: heredoc.New(ctx.IO).Must(fmt.Sprintf(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations list" }} command lists Vault Secrets generic integrations. + The optional {{ template "mdCodeOrBold" "--type" }} flag may be any of the following: %v + `, IntegrationProviders)), + Examples: []cmd.Example{ + { + Preamble: `List twilio integrations:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations list --type "twilio" + `), + }, + { + Preamble: `List all generic integrations:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations list + `), + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "type", + DisplayValue: "TYPE", + Description: "The optional type of integration to list.", + Value: flagvalue.Simple("", &opts.Type), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + return cmd +} + +func listRun(opts *ListOpts) error { + if opts.Type == "" { + var integrations []*preview_models.Secrets20231128Integration + params := &preview_secret_service.ListIntegrationsParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Capabilities: []string{"ROTATION", "DYNAMIC"}, + } + for { + resp, err := opts.PreviewClient.ListIntegrations(params, nil) + if err != nil { + return fmt.Errorf("failed to list integrations: %w", err) + } + + integrations = append(integrations, resp.Payload.Integrations...) + if resp.Payload.Pagination == nil || resp.Payload.Pagination.NextPageToken == "" { + break + } + + next := resp.Payload.Pagination.NextPageToken + params.PaginationNextPageToken = &next + } + return opts.Output.Display(newGenericDisplayer(false, integrations...)) + + } + + switch opts.Type { + case Twilio: + var integrations []*preview_models.Secrets20231128TwilioIntegration + + params := &preview_secret_service.ListTwilioIntegrationsParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + } + + for { + resp, err := opts.PreviewClient.ListTwilioIntegrations(params, nil) + if err != nil { + return fmt.Errorf("failed to list twilio integrations: %w", err) + } + + integrations = append(integrations, resp.Payload.Integrations...) + if resp.Payload.Pagination == nil || resp.Payload.Pagination.NextPageToken == "" { + break + } + + next := resp.Payload.Pagination.NextPageToken + params.PaginationNextPageToken = &next + } + + return opts.Output.Display(newTwilioDisplayer(false, integrations...)) + + case MongoDBAtlas: + var integrations []*preview_models.Secrets20231128MongoDBAtlasIntegration + + params := &preview_secret_service.ListMongoDBAtlasIntegrationsParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + } + + for { + resp, err := opts.PreviewClient.ListMongoDBAtlasIntegrations(params, nil) + if err != nil { + return fmt.Errorf("failed to list mongo integrations: %w", err) + } + + integrations = append(integrations, resp.Payload.Integrations...) + if resp.Payload.Pagination == nil || resp.Payload.Pagination.NextPageToken == "" { + break + } + + next := resp.Payload.Pagination.NextPageToken + params.PaginationNextPageToken = &next + } + return opts.Output.Display(newMongoDBDisplayer(false, integrations...)) + + case AWS: + var integrations []*preview_models.Secrets20231128AwsIntegration + + params := &preview_secret_service.ListAwsIntegrationsParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + } + + for { + resp, err := opts.PreviewClient.ListAwsIntegrations(params, nil) + if err != nil { + return fmt.Errorf("failed to list AWS integrations: %w", err) + } + + integrations = append(integrations, resp.Payload.Integrations...) + if resp.Payload.Pagination == nil || resp.Payload.Pagination.NextPageToken == "" { + break + } + + next := resp.Payload.Pagination.NextPageToken + params.PaginationNextPageToken = &next + } + return opts.Output.Display(newAwsDisplayer(false, false, integrations...)) + + case GCP: + var integrations []*preview_models.Secrets20231128GcpIntegration + + params := &preview_secret_service.ListGcpIntegrationsParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + } + + for { + resp, err := opts.PreviewClient.ListGcpIntegrations(params, nil) + if err != nil { + return fmt.Errorf("failed to list GCP integrations: %w", err) + } + + integrations = append(integrations, resp.Payload.Integrations...) + if resp.Payload.Pagination == nil || resp.Payload.Pagination.NextPageToken == "" { + break + } + + next := resp.Payload.Pagination.NextPageToken + params.PaginationNextPageToken = &next + } + return opts.Output.Display(newGcpDisplayer(false, false, integrations...)) + + default: + return fmt.Errorf("not a valid integration type") + } +} diff --git a/internal/commands/vaultsecrets/integrations/list_test.go b/internal/commands/vaultsecrets/integrations/list_test.go new file mode 100644 index 00000000..16c082b3 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/list_test.go @@ -0,0 +1,170 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func TestNewCmdList(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *ListOpts + }{ + { + Name: "Good", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("abc") + }, + Args: []string{"--type=twilio"}, + Expect: &ListOpts{ + Type: "twilio", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *ListOpts + listCmd := NewCmdList(ctx, func(o *ListOpts) error { + gotOpts = o + return nil + }) + listCmd.SetIO(io) + + code := listCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + }) + } +} + +func TestListRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + RespErr bool + ErrMsg string + }{ + { + Name: "Success: List integrations", + }, + { + Name: "Failed: Unable to list integrations", + RespErr: true, + ErrMsg: "[GET /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/integrations][404] ListIntegrations", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + io.ErrorTTY = true + vs := mock_preview_secret_service.NewMockClientService(t) + + opts := &ListOpts{ + Ctx: context.Background(), + IO: io, + Profile: profile.TestProfile(t).SetOrgID("123").SetProjectID("abc"), + Output: format.New(io), + PreviewClient: vs, + Type: "twilio", + } + + if c.RespErr { + vs.EXPECT().ListTwilioIntegrations(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + paginationNextPageToken := "token" + vs.EXPECT().ListTwilioIntegrations(&preview_secret_service.ListTwilioIntegrationsParams{ + OrganizationID: "123", + ProjectID: "abc", + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.ListTwilioIntegrationsOK{ + Payload: &preview_models.Secrets20231128ListTwilioIntegrationsResponse{ + Integrations: getIntegrations(0, 10), + Pagination: &preview_models.CommonPaginationResponse{ + NextPageToken: paginationNextPageToken, + }, + }, + }, nil).Once() + + vs.EXPECT().ListTwilioIntegrations(&preview_secret_service.ListTwilioIntegrationsParams{ + OrganizationID: "123", + ProjectID: "abc", + Context: opts.Ctx, + PaginationNextPageToken: &paginationNextPageToken, + }, mock.Anything).Return(&preview_secret_service.ListTwilioIntegrationsOK{ + Payload: &preview_models.Secrets20231128ListTwilioIntegrationsResponse{ + Integrations: getIntegrations(10, 5), + }, + }, nil).Once() + } + + // Run the command + err := listRun(opts) + if c.ErrMsg != "" { + r.Contains(err.Error(), c.ErrMsg) + return + } + + r.NoError(err) + r.NotNil(io.Error.String()) + }) + } +} + +func getIntegrations(start, limit int) []*preview_models.Secrets20231128TwilioIntegration { + var secrets []*preview_models.Secrets20231128TwilioIntegration + for i := start; i < (start + limit); i++ { + secrets = append(secrets, &preview_models.Secrets20231128TwilioIntegration{ + Name: fmt.Sprint("test_app_", i), + }) + } + return secrets +} diff --git a/internal/commands/vaultsecrets/integrations/read.go b/internal/commands/vaultsecrets/integrations/read.go new file mode 100644 index 00000000..7f94ed20 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/read.go @@ -0,0 +1,157 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "fmt" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type ReadOpts struct { + Ctx context.Context + Profile *profile.Profile + Output *format.Outputter + IO iostreams.IOStreams + + IntegrationName string + Type IntegrationType + Client secret_service.ClientService + PreviewClient preview_secret_service.ClientService +} + +func NewCmdRead(ctx *cmd.Context, runF func(*ReadOpts) error) *cmd.Command { + opts := &ReadOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + Output: ctx.Output, + IO: ctx.IO, + Client: secret_service.New(ctx.HCP, nil), + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "read", + ShortHelp: "Read a Vault Secrets integration.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations read" }} command gets a Vault Secrets integration. + `), + Examples: []cmd.Example{ + { + Preamble: `Read an integration:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations read sample-integration --type twilio + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the integration to read.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "type", + DisplayValue: "TYPE", + Description: "The type of the integration to read.", + Value: flagvalue.Simple("", &opts.Type), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.IntegrationName = args[0] + + if runF != nil { + return runF(opts) + } + return readRun(opts) + }, + } + + return cmd +} + +func readRun(opts *ReadOpts) error { + switch opts.Type { + case "": + resp, err := opts.PreviewClient.GetIntegration(&preview_secret_service.GetIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to read integration: %w", err) + } + + return opts.Output.Display(newGenericDisplayer(true, resp.Payload.Integration)) + + case Twilio: + resp, err := opts.PreviewClient.GetTwilioIntegration(&preview_secret_service.GetTwilioIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to read integration: %w", err) + } + + return opts.Output.Display(newTwilioDisplayer(true, resp.Payload.Integration)) + + case MongoDBAtlas: + resp, err := opts.PreviewClient.GetMongoDBAtlasIntegration(&preview_secret_service.GetMongoDBAtlasIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to read integration: %w", err) + } + + return opts.Output.Display(newMongoDBDisplayer(true, resp.Payload.Integration)) + + case AWS: + resp, err := opts.PreviewClient.GetAwsIntegration(&preview_secret_service.GetAwsIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to read integration: %w", err) + } + + return opts.Output.Display(newAwsDisplayer(true, resp.Payload.Integration.FederatedWorkloadIdentity != nil, resp.Payload.Integration)) + + case GCP: + resp, err := opts.PreviewClient.GetGcpIntegration(&preview_secret_service.GetGcpIntegrationParams{ + Context: opts.Ctx, + ProjectID: opts.Profile.ProjectID, + OrganizationID: opts.Profile.OrganizationID, + Name: opts.IntegrationName, + }, nil) + if err != nil { + return fmt.Errorf("failed to read integration: %w", err) + } + + return opts.Output.Display(newGcpDisplayer(true, resp.Payload.Integration.FederatedWorkloadIdentity != nil, resp.Payload.Integration)) + + default: + return fmt.Errorf("not a valid integration type") + } +} diff --git a/internal/commands/vaultsecrets/integrations/read_test.go b/internal/commands/vaultsecrets/integrations/read_test.go new file mode 100644 index 00000000..10cb9aed --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/read_test.go @@ -0,0 +1,158 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" + "github.com/stretchr/testify/mock" +) + +func TestNewCmdRead(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *ReadOpts + }{ + { + Name: "Good", + Profile: func(t *testing.T) *profile.Profile { + return profile.TestProfile(t).SetOrgID("123").SetProjectID("abc") + }, + Args: []string{"sample-integration", "--type", "twilio"}, + Expect: &ReadOpts{ + IntegrationName: "sample-integration", + Type: "twilio", + }, + }, + } + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + r := require.New(t) + io := iostreams.Test() + + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + ShutdownCtx: context.Background(), + HCP: &client.Runtime{}, + Output: format.New(io), + } + + var readOpts *ReadOpts + readCmd := NewCmdRead(ctx, func(o *ReadOpts) error { + readOpts = o + return nil + }) + readCmd.SetIO(io) + + code := readCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(readOpts) + r.Equal(c.Expect.IntegrationName, readOpts.IntegrationName) + r.Equal(c.Expect.Type, readOpts.Type) + + }) + } +} + +func TestReadRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + ErrMsg string + IntegrationName string + Type IntegrationType + }{ + { + Name: "Failed: Integration not found", + ErrMsg: "[GET /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/integrations/twilio/config/{integration_name}][404] GetTwilioIntegration", + Type: Twilio, + }, + { + Name: "Success: Read integration", + IntegrationName: "sample-integration", + Type: Twilio, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + vs := mock_preview_secret_service.NewMockClientService(t) + + opts := &ReadOpts{ + Ctx: context.Background(), + Profile: profile.TestProfile(t).SetOrgID("123").SetProjectID("abc"), + IO: io, + PreviewClient: vs, + Output: format.New(io), + IntegrationName: c.IntegrationName, + Type: c.Type, + } + + if c.ErrMsg != "" { + vs.EXPECT().GetTwilioIntegration(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + vs.EXPECT().GetTwilioIntegration(&preview_secret_service.GetTwilioIntegrationParams{ + OrganizationID: "123", + ProjectID: "abc", + Name: opts.IntegrationName, + Context: opts.Ctx, + }, nil).Return(&preview_secret_service.GetTwilioIntegrationOK{ + Payload: &preview_models.Secrets20231128GetTwilioIntegrationResponse{ + Integration: &preview_models.Secrets20231128TwilioIntegration{ + Name: opts.IntegrationName, + StaticCredentialDetails: &preview_models.Secrets20231128TwilioStaticCredentialsResponse{ + AccountSid: "account_sid", + APIKeySid: "api_key_sid", + }, + }, + }, + }, nil).Once() + } + + // Run the command + err := readRun(opts) + if c.ErrMsg != "" { + r.Contains(err.Error(), c.ErrMsg) + return + } + + r.NoError(err) + r.Contains(io.Output.String(), fmt.Sprintf("Integration Name Account SID API Key SID\n%s account_sid api_key_sid\n", opts.IntegrationName)) + }) + } +} diff --git a/internal/commands/vaultsecrets/integrations/update.go b/internal/commands/vaultsecrets/integrations/update.go new file mode 100644 index 00000000..ac799413 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/update.go @@ -0,0 +1,213 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/hcl/v2/hclsimple" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type UpdateOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + IntegrationName string + ConfigFilePath string + PreviewClient preview_secret_service.ClientService + Client secret_service.ClientService +} + +func NewCmdUpdate(ctx *cmd.Context, runF func(*UpdateOpts) error) *cmd.Command { + opts := &UpdateOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + Client: secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "update", + ShortHelp: "Update an integration.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets integrations update" }} command updates a Vault Secrets integration. + The configuration for updating your integration will be read from the provided HCL config file. The following fields are + required: [type details]. For help populating the details for an integration type, please refer to the + {{ Link "API reference documentation" "https://developer.hashicorp.com/hcp/api-docs/vault-secrets/2023-11-28" }}. + `), + Examples: []cmd.Example{ + { + Preamble: `Update a Vault Secrets integration:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets integrations update sample-integration --config-file=path-to-file/config.hcl + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the integration to update.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "config-file", + DisplayValue: "CONFIG_FILE", + Description: "File path to read integration config data.", + Value: flagvalue.Simple("", &opts.ConfigFilePath), + Required: true, + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.IntegrationName = args[0] + + if runF != nil { + return runF(opts) + } + return updateRun(opts) + }, + } + + return cmd +} + +func updateRun(opts *UpdateOpts) error { + var ( + config IntegrationConfig + internalConfig integrationConfigInternal + ) + + if err := hclsimple.DecodeFile(opts.ConfigFilePath, nil, &config); err != nil { + return fmt.Errorf("failed to decode config file: %w", err) + } + + detailsMap, err := CtyValueToMap(config.Details) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + internalConfig.Details = detailsMap + + switch config.Type { + case Twilio: + req := preview_secret_service.NewUpdateTwilioIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.Name = opts.IntegrationName + + var twilioBody preview_models.SecretServiceUpdateTwilioIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = twilioBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &twilioBody + + _, err = opts.PreviewClient.UpdateTwilioIntegration(req, nil) + if err != nil { + return fmt.Errorf("failed to update Twilio integration: %w", err) + } + + case MongoDBAtlas: + req := preview_secret_service.NewUpdateMongoDBAtlasIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.Name = opts.IntegrationName + + var mongoDBBody preview_models.SecretServiceUpdateMongoDBAtlasIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = mongoDBBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &mongoDBBody + + _, err = opts.PreviewClient.UpdateMongoDBAtlasIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to update MongoDB Atlas integration: %w", err) + } + + case AWS: + req := preview_secret_service.NewUpdateAwsIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.Name = opts.IntegrationName + + var awsBody preview_models.SecretServiceUpdateAwsIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &awsBody + + _, err = opts.PreviewClient.UpdateAwsIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to update AWS integration: %w", err) + } + + case GCP: + req := preview_secret_service.NewUpdateGcpIntegrationParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.Name = opts.IntegrationName + + var gcpBody preview_models.SecretServiceUpdateGcpIntegrationBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + req.Body = &gcpBody + + _, err = opts.PreviewClient.UpdateGcpIntegration(req, nil) + + if err != nil { + return fmt.Errorf("failed to update GCP integration: %w", err) + } + } + + fmt.Fprintln(opts.IO.Err()) + fmt.Fprintf(opts.IO.Err(), "%s Successfully updated integration with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.IntegrationName) + + return nil +} diff --git a/internal/commands/vaultsecrets/integrations/update_test.go b/internal/commands/vaultsecrets/integrations/update_test.go new file mode 100644 index 00000000..94e205d8 --- /dev/null +++ b/internal/commands/vaultsecrets/integrations/update_test.go @@ -0,0 +1,197 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package integrations + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func TestNewCmdUpdate(t *testing.T) { + t.Parallel() + + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + return tp + } + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *UpdateOpts + }{ + { + Name: "Good", + Profile: testProfile, + Args: []string{"sample-integration", "--config-file", "path/to/file"}, + Expect: &UpdateOpts{ + IntegrationName: "sample-integration", + ConfigFilePath: "path/to/file", + }, + }, + { + Name: "Failed: No secret name arg specified", + Profile: testProfile, + Args: []string{"--config-file", "path/to/file"}, + Error: "ERROR: accepts 1 arg(s), received 0", + }, + { + Name: "Failed: No secret name arg specified", + Profile: testProfile, + Args: []string{"sample-integration"}, + Error: "ERROR: missing required flag: --config-file=CONFIG_FILE", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *UpdateOpts + createCmd := NewCmdUpdate(ctx, func(o *UpdateOpts) error { + gotOpts = o + return nil + }) + createCmd.SetIO(io) + + code := createCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.IntegrationName, gotOpts.IntegrationName) + r.Equal(c.Expect.ConfigFilePath, gotOpts.ConfigFilePath) + }) + } +} + +func TestUpdateRun(t *testing.T) { + t.Parallel() + + cases := []struct { + Name string + IntegrationName string + Input []byte + Error string + }{ + { + Name: "Good", + IntegrationName: "sample-integration", + Input: []byte(`type = "aws" +details = { + "federated_workload_identity" = { + "audience" = "abc", + "role_arn" = "def" + } + + "capabilities" = [ + "ROTATION", "DYNAMIC" + ] +}`), + }, + } + + for _, c := range cases { + + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + tempDir := t.TempDir() + f, err := os.Create(filepath.Join(tempDir, "config.hcl")) + r.NoError(err) + _, err = f.Write(c.Input) + r.NoError(err) + + io := iostreams.Test() + vs := mock_preview_secret_service.NewMockClientService(t) + + opts := &UpdateOpts{ + Ctx: context.Background(), + Profile: profile.TestProfile(t).SetOrgID("123").SetProjectID("abc"), + IO: io, + PreviewClient: vs, + Output: format.New(io), + IntegrationName: c.IntegrationName, + ConfigFilePath: f.Name(), + } + + if c.Error == "" { + vs.EXPECT().UpdateAwsIntegration(&preview_secret_service.UpdateAwsIntegrationParams{ + Context: opts.Ctx, + OrganizationID: "123", + ProjectID: "abc", + Name: opts.IntegrationName, + Body: &preview_models.SecretServiceUpdateAwsIntegrationBody{ + FederatedWorkloadIdentity: &preview_models.Secrets20231128AwsFederatedWorkloadIdentityRequest{ + Audience: "abc", + RoleArn: "def", + }, + Capabilities: []*preview_models.Secrets20231128Capability{ + preview_models.Secrets20231128CapabilityROTATION.Pointer(), + preview_models.Secrets20231128CapabilityDYNAMIC.Pointer(), + }, + }, + }, nil).Return(&preview_secret_service.UpdateAwsIntegrationOK{ + Payload: &preview_models.Secrets20231128UpdateAwsIntegrationResponse{ + Integration: &preview_models.Secrets20231128AwsIntegration{ + Name: opts.IntegrationName, + FederatedWorkloadIdentity: &preview_models.Secrets20231128AwsFederatedWorkloadIdentityResponse{ + Audience: "abc", + RoleArn: "def", + }, + Capabilities: []*preview_models.Secrets20231128Capability{ + preview_models.Secrets20231128CapabilityROTATION.Pointer(), + preview_models.Secrets20231128CapabilityDYNAMIC.Pointer(), + }, + }, + }, + }, nil).Once() + } + + // Run the command + err = updateRun(opts) + if c.Error != "" { + r.ErrorContains(err, c.Error) + return + } + + r.NoError(err) + r.Contains(io.Error.String(), fmt.Sprintf("✓ Successfully updated integration with name %q\n", opts.IntegrationName)) + }) + } +} diff --git a/internal/commands/vaultsecrets/secrets/create.go b/internal/commands/vaultsecrets/secrets/create.go index ea38f1f4..14b3eacf 100644 --- a/internal/commands/vaultsecrets/secrets/create.go +++ b/internal/commands/vaultsecrets/secrets/create.go @@ -5,13 +5,20 @@ package secrets import ( "context" + "encoding/json" "errors" "fmt" "io" "os" + "github.com/posener/complete" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/hcl/v2/hclsimple" preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/integrations" "github.com/hashicorp/hcp/internal/commands/vaultsecrets/secrets/appname" "github.com/hashicorp/hcp/internal/pkg/cmd" "github.com/hashicorp/hcp/internal/pkg/flagvalue" @@ -19,7 +26,6 @@ import ( "github.com/hashicorp/hcp/internal/pkg/heredoc" "github.com/hashicorp/hcp/internal/pkg/iostreams" "github.com/hashicorp/hcp/internal/pkg/profile" - "github.com/posener/complete" ) func NewCmdCreate(ctx *cmd.Context, runF func(*CreateOpts) error) *cmd.Command { @@ -36,11 +42,14 @@ func NewCmdCreate(ctx *cmd.Context, runF func(*CreateOpts) error) *cmd.Command { Name: "create", ShortHelp: "Create a new static secret.", LongHelp: heredoc.New(ctx.IO).Must(` - The {{ template "mdCodeOrBold" "hcp vault-secrets secrets create" }} command creates a new static secret under a Vault Secrets application. + The {{ template "mdCodeOrBold" "hcp vault-secrets secrets create" }} command creates a new static, rotating, or dynamic secret under a Vault Secrets application. + The configuration for creating your rotating or dynamic secret will be read from the provided HCL config file. The following fields are required in the config + file: [type integration_name details]. For help populating the details for a dynamic or rotating secret, please refer to the + {{ Link "API reference documentation" "https://developer.hashicorp.com/hcp/api-docs/vault-secrets/2023-11-28" }}. `), Examples: []cmd.Example{ { - Preamble: `Create a new secret in the Vault Secrets application on your active profile:`, + Preamble: `Create a new static secret in the Vault Secrets application on your active profile:`, Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` $ hcp vault-secrets secrets create secret_1 --data-file=tmp/secrets1.txt `), @@ -51,6 +60,12 @@ func NewCmdCreate(ctx *cmd.Context, runF func(*CreateOpts) error) *cmd.Command { $ echo -n "my super secret" | hcp vault-secrets secrets create secret_2 --data-file=- `), }, + { + Preamble: `Create a new rotating secret in the Vault Secrets application on your active profile:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets secrets create secret_1 --secret-type=rotating --data-file=path/to/file/config.hcl + `), + }, }, Args: cmd.PositionalArguments{ Args: []cmd.PositionalArgument{ @@ -67,11 +82,18 @@ func NewCmdCreate(ctx *cmd.Context, runF func(*CreateOpts) error) *cmd.Command { DisplayValue: "DATA_FILE_PATH", Description: "File path to read secret data from. Set this to '-' to read the secret data from stdin.", Value: flagvalue.Simple("", &opts.SecretFilePath), + Required: true, Autocomplete: complete.PredictOr( complete.PredictFiles("*"), complete.PredictSet("-"), ), }, + { + Name: "secret-type", + DisplayValue: "SECRET_TYPE", + Description: "The type of secret to create: static, rotating, or dynamic.", + Value: flagvalue.Simple("", &opts.Type), + }, }, }, RunF: func(c *cmd.Command, args []string) error { @@ -98,35 +120,249 @@ type CreateOpts struct { SecretName string SecretValuePlaintext string SecretFilePath string + Type string PreviewClient preview_secret_service.ClientService Client secret_service.ClientService } +type secretConfigInternal struct { + Details map[string]any +} + +type SecretConfig struct { + Type integrations.IntegrationType `hcl:"type"` + IntegrationName string `hcl:"integration_name"` + Details cty.Value `hcl:"details"` +} + func createRun(opts *CreateOpts) error { - if err := readPlainTextSecret(opts); err != nil { - return err - } + switch opts.Type { + case secretTypeKV, "": + if err := readPlainTextSecret(opts); err != nil { + return err + } - req := secret_service.NewCreateAppKVSecretParamsWithContext(opts.Ctx) - req.LocationOrganizationID = opts.Profile.OrganizationID - req.LocationProjectID = opts.Profile.ProjectID - req.AppName = opts.AppName + req := secret_service.NewCreateAppKVSecretParamsWithContext(opts.Ctx) + req.LocationOrganizationID = opts.Profile.OrganizationID + req.LocationProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName - req.Body = secret_service.CreateAppKVSecretBody{ - Name: opts.SecretName, - Value: opts.SecretValuePlaintext, - } + req.Body = secret_service.CreateAppKVSecretBody{ + Name: opts.SecretName, + Value: opts.SecretValuePlaintext, + } - resp, err := opts.Client.CreateAppKVSecret(req, nil) - if err != nil { - return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) - } + resp, err := opts.Client.CreateAppKVSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } - if err := opts.Output.Display(newDisplayer().Secrets(resp.Payload.Secret)); err != nil { - return err + if err := opts.Output.Display(newDisplayer().Secrets(resp.Payload.Secret)); err != nil { + return err + } + case secretTypeRotating: + secretConfig, internalConfig, err := readConfigFile(opts.SecretFilePath) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + + missingFields := validateSecretConfig(secretConfig) + + if len(missingFields) > 0 { + return fmt.Errorf("missing required field(s) in the config file: %s", missingFields) + } + + switch secretConfig.Type { + case integrations.Twilio: + req := preview_secret_service.NewCreateTwilioRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var twilioBody preview_models.SecretServiceCreateTwilioRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = twilioBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + twilioBody.IntegrationName = secretConfig.IntegrationName + twilioBody.SecretName = opts.SecretName + req.Body = &twilioBody + + resp, err := opts.PreviewClient.CreateTwilioRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + if err := opts.Output.Display(newRotatingSecretsDisplayer(true).PreviewRotatingSecrets(resp.Payload.Config)); err != nil { + return err + } + + case integrations.MongoDBAtlas: + + req := preview_secret_service.NewCreateMongoDBAtlasRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var mongoDBBody preview_models.SecretServiceCreateMongoDBAtlasRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = mongoDBBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + mongoDBBody.IntegrationName = secretConfig.IntegrationName + mongoDBBody.SecretName = opts.SecretName + req.Body = &mongoDBBody + + resp, err := opts.PreviewClient.CreateMongoDBAtlasRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + if err := opts.Output.Display(newRotatingSecretsDisplayer(true).PreviewRotatingSecrets(resp.Payload.Config)); err != nil { + return err + } + + case integrations.AWS: + req := preview_secret_service.NewCreateAwsIAMUserAccessKeyRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var awsBody preview_models.SecretServiceCreateAwsIAMUserAccessKeyRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + awsBody.IntegrationName = secretConfig.IntegrationName + awsBody.Name = opts.SecretName + req.Body = &awsBody + + _, err = opts.PreviewClient.CreateAwsIAMUserAccessKeyRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + case integrations.GCP: + req := preview_secret_service.NewCreateGcpServiceAccountKeyRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var gcpBody preview_models.SecretServiceCreateGcpServiceAccountKeyRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + gcpBody.IntegrationName = secretConfig.IntegrationName + gcpBody.Name = opts.SecretName + req.Body = &gcpBody + + _, err = opts.PreviewClient.CreateGcpServiceAccountKeyRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + default: + return fmt.Errorf("unsupported rotating secret provider type") + } + + case secretTypeDynamic: + secretConfig, internalConfig, err := readConfigFile(opts.SecretFilePath) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + missingFields := validateSecretConfig(secretConfig) + + if len(missingFields) > 0 { + return fmt.Errorf("missing required field(s) in the config file: %s", missingFields) + } + + switch secretConfig.Type { + case integrations.AWS: + req := preview_secret_service.NewCreateAwsDynamicSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var awsBody preview_models.SecretServiceCreateAwsDynamicSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + awsBody.IntegrationName = secretConfig.IntegrationName + awsBody.Name = opts.SecretName + req.Body = &awsBody + + _, err = opts.PreviewClient.CreateAwsDynamicSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + case integrations.GCP: + req := preview_secret_service.NewCreateGcpDynamicSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + + var gcpBody preview_models.SecretServiceCreateGcpDynamicSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + gcpBody.IntegrationName = secretConfig.IntegrationName + gcpBody.Name = opts.SecretName + req.Body = &gcpBody + + _, err = opts.PreviewClient.CreateGcpDynamicSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to create secret with name %q: %w", opts.SecretName, err) + } + + default: + return fmt.Errorf("unsupported dynamic secret provider type") + } + + default: + return fmt.Errorf("%q is an unsupported secret type; \"static\", \"rotating\", \"dynamic\" are available types", opts.Type) } - command := fmt.Sprintf(`$ hcp vault-secrets secrets read %s --app %s`, opts.SecretName, req.AppName) + command := fmt.Sprintf(`$ hcp vault-secrets secrets read %s --app %s`, opts.SecretName, opts.AppName) fmt.Fprintln(opts.IO.Err()) fmt.Fprintf(opts.IO.Err(), "%s Successfully created secret with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.SecretName) fmt.Fprintln(opts.IO.Err()) @@ -176,3 +412,36 @@ func readPlainTextSecret(opts *CreateOpts) error { opts.SecretValuePlaintext = string(data) return nil } + +func readConfigFile(filePath string) (SecretConfig, secretConfigInternal, error) { + var ( + secretConfig SecretConfig + internalConfig secretConfigInternal + ) + + if err := hclsimple.DecodeFile(filePath, nil, &secretConfig); err != nil { + return secretConfig, internalConfig, fmt.Errorf("failed to decode config file: %w", err) + } + + detailsMap, err := integrations.CtyValueToMap(secretConfig.Details) + if err != nil { + return secretConfig, internalConfig, err + } + internalConfig.Details = detailsMap + + return secretConfig, internalConfig, nil +} + +func validateSecretConfig(secretConfig SecretConfig) []string { + var missingKeys []string + + if secretConfig.Type == "" { + missingKeys = append(missingKeys, "type") + } + + if secretConfig.IntegrationName == "" { + missingKeys = append(missingKeys, "integration_name") + } + + return missingKeys +} diff --git a/internal/commands/vaultsecrets/secrets/create_test.go b/internal/commands/vaultsecrets/secrets/create_test.go index 59599c94..34556fcc 100644 --- a/internal/commands/vaultsecrets/secrets/create_test.go +++ b/internal/commands/vaultsecrets/secrets/create_test.go @@ -7,20 +7,25 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "testing" "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" - models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/models" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" mock_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" - "github.com/hashicorp/hcp/internal/pkg/cmd" "github.com/hashicorp/hcp/internal/pkg/format" "github.com/hashicorp/hcp/internal/pkg/iostreams" "github.com/hashicorp/hcp/internal/pkg/profile" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func TestNewCmdCreate(t *testing.T) { @@ -45,12 +50,30 @@ func TestNewCmdCreate(t *testing.T) { Name: "Failed: No secret name arg specified", Profile: testProfile, Args: []string{}, - Error: "ERROR: accepts 1 arg(s), received 0", + Error: "ERROR: missing required flag: --data-file=DATA_FILE_PATH", }, { Name: "Good: Secret name arg specified", Profile: testProfile, - Args: []string{"test"}, + Args: []string{"test", "--data-file=DATA_FILE_PATH"}, + Expect: &CreateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test", + }, + }, + { + Name: "Good: Rotating secret", + Profile: testProfile, + Args: []string{"test", "--secret-type=rotating", "--data-file=DATA_FILE_PATH"}, + Expect: &CreateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test", + }, + }, + { + Name: "Good: Dynamic secret", + Profile: testProfile, + Args: []string{"test", "--secret-type=dynamic", "--data-file=DATA_FILE_PATH"}, Expect: &CreateOpts{ AppName: testProfile(t).VaultSecrets.AppName, SecretName: "test", @@ -117,9 +140,10 @@ func TestCreateRun(t *testing.T) { ErrMsg string MockCalled bool AugmentOpts func(*CreateOpts) + Input []byte }{ { - Name: "Failed: Read via stdin as hypen not supplied for --data-file flag", + Name: "Failed: Read via stdin as hyphen not supplied for --data-file flag", ErrMsg: "data file path is required", }, { @@ -127,27 +151,93 @@ func TestCreateRun(t *testing.T) { EmptySecretValue: true, ReadViaStdin: true, RespErr: true, - AugmentOpts: func(o *CreateOpts) { o.SecretFilePath = "-" }, - ErrMsg: "secret value cannot be empty", + AugmentOpts: func(o *CreateOpts) { + o.SecretFilePath = "-" + o.Type = secretTypeKV + }, + ErrMsg: "secret value cannot be empty", }, { Name: "Success: Create secret via stdin", ReadViaStdin: true, - AugmentOpts: func(o *CreateOpts) { o.SecretFilePath = "-" }, - MockCalled: true, + AugmentOpts: func(o *CreateOpts) { + o.SecretFilePath = "-" + o.Type = secretTypeKV + }, + MockCalled: true, }, { - Name: "Failed: Max secret versions reached", - RespErr: true, - ErrMsg: "[POST /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/apps/{app_name}/secret/kv][429] CreateAppKVSecret default &{Code:8 Details:[] Message:maximum number of secret versions reached}", - AugmentOpts: func(o *CreateOpts) { o.SecretValuePlaintext = testSecretValue }, - MockCalled: true, + Name: "Failed: Max secret versions reached", + RespErr: true, + ErrMsg: "[POST /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/apps/{app_name}/secret/kv][429] CreateAppKVSecret default &{Code:8 Details:[] Message:maximum number of secret versions reached}", + AugmentOpts: func(o *CreateOpts) { + o.SecretValuePlaintext = testSecretValue + o.Type = secretTypeKV + }, + MockCalled: true, }, { - Name: "Success: Created secret", - RespErr: false, - AugmentOpts: func(o *CreateOpts) { o.SecretValuePlaintext = testSecretValue }, - MockCalled: true, + Name: "Success: Created secret", + RespErr: false, + AugmentOpts: func(o *CreateOpts) { + o.SecretValuePlaintext = testSecretValue + o.Type = secretTypeKV + }, + MockCalled: true, + }, + { + Name: "Success: Create a MongoDB rotating secret", + RespErr: false, + AugmentOpts: func(o *CreateOpts) { + o.Type = secretTypeRotating + }, + MockCalled: true, + Input: []byte(`type = "mongodb-atlas" +integration_name = "mongo-db-integration" +details = { + rotation_policy_name = "built-in:60-days-2-active" + secret_details = { + mongodb_group_id = "mbdgi" + mongodb_roles = [{ + "role_name" = "rn1" + "database_name" = "dn1" + "collection_name" = "cn1" + }, + { + "role_name" = "rn2" + "database_name" = "dn2" + "collection_name" = "cn2" + }] + } +}`), + }, + { + Name: "Success: Create an Aws dynamic secret", + RespErr: false, + AugmentOpts: func(o *CreateOpts) { + o.Type = secretTypeDynamic + }, + MockCalled: true, + Input: []byte(`type = "aws" + +integration_name = "Aws-Int-12" + +details = { + "default_ttl" = "3600s" + + "assume_role" = { + "role_arn" = "ra" + } +}`), + }, + { + Name: "Failed: Unsupported secret type", + RespErr: true, + AugmentOpts: func(o *CreateOpts) { + o.Type = "random" + }, + Input: []byte{}, + ErrMsg: "\"random\" is an unsupported secret type; \"static\", \"rotating\", \"dynamic\" are available types", }, } @@ -168,48 +258,138 @@ func TestCreateRun(t *testing.T) { } } vs := mock_secret_service.NewMockClientService(t) + pvs := mock_preview_secret_service.NewMockClientService(t) + opts := &CreateOpts{ - Ctx: context.Background(), - IO: io, - Profile: testProfile(t), - Output: format.New(io), - Client: vs, - AppName: testProfile(t).VaultSecrets.AppName, - SecretName: "test_secret", + Ctx: context.Background(), + IO: io, + Profile: testProfile(t), + Output: format.New(io), + Client: vs, + PreviewClient: pvs, + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test_secret", } if c.AugmentOpts != nil { c.AugmentOpts(opts) } + if opts.Type == secretTypeRotating || opts.Type == secretTypeDynamic { + tempDir := t.TempDir() + f, err := os.Create(filepath.Join(tempDir, "config.hcl")) + r.NoError(err) + _, err = f.Write(c.Input) + r.NoError(err) + opts.SecretFilePath = f.Name() + } + dt := strfmt.NewDateTime() - if c.MockCalled { - if c.RespErr { - vs.EXPECT().CreateAppKVSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() - } else { - vs.EXPECT().CreateAppKVSecret(&secret_service.CreateAppKVSecretParams{ - LocationOrganizationID: testProfile(t).OrganizationID, - LocationProjectID: testProfile(t).ProjectID, - AppName: testProfile(t).VaultSecrets.AppName, - Body: secret_service.CreateAppKVSecretBody{ - Name: opts.SecretName, - Value: testSecretValue, - }, - Context: opts.Ctx, - }, mock.Anything).Return(&secret_service.CreateAppKVSecretOK{ - Payload: &models.Secrets20230613CreateAppKVSecretResponse{ - Secret: &models.Secrets20230613Secret{ - Name: opts.SecretName, - CreatedAt: dt, - Version: &models.Secrets20230613SecretVersion{ - Version: "2", + if opts.Type == secretTypeKV { + if c.MockCalled { + if c.RespErr { + vs.EXPECT().CreateAppKVSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + vs.EXPECT().CreateAppKVSecret(&secret_service.CreateAppKVSecretParams{ + LocationOrganizationID: testProfile(t).OrganizationID, + LocationProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + Body: secret_service.CreateAppKVSecretBody{ + Name: opts.SecretName, + Value: testSecretValue, + }, + Context: opts.Ctx, + }, mock.Anything).Return(&secret_service.CreateAppKVSecretOK{ + Payload: &models.Secrets20230613CreateAppKVSecretResponse{ + Secret: &models.Secrets20230613Secret{ + Name: opts.SecretName, CreatedAt: dt, - Type: "kv", + Version: &models.Secrets20230613SecretVersion{ + Version: "2", + CreatedAt: dt, + Type: "kv", + }, + LatestVersion: "2", + }, + }, + }, nil).Once() + } + } + } else if opts.Type == secretTypeRotating { + if c.MockCalled { + if c.RespErr { + pvs.EXPECT().CreateMongoDBAtlasRotatingSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + pvs.EXPECT().CreateMongoDBAtlasRotatingSecret(&preview_secret_service.CreateMongoDBAtlasRotatingSecretParams{ + OrganizationID: testProfile(t).OrganizationID, + ProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + Body: &preview_models.SecretServiceCreateMongoDBAtlasRotatingSecretBody{ + SecretName: opts.SecretName, + IntegrationName: "mongo-db-integration", + RotationPolicyName: "built-in:60-days-2-active", + SecretDetails: &preview_models.Secrets20231128MongoDBAtlasSecretDetails{ + MongodbGroupID: "mbdgi", + MongodbRoles: []*preview_models.Secrets20231128MongoDBRole{ + { + RoleName: "rn1", + DatabaseName: "dn1", + CollectionName: "cn1", + }, + { + RoleName: "rn2", + DatabaseName: "dn2", + CollectionName: "cn2", + }, + }, + }, + }, + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.CreateMongoDBAtlasRotatingSecretOK{ + Payload: &preview_models.Secrets20231128CreateMongoDBAtlasRotatingSecretResponse{ + Config: &preview_models.Secrets20231128RotatingSecretConfig{ + AppName: opts.AppName, + CreatedAt: dt, + IntegrationName: "mongo-db-integration", + RotationPolicyName: "built-in:60-days-2-active", + SecretName: opts.SecretName, + }, + }, + }, nil).Once() + } + } + } else if opts.Type == secretTypeDynamic { + if c.MockCalled { + if c.RespErr { + pvs.EXPECT().CreateAwsDynamicSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + pvs.EXPECT().CreateAwsDynamicSecret(&preview_secret_service.CreateAwsDynamicSecretParams{ + OrganizationID: testProfile(t).OrganizationID, + ProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + Body: &preview_models.SecretServiceCreateAwsDynamicSecretBody{ + IntegrationName: "Aws-Int-12", + Name: opts.SecretName, + DefaultTTL: "3600s", + AssumeRole: &preview_models.Secrets20231128AssumeRoleRequest{ + RoleArn: "ra", + }, + }, + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.CreateAwsDynamicSecretOK{ + Payload: &preview_models.Secrets20231128CreateAwsDynamicSecretResponse{ + Secret: &preview_models.Secrets20231128AwsDynamicSecret{ + AssumeRole: &preview_models.Secrets20231128AssumeRoleResponse{ + RoleArn: "ra", + }, + DefaultTTL: "3600s", + CreatedAt: dt, + IntegrationName: "Aws-Int-12", + Name: opts.SecretName, }, - LatestVersion: "2", }, - }, - }, nil).Once() + }, nil).Once() + } } } diff --git a/internal/commands/vaultsecrets/secrets/displayer.go b/internal/commands/vaultsecrets/secrets/displayer.go index ad5c6a94..85e1162f 100644 --- a/internal/commands/vaultsecrets/secrets/displayer.go +++ b/internal/commands/vaultsecrets/secrets/displayer.go @@ -232,3 +232,53 @@ func (d *displayer) openAppSecretsPayload() any { } return d.openAppSecrets } + +type rotatingSecretsDisplayer struct { + previewRotatingSecrets []*preview_models.Secrets20231128RotatingSecretConfig + single bool + + format format.Format +} + +func newRotatingSecretsDisplayer(single bool) *rotatingSecretsDisplayer { + return &rotatingSecretsDisplayer{ + single: single, + format: format.Table, + } +} + +func (r *rotatingSecretsDisplayer) PreviewRotatingSecrets(secrets ...*preview_models.Secrets20231128RotatingSecretConfig) *rotatingSecretsDisplayer { + r.previewRotatingSecrets = secrets + return r +} + +func (r *rotatingSecretsDisplayer) DefaultFormat() format.Format { + return r.format +} + +func (r *rotatingSecretsDisplayer) Payload() any { + if r.single { + if len(r.previewRotatingSecrets) != 1 { + return nil + } + return r.previewRotatingSecrets[0] + } + return r.previewRotatingSecrets +} + +func (r *rotatingSecretsDisplayer) FieldTemplates() []format.Field { + return []format.Field{ + { + Name: "Secret Name", + ValueFormat: "{{ .SecretName }}", + }, + { + Name: " Integration Name", + ValueFormat: "{{ .IntegrationName }}", + }, + { + Name: " Rotation Policy", + ValueFormat: "{{ .RotationPolicyName }}", + }, + } +} diff --git a/internal/commands/vaultsecrets/secrets/rotate.go b/internal/commands/vaultsecrets/secrets/rotate.go new file mode 100644 index 00000000..ad8b0615 --- /dev/null +++ b/internal/commands/vaultsecrets/secrets/rotate.go @@ -0,0 +1,102 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package secrets + +import ( + "context" + "fmt" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/secrets/appname" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/secrets/helper" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type RotateOpts struct { + Ctx context.Context + Profile *profile.Profile + Output *format.Outputter + IO iostreams.IOStreams + + AppName string + SecretName string + Client secret_service.ClientService + PreviewClient preview_secret_service.ClientService +} + +func NewCmdRotate(ctx *cmd.Context, runF func(*RotateOpts) error) *cmd.Command { + opts := &RotateOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + Client: secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "rotate", + ShortHelp: "Rotate a rotating secret.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets secrets rotate" }} command rotates a rotating secret from the Vault Secrets application. + `), + Examples: []cmd.Example{ + { + Preamble: `Rotate a secret:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets secret rotate "test_secret" + `), + }, + { + Preamble: `Rotate a secret under the specified Vault Secrets application:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets secret rotate "test_secret" --app test-app + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the secret to rotate.", + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.AppName = appname.Get() + opts.SecretName = args[0] + + if runF != nil { + return runF(opts) + } + return rotateRun(opts) + }, + } + cmd.Args.Autocomplete = helper.PredictSecretName(ctx, cmd, opts.PreviewClient) + + return cmd +} + +func rotateRun(opts *RotateOpts) error { + params := &preview_secret_service.RotateSecretParams{ + Context: opts.Ctx, + OrganizationID: opts.Profile.OrganizationID, + ProjectID: opts.Profile.ProjectID, + AppName: opts.AppName, + SecretName: opts.SecretName, + } + + _, err := opts.PreviewClient.RotateSecret(params, nil) + if err != nil { + return fmt.Errorf("failed to rotate the secret %q: %w", opts.SecretName, err) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully scheduled rotation of secret with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.SecretName) + return nil +} diff --git a/internal/commands/vaultsecrets/secrets/rotate_test.go b/internal/commands/vaultsecrets/secrets/rotate_test.go new file mode 100644 index 00000000..92f7480b --- /dev/null +++ b/internal/commands/vaultsecrets/secrets/rotate_test.go @@ -0,0 +1,181 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package secrets + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func TestNewCmdRotate(t *testing.T) { + t.Parallel() + + testSecretName := "test_secret" + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + tp.VaultSecrets = &profile.VaultSecretsConf{ + AppName: "test-app", + } + return tp + } + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *RotateOpts + }{ + { + Name: "No args", + Profile: testProfile, + Args: []string{}, + Error: "accepts 1 arg(s), received 0", + }, + { + Name: "Too many args", + Profile: testProfile, + Args: []string{"foo", "bar"}, + Error: "accepts 1 arg(s), received 2", + }, + { + Name: "Good", + Profile: testProfile, + Args: []string{"foo"}, + Expect: &RotateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: testSecretName, + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *RotateOpts + rotateCmd := NewCmdRotate(ctx, func(o *RotateOpts) error { + gotOpts = o + gotOpts.AppName = c.Profile(t).VaultSecrets.AppName + gotOpts.SecretName = testSecretName + return nil + }) + rotateCmd.SetIO(io) + + code := rotateCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.AppName, gotOpts.AppName) + r.Equal(c.Expect.SecretName, gotOpts.SecretName) + }) + } +} + +func TestRotateRun(t *testing.T) { + t.Parallel() + + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + tp.VaultSecrets = &profile.VaultSecretsConf{ + AppName: "test-app", + } + return tp + } + testSecretName := "test_secret" + + cases := []struct { + Name string + RespErr bool + ErrMsg string + MockCalled bool + }{ + { + Name: "Failed: Secret not found", + RespErr: true, + ErrMsg: "[POST] /secrets/2023-11-28/organizations/{organization_id}/projects/{project_id}/apps/{app_name}/secrets/{secret_name}:rotate][404] RotateSecret}", + MockCalled: true, + }, + { + Name: "Success: Rotate secret", + RespErr: false, + MockCalled: true, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + io.ErrorTTY = true + vs := mock_preview_secret_service.NewMockClientService(t) + opts := &RotateOpts{ + Ctx: context.Background(), + IO: io, + Profile: testProfile(t), + Output: format.New(io), + PreviewClient: vs, + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: testSecretName, + } + + if c.MockCalled { + if c.RespErr { + vs.EXPECT().RotateSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + vs.EXPECT().RotateSecret(&preview_secret_service.RotateSecretParams{ + OrganizationID: testProfile(t).OrganizationID, + ProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: opts.SecretName, + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.RotateSecretOK{}, nil).Once() + } + } + + // Run the command + err := rotateRun(opts) + if c.ErrMsg != "" { + r.Contains(err.Error(), c.ErrMsg) + return + } + + r.NoError(err) + r.Equal(io.Error.String(), fmt.Sprintf("✓ Successfully scheduled rotation of secret with name %q\n", opts.SecretName)) + }) + } +} diff --git a/internal/commands/vaultsecrets/secrets/secrets.go b/internal/commands/vaultsecrets/secrets/secrets.go index 4a5f5fd9..2f941dc3 100644 --- a/internal/commands/vaultsecrets/secrets/secrets.go +++ b/internal/commands/vaultsecrets/secrets/secrets.go @@ -48,6 +48,8 @@ func NewCmdSecrets(ctx *cmd.Context) *cmd.Command { cmd.AddChild(NewCmdDelete(ctx, nil)) cmd.AddChild(NewCmdList(ctx, nil)) cmd.AddChild(NewCmdOpen(ctx, nil)) + cmd.AddChild(NewCmdRotate(ctx, nil)) + cmd.AddChild(NewCmdUpdate(ctx, nil)) cmd.AddChild(versions.NewCmdVersions(ctx)) return cmd diff --git a/internal/commands/vaultsecrets/secrets/update.go b/internal/commands/vaultsecrets/secrets/update.go new file mode 100644 index 00000000..2948cbd1 --- /dev/null +++ b/internal/commands/vaultsecrets/secrets/update.go @@ -0,0 +1,340 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package secrets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/posener/complete" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/hcl/v2/hclsimple" + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/integrations" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/secrets/appname" + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/flagvalue" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/heredoc" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +type UpdateOpts struct { + Ctx context.Context + Profile *profile.Profile + IO iostreams.IOStreams + Output *format.Outputter + + AppName string + SecretName string + SecretValuePlaintext string + SecretFilePath string + Type string + PreviewClient preview_secret_service.ClientService + Client secret_service.ClientService +} + +func NewCmdUpdate(ctx *cmd.Context, runF func(*UpdateOpts) error) *cmd.Command { + opts := &UpdateOpts{ + Ctx: ctx.ShutdownCtx, + Profile: ctx.Profile, + IO: ctx.IO, + Output: ctx.Output, + PreviewClient: preview_secret_service.New(ctx.HCP, nil), + Client: secret_service.New(ctx.HCP, nil), + } + + cmd := &cmd.Command{ + Name: "update", + ShortHelp: "Update an existing dynamic or rotating secret.", + LongHelp: heredoc.New(ctx.IO).Must(` + The {{ template "mdCodeOrBold" "hcp vault-secrets secrets update" }} command updates an existing rotating or dynamic secret under a Vault Secrets application. + The configuration for updating your rotating or dynamic secret will be read from the provided HCL config file. The following fields are required in the config + file: [type details]. For help populating the details for a dynamic or rotating secret, please refer to the + {{ Link "API reference documentation" "https://developer.hashicorp.com/hcp/api-docs/vault-secrets/2023-11-28" }}. + `), + Examples: []cmd.Example{ + { + Preamble: `Update a rotating secret in the Vault Secrets application on your active profile:`, + Command: heredoc.New(ctx.IO, heredoc.WithPreserveNewlines()).Must(` + $ hcp vault-secrets secrets update secret_1 --secret-type=rotating --data-file=tmp/secrets1.txt + `), + }, + }, + Args: cmd.PositionalArguments{ + Args: []cmd.PositionalArgument{ + { + Name: "NAME", + Documentation: "The name of the secret to update.", + }, + }, + }, + Flags: cmd.Flags{ + Local: []*cmd.Flag{ + { + Name: "data-file", + DisplayValue: "DATA_FILE_PATH", + Description: "File path to read secret data from. Set this to '-' to read the secret data from stdin.", + Value: flagvalue.Simple("", &opts.SecretFilePath), + Required: true, + Autocomplete: complete.PredictOr( + complete.PredictFiles("*"), + complete.PredictSet("-"), + ), + }, + { + Name: "secret-type", + DisplayValue: "SECRET_TYPE", + Description: "The type of secret to update: rotating or dynamic.", + Value: flagvalue.Simple("", &opts.Type), + }, + }, + }, + RunF: func(c *cmd.Command, args []string) error { + opts.AppName = appname.Get() + opts.SecretName = args[0] + + if runF != nil { + return runF(opts) + } + return updateRun(opts) + }, + } + + return cmd +} + +type SecretUpdateConfig struct { + Type integrations.IntegrationType `hcl:"type"` + Details cty.Value `hcl:"details"` +} + +func updateRun(opts *UpdateOpts) error { + switch opts.Type { + case secretTypeRotating: + secretConfig, internalConfig, err := readUpdateConfigFile(opts.SecretFilePath) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + + missingFields := validateSecretUpdateConfig(secretConfig) + + if len(missingFields) > 0 { + return fmt.Errorf("missing required field(s) in the config file: %s", missingFields) + } + + switch secretConfig.Type { + case integrations.Twilio: + req := preview_secret_service.NewUpdateTwilioRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.SecretName = opts.SecretName + + var twilioBody preview_models.SecretServiceUpdateTwilioRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = twilioBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + req.Body = &twilioBody + + resp, err := opts.PreviewClient.UpdateTwilioRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + + if err := opts.Output.Display(newRotatingSecretsDisplayer(true).PreviewRotatingSecrets(resp.Payload.Config)); err != nil { + return err + } + + case integrations.MongoDBAtlas: + req := preview_secret_service.NewUpdateMongoDBAtlasRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.SecretName = opts.SecretName + + var mongoDBBody preview_models.SecretServiceUpdateMongoDBAtlasRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = mongoDBBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + req.Body = &mongoDBBody + + resp, err := opts.PreviewClient.UpdateMongoDBAtlasRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + + if err := opts.Output.Display(newRotatingSecretsDisplayer(true).PreviewRotatingSecrets(resp.Payload.Config)); err != nil { + return err + } + + case integrations.AWS: + req := preview_secret_service.NewUpdateAwsIAMUserAccessKeyRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.Name = opts.SecretName + + var awsBody preview_models.SecretServiceUpdateAwsIAMUserAccessKeyRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + + req.Body = &awsBody + + _, err = opts.PreviewClient.UpdateAwsIAMUserAccessKeyRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + + case integrations.GCP: + req := preview_secret_service.NewUpdateGcpServiceAccountKeyRotatingSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.Name = opts.SecretName + + var gcpBody preview_models.SecretServiceUpdateGcpServiceAccountKeyRotatingSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + + req.Body = &gcpBody + + _, err = opts.PreviewClient.UpdateGcpServiceAccountKeyRotatingSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + } + + case secretTypeDynamic: + secretConfig, internalConfig, err := readUpdateConfigFile(opts.SecretFilePath) + if err != nil { + return fmt.Errorf("failed to process config file: %w", err) + } + + missingFields := validateSecretUpdateConfig(secretConfig) + + if len(missingFields) > 0 { + return fmt.Errorf("missing required field(s) in the config file: %s", missingFields) + } + + switch secretConfig.Type { + case integrations.AWS: + req := preview_secret_service.NewUpdateAwsDynamicSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.Name = opts.SecretName + + var awsBody preview_models.SecretServiceUpdateAwsDynamicSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = awsBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + + req.Body = &awsBody + + _, err = opts.PreviewClient.UpdateAwsDynamicSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + + case integrations.GCP: + req := preview_secret_service.NewUpdateGcpDynamicSecretParamsWithContext(opts.Ctx) + req.OrganizationID = opts.Profile.OrganizationID + req.ProjectID = opts.Profile.ProjectID + req.AppName = opts.AppName + req.Name = opts.SecretName + + var gcpBody preview_models.SecretServiceUpdateGcpDynamicSecretBody + detailBytes, err := json.Marshal(internalConfig.Details) + if err != nil { + return fmt.Errorf("error marshaling details config: %w", err) + } + + err = gcpBody.UnmarshalBinary(detailBytes) + if err != nil { + return fmt.Errorf("error unmarshaling details config: %w", err) + } + + req.Body = &gcpBody + + _, err = opts.PreviewClient.UpdateGcpDynamicSecret(req, nil) + if err != nil { + return fmt.Errorf("failed to update secret with name %q: %w", opts.SecretName, err) + } + } + + default: + return fmt.Errorf("%q is an unsupported secret type; \"rotating\" and \"dynamic\" are available types", opts.Type) + } + + fmt.Fprintf(opts.IO.Err(), "%s Successfully updated secret with name %q\n", opts.IO.ColorScheme().SuccessIcon(), opts.SecretName) + + return nil +} + +func readUpdateConfigFile(filePath string) (SecretUpdateConfig, secretConfigInternal, error) { + var ( + secretConfig SecretUpdateConfig + internalConfig secretConfigInternal + ) + + if err := hclsimple.DecodeFile(filePath, nil, &secretConfig); err != nil { + return secretConfig, internalConfig, fmt.Errorf("failed to decode config file: %w", err) + } + + detailsMap, err := integrations.CtyValueToMap(secretConfig.Details) + if err != nil { + return secretConfig, internalConfig, err + } + internalConfig.Details = detailsMap + + return secretConfig, internalConfig, nil +} + +func validateSecretUpdateConfig(secretConfig SecretUpdateConfig) []string { + var missingKeys []string + + if secretConfig.Type == "" { + missingKeys = append(missingKeys, "type") + } + + return missingKeys +} diff --git a/internal/commands/vaultsecrets/secrets/update_test.go b/internal/commands/vaultsecrets/secrets/update_test.go new file mode 100644 index 00000000..15dad037 --- /dev/null +++ b/internal/commands/vaultsecrets/secrets/update_test.go @@ -0,0 +1,347 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package secrets + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/go-openapi/strfmt" + preview_secret_service "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + preview_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/models" + mock_preview_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/preview/2023-11-28/client/secret_service" + mock_secret_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-06-13/client/secret_service" + "github.com/stretchr/testify/mock" + + "github.com/go-openapi/runtime/client" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/hcp/internal/pkg/cmd" + "github.com/hashicorp/hcp/internal/pkg/format" + "github.com/hashicorp/hcp/internal/pkg/iostreams" + "github.com/hashicorp/hcp/internal/pkg/profile" +) + +func TestNewCmdUpdate(t *testing.T) { + t.Parallel() + + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + tp.VaultSecrets = &profile.VaultSecretsConf{ + AppName: "test-app", + } + return tp + } + + cases := []struct { + Name string + Args []string + Profile func(t *testing.T) *profile.Profile + Error string + Expect *UpdateOpts + }{ + { + Name: "Failed: No secret name arg specified", + Profile: testProfile, + Args: []string{}, + Error: "ERROR: missing required flag: --data-file=DATA_FILE_PATH", + }, + { + Name: "Good: Secret name arg specified", + Profile: testProfile, + Args: []string{"test", "--data-file=DATA_FILE_PATH"}, + Expect: &UpdateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test", + }, + }, + { + Name: "Good: Rotating secret", + Profile: testProfile, + Args: []string{"test", "--secret-type=rotating", "--data-file=DATA_FILE_PATH"}, + Expect: &UpdateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test", + }, + }, + { + Name: "Good: Dynamic secret", + Profile: testProfile, + Args: []string{"test", "--secret-type=dynamic", "--data-file=DATA_FILE_PATH"}, + Expect: &UpdateOpts{ + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + // Create a context. + io := iostreams.Test() + ctx := &cmd.Context{ + IO: io, + Profile: c.Profile(t), + Output: format.New(io), + HCP: &client.Runtime{}, + ShutdownCtx: context.Background(), + } + + var gotOpts *UpdateOpts + updateCmd := NewCmdUpdate(ctx, func(o *UpdateOpts) error { + gotOpts = o + gotOpts.AppName = "test-app" + return nil + }) + updateCmd.SetIO(io) + + code := updateCmd.Run(c.Args) + if c.Error != "" { + r.NotZero(code) + r.Contains(io.Error.String(), c.Error) + return + } + + r.Zero(code, io.Error.String()) + r.NotNil(gotOpts) + r.Equal(c.Expect.AppName, gotOpts.AppName) + r.Equal(c.Expect.SecretName, gotOpts.SecretName) + }) + } +} + +func TestUpdateRun(t *testing.T) { + t.Parallel() + + testProfile := func(t *testing.T) *profile.Profile { + tp := profile.TestProfile(t).SetOrgID("123").SetProjectID("456") + tp.VaultSecrets = &profile.VaultSecretsConf{ + AppName: "test-app", + } + return tp + } + + cases := []struct { + Name string + RespErr bool + ReadViaStdin bool + EmptySecretValue bool + ErrMsg string + MockCalled bool + AugmentOpts func(opts *UpdateOpts) + Input []byte + }{ + { + Name: "Success: Update a MongoDB rotating secret", + RespErr: false, + AugmentOpts: func(o *UpdateOpts) { + o.Type = secretTypeRotating + }, + MockCalled: true, + Input: []byte(`type = "mongodb-atlas" +details = { + rotate_on_update = true + rotation_policy_name = "built-in:60-days-2-active" + secret_details = { + mongodb_group_id = "mbdgi" + mongodb_roles = [{ + "role_name" = "rn1" + "database_name" = "dn1" + "collection_name" = "cn1" + }, + { + "role_name" = "rn2" + "database_name" = "dn2" + "collection_name" = "cn2" + }] + } +}`), + }, + { + Name: "Success: Update an Aws dynamic secret", + RespErr: false, + AugmentOpts: func(o *UpdateOpts) { + o.Type = secretTypeDynamic + }, + MockCalled: true, + Input: []byte(`type = "aws" + +details = { + "default_ttl" = "3600s" + + "assume_role" = { + "role_arn" = "ra2" + } +}`), + }, + { + Name: "Failed: Unable to update integration name of a secret", + RespErr: true, + AugmentOpts: func(o *UpdateOpts) { + o.Type = secretTypeDynamic + }, + ErrMsg: "Unsupported argument; An argument named \"integration_name\" is not expected here", + Input: []byte(`type = "aws" +integration_name = "Aws-Int-12" + +details = { + "default_ttl" = "3600s" + + "assume_role" = { + "role_arn" = "ra2" + } +}`), + }, + { + Name: "Failed: Unsupported secret type", + RespErr: true, + AugmentOpts: func(o *UpdateOpts) { + o.Type = "random" + }, + Input: []byte{}, + ErrMsg: "\"random\" is an unsupported secret type; \"rotating\" and \"dynamic\" are available types", + }, + { + Name: "Failed: Unsupported static secret type", + RespErr: true, + AugmentOpts: func(o *UpdateOpts) { + o.Type = secretTypeKV + }, + Input: []byte{}, + ErrMsg: "\"kv\" is an unsupported secret type; \"rotating\" and \"dynamic\" are available types", + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + io := iostreams.Test() + + vs := mock_secret_service.NewMockClientService(t) + pvs := mock_preview_secret_service.NewMockClientService(t) + + opts := &UpdateOpts{ + Ctx: context.Background(), + IO: io, + Profile: testProfile(t), + Output: format.New(io), + Client: vs, + PreviewClient: pvs, + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test_secret", + } + + if c.AugmentOpts != nil { + c.AugmentOpts(opts) + } + + tempDir := t.TempDir() + f, err := os.Create(filepath.Join(tempDir, "config.hcl")) + r.NoError(err) + _, err = f.Write(c.Input) + r.NoError(err) + opts.SecretFilePath = f.Name() + + dt := strfmt.NewDateTime() + if opts.Type == secretTypeRotating { + if c.MockCalled { + if c.RespErr { + pvs.EXPECT().UpdateMongoDBAtlasRotatingSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + pvs.EXPECT().UpdateMongoDBAtlasRotatingSecret(&preview_secret_service.UpdateMongoDBAtlasRotatingSecretParams{ + OrganizationID: testProfile(t).OrganizationID, + ProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + SecretName: "test_secret", + Body: &preview_models.SecretServiceUpdateMongoDBAtlasRotatingSecretBody{ + RotateOnUpdate: true, + RotationPolicyName: "built-in:60-days-2-active", + SecretDetails: &preview_models.Secrets20231128MongoDBAtlasSecretDetails{ + MongodbGroupID: "mbdgi", + MongodbRoles: []*preview_models.Secrets20231128MongoDBRole{ + { + RoleName: "rn1", + DatabaseName: "dn1", + CollectionName: "cn1", + }, + { + RoleName: "rn2", + DatabaseName: "dn2", + CollectionName: "cn2", + }, + }, + }, + }, + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.UpdateMongoDBAtlasRotatingSecretOK{ + Payload: &preview_models.Secrets20231128UpdateMongoDBAtlasRotatingSecretResponse{ + Config: &preview_models.Secrets20231128RotatingSecretConfig{ + AppName: opts.AppName, + CreatedAt: dt, + IntegrationName: "mongo-db-integration", + RotationPolicyName: "built-in:60-days-2-active", + SecretName: opts.SecretName, + }, + }, + }, nil).Once() + } + } + } else if opts.Type == secretTypeDynamic { + if c.MockCalled { + if c.RespErr { + pvs.EXPECT().UpdateAwsDynamicSecret(mock.Anything, mock.Anything).Return(nil, errors.New(c.ErrMsg)).Once() + } else { + pvs.EXPECT().UpdateAwsDynamicSecret(&preview_secret_service.UpdateAwsDynamicSecretParams{ + OrganizationID: testProfile(t).OrganizationID, + ProjectID: testProfile(t).ProjectID, + AppName: testProfile(t).VaultSecrets.AppName, + Name: opts.SecretName, + Body: &preview_models.SecretServiceUpdateAwsDynamicSecretBody{ + DefaultTTL: "3600s", + AssumeRole: &preview_models.Secrets20231128AssumeRoleRequest{ + RoleArn: "ra2", + }, + }, + Context: opts.Ctx, + }, mock.Anything).Return(&preview_secret_service.UpdateAwsDynamicSecretOK{ + Payload: &preview_models.Secrets20231128UpdateAwsDynamicSecretResponse{ + Secret: &preview_models.Secrets20231128AwsDynamicSecret{ + AssumeRole: &preview_models.Secrets20231128AssumeRoleResponse{ + RoleArn: "ra2", + }, + DefaultTTL: "3600s", + CreatedAt: dt, + IntegrationName: "Aws-Int-12", + Name: opts.SecretName, + }, + }, + }, nil).Once() + } + } + } + + // Run the command + err = updateRun(opts) + if c.ErrMsg != "" { + r.Contains(err.Error(), c.ErrMsg) + return + } + + r.NoError(err) + r.Contains(io.Error.String(), fmt.Sprintf("✓ Successfully updated secret with name %q\n", opts.SecretName)) + }) + } + +} diff --git a/internal/commands/vaultsecrets/vault_secrets.go b/internal/commands/vaultsecrets/vault_secrets.go index bdfa362e..452ff627 100644 --- a/internal/commands/vaultsecrets/vault_secrets.go +++ b/internal/commands/vaultsecrets/vault_secrets.go @@ -6,6 +6,7 @@ package vaultsecrets import ( "github.com/hashicorp/hcp/internal/commands/vaultsecrets/apps" "github.com/hashicorp/hcp/internal/commands/vaultsecrets/gatewaypools" + "github.com/hashicorp/hcp/internal/commands/vaultsecrets/integrations" "github.com/hashicorp/hcp/internal/commands/vaultsecrets/run" "github.com/hashicorp/hcp/internal/commands/vaultsecrets/secrets" "github.com/hashicorp/hcp/internal/pkg/cmd" @@ -26,6 +27,7 @@ func NewCmdVaultSecrets(ctx *cmd.Context) *cmd.Command { } cmd.AddChild(apps.NewCmdApps(ctx)) + cmd.AddChild(integrations.NewCmdIntegrations(ctx)) cmd.AddChild(secrets.NewCmdSecrets(ctx)) cmd.AddChild(gatewaypools.NewCmdGatewayPools(ctx)) cmd.AddChild(run.NewCmdRun(ctx, nil))