From bd7aa5b33dddb6fc8a0ea64688af1490c27e69f5 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 30 Oct 2025 17:31:19 -0400 Subject: [PATCH 01/35] initial implementation --- go.mod | 32 +-- go.sum | 66 +++---- internal/provider/action_local_command.go | 183 ++++++++++++++++++ .../provider/action_local_command_test.go | 47 +++++ internal/provider/provider.go | 9 +- .../testdata/TestLocalCommandAction/main.tf | 25 +++ .../scripts/example_script.sh | 4 + 7 files changed, 316 insertions(+), 50 deletions(-) create mode 100644 internal/provider/action_local_command.go create mode 100644 internal/provider/action_local_command_test.go create mode 100644 internal/provider/testdata/TestLocalCommandAction/main.tf create mode 100644 internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh diff --git a/go.mod b/go.mod index a69953da..37c2570f 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,14 @@ module github.com/terraform-providers/terraform-provider-local go 1.24.0 +replace github.com/hashicorp/terraform-plugin-testing => /Users/austin.valle/code/terraform-plugin-testing + require ( github.com/google/go-cmp v0.7.0 github.com/hashicorp/terraform-plugin-framework v1.16.1 github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.29.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.13.3 ) @@ -28,35 +31,34 @@ require ( github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hc-install v0.9.2 // indirect - github.com/hashicorp/hcl/v2 v2.23.0 // indirect + github.com/hashicorp/hcl/v2 v2.24.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-exec v0.23.0 // indirect - github.com/hashicorp/terraform-json v0.25.0 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect - github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 // indirect + github.com/hashicorp/terraform-exec v0.24.0 // indirect + github.com/hashicorp/terraform-json v0.27.2 // indirect + github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/kr/pretty v0.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect - github.com/mitchellh/go-wordwrap v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/zclconf/go-cty v1.16.3 // indirect - golang.org/x/crypto v0.41.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/net v0.45.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/go.sum b/go.sum index 101bd83f..d1dbb316 100644 --- a/go.sum +++ b/go.sum @@ -75,14 +75,14 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= -github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= -github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= -github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= -github.com/hashicorp/terraform-json v0.25.0 h1:rmNqc/CIfcWawGiwXmRuiXJKEiJu1ntGoxseG1hLhoQ= -github.com/hashicorp/terraform-json v0.25.0/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= +github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= +github.com/hashicorp/terraform-exec v0.24.0/go.mod h1:lluc/rDYfAhYdslLJQg3J0oDqo88oGQAdHR+wDqFvo4= +github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= +github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-framework v1.16.1 h1:1+zwFm3MEqd/0K3YBB2v9u9DtyYHyEuhVOfeIXbteWA= github.com/hashicorp/terraform-plugin-framework v1.16.1/go.mod h1:0xFOxLy5lRzDTayc4dzK/FakIgBhNf/lC4499R9cV4Y= github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 h1:Zz3iGgzxe/1XBkooZCewS0nJAaCFPFPHdNJd8FgE4Ow= @@ -91,10 +91,8 @@ github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+ github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM= -github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA= -github.com/hashicorp/terraform-plugin-testing v1.13.3 h1:QLi/khB8Z0a5L54AfPrHukFpnwsGL8cwwswj4RZduCo= -github.com/hashicorp/terraform-plugin-testing v1.13.3/go.mod h1:WHQ9FDdiLoneey2/QHpGM/6SAYf4A7AZazVg7230pLE= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 h1:mlAq/OrMlg04IuJT7NpefI1wwtdpWudnEmjuQs04t/4= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1/go.mod h1:GQhpKVvvuwzD79e8/NZ+xzj+ZpWovdPAe8nfV/skwNU= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -108,8 +106,8 @@ github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -127,8 +125,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-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= -github.com/mitchellh/go-wordwrap v1.0.0/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.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -140,8 +138,8 @@ github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxu github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= @@ -160,8 +158,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.3 h1:osr++gw2T61A8KVYHoQiFbFd1Lh3JOCXc/jFLJXKTxk= -github.com/zclconf/go-cty v1.16.3/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= 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.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -178,22 +176,22 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -206,21 +204,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 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.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= @@ -237,9 +235,9 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go new file mode 100644 index 00000000..bdd56696 --- /dev/null +++ b/internal/provider/action_local_command.go @@ -0,0 +1,183 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ action.Action = (*localCommandAction)(nil) +) + +func NewLocalCommandAction() action.Action { + return &localCommandAction{} +} + +type localCommandAction struct{} + +func (a *localCommandAction) Metadata(ctx context.Context, req action.MetadataRequest, resp *action.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_command" +} + +func (a *localCommandAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "", // TODO: Describe action, mention that actions don't have output, this is meant to execute local commands, they can be non-idempotent as they are only executed during apply. + // If the external command is idempotent/you need the output, use data source (coming soon). + Attributes: map[string]schema.Attribute{ + "command": schema.StringAttribute{ + Description: "Executable name to be discovered on the PATH or absolute path to executable.", + Required: true, + }, + "arguments": schema.ListAttribute{ + Description: "Arguments to be passed to the given command.", + ElementType: types.StringType, + Optional: true, + }, + "stdin": schema.StringAttribute{ + Description: "Data to be passed to the given command's standard input.", + Optional: true, + }, + "working_directory": schema.StringAttribute{ + Description: "The directory where the command should be executed. Defaults to the current working directory.", + Optional: true, + }, + }, + } +} + +type localCommandActionModel struct { + Command types.String `tfsdk:"command"` + Arguments types.List `tfsdk:"arguments"` + Stdin types.String `tfsdk:"stdin"` + WorkingDirectory types.String `tfsdk:"working_directory"` +} + +func (a *localCommandAction) ModifyPlan(ctx context.Context, req action.ModifyPlanRequest, resp *action.ModifyPlanResponse) { + var command types.String + resp.Diagnostics.Append(req.Config.GetAttribute(ctx, path.Root("command"), &command)...) + if resp.Diagnostics.HasError() || command.IsUnknown() { + return + } + + resp.Diagnostics.Append(findCommand(command.ValueString())) +} + +func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var config localCommandActionModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // Prep the commmand + command := config.Command.ValueString() + resp.Diagnostics.Append(findCommand(command)) + if resp.Diagnostics.HasError() { + return + } + + arguments := make([]string, 0) + resp.Diagnostics.Append(config.Arguments.ElementsAs(ctx, &arguments, true)...) + if resp.Diagnostics.HasError() { + return + } + + cmd := exec.CommandContext(ctx, command, arguments...) + + cmd.Dir = config.WorkingDirectory.ValueString() + + if !config.Stdin.IsNull() { + cmd.Stdin = bytes.NewReader([]byte(config.Stdin.ValueString())) + } + + var stderr strings.Builder + cmd.Stderr = &stderr + + tflog.Trace(ctx, "Executing local command", map[string]interface{}{"command": cmd.String()}) + + // Run the command + stdout, err := cmd.Output() + stdoutStr := string(stdout) + stderrStr := stderr.String() + + if err != nil { + if len(stderrStr) > 0 { + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The action received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Commmand: %s\n", cmd.String())+ + fmt.Sprintf("Command Error: %s\n", stderrStr)+ + fmt.Sprintf("Error Message: %s", err), + ) + return + } + + resp.Diagnostics.Append(genericCommandDiag(cmd, err)) + return + } + + tflog.Trace(ctx, "Executed local command", map[string]interface{}{"command": cmd.String(), "stdout": stdoutStr, "stderr": stderrStr}) + + // Send the STDOUT to Terraform to display to the practitioner. The underlying action protocol supports streaming the + // STDOUT line-by-line in real-time, although each progress message gets a prefix per line, so it'd be difficult + // to read without batching lines together with an arbitrary time interval. (we can do this later if needed) + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("\n\n%s\n", stdoutStr), + }) +} + +func findCommand(command string) diag.Diagnostic { + if _, err := exec.LookPath(command); err != nil { + return diag.NewAttributeErrorDiagnostic( + path.Root("command"), + "Command Lookup Failed", + "The action received an unexpected error while attempting to find the command."+ + "\n\n"+ + "The command must be accessible according to the platform where Terraform is running."+ + "\n\n"+ + "If the expected command should be automatically found on the platform where Terraform is running, "+ + "ensure that the command is in an expected directory. On Unix-based platforms, these directories are "+ + "typically searched based on the '$PATH' environment variable. On Windows-based platforms, these directories "+ + "are typically searched based on the '%PATH%' environment variable."+ + "\n\n"+ + "If the expected command is relative to the Terraform configuration, it is recommended that the command name includes "+ + "the interpolated value of 'path.module' before the command name to ensure that it is compatible with varying module usage. For example: \"${path.module}/my-command\""+ + "\n\n"+ + "The command must also be executable according to the platform where Terraform is running. On Unix-based platforms, the file on the filesystem must have the executable bit set. "+ + "On Windows-based platforms, no action is typically necessary."+ + "\n\n"+ + fmt.Sprintf("Platform: %s\n", runtime.GOOS)+ + fmt.Sprintf("Command: %s\n", command)+ + fmt.Sprintf("Error Message: %s", err), + ) + } + + return nil +} + +func genericCommandDiag(cmd *exec.Cmd, err error) diag.Diagnostic { + return diag.NewAttributeErrorDiagnostic( + path.Root("command"), + "Command Execution Failed", + "The action received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Commmand: %s\n", cmd.Path)+ + fmt.Sprintf("Error Message: %s", err), + ) +} diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go new file mode 100644 index 00000000..ec4162b6 --- /dev/null +++ b/internal/provider/action_local_command_test.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/actioncheck" + "github.com/hashicorp/terraform-plugin-testing/config" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestLocalCommandAction(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + testScriptsDir := filepath.Join(wd, "testdata", t.Name(), "scripts") + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "stdin": config.StringVariable("Austin"), + "working_directory": config.StringVariable(testScriptsDir), + }, + ConfigDirectory: config.TestNameDirectory(), + ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressMessageContains("local_command", "Hello Austin!"), + }, + PostApplyFunc: func() { + fmt.Println("we're done!") + }, + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 907ce2f3..69294a52 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -12,6 +12,7 @@ import ( "encoding/base64" "encoding/hex" + "github.com/hashicorp/terraform-plugin-framework/action" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/provider" @@ -20,7 +21,7 @@ import ( ) var ( - _ provider.ProviderWithFunctions = (*localProvider)(nil) + _ provider.Provider = (*localProvider)(nil) ) func New() provider.Provider { @@ -57,6 +58,12 @@ func (p *localProvider) Functions(ctx context.Context) []func() function.Functio } } +func (p *localProvider) Actions(ctx context.Context) []func() action.Action { + return []func() action.Action{ + NewLocalCommandAction, + } +} + func (p *localProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{} } diff --git a/internal/provider/testdata/TestLocalCommandAction/main.tf b/internal/provider/testdata/TestLocalCommandAction/main.tf new file mode 100644 index 00000000..e8bea22a --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction/main.tf @@ -0,0 +1,25 @@ +variable "stdin" { + type = string +} + +variable "working_directory" { + type = string +} + +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.bash_test] + } + } +} + +action "local_command" "bash_test" { + config { + command = "bash" + arguments = ["example_script.sh"] + stdin = var.stdin + working_directory = var.working_directory + } +} diff --git a/internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh new file mode 100644 index 00000000..536090ae --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +NAME=$( Date: Thu, 30 Oct 2025 17:49:07 -0400 Subject: [PATCH 02/35] update the config directory to work running manually --- .../testdata/TestLocalCommandAction/main.tf | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/provider/testdata/TestLocalCommandAction/main.tf b/internal/provider/testdata/TestLocalCommandAction/main.tf index e8bea22a..9c372a5b 100644 --- a/internal/provider/testdata/TestLocalCommandAction/main.tf +++ b/internal/provider/testdata/TestLocalCommandAction/main.tf @@ -3,7 +3,8 @@ variable "stdin" { } variable "working_directory" { - type = string + type = string + default = "" } resource "terraform_data" "test" { @@ -17,9 +18,17 @@ resource "terraform_data" "test" { action "local_command" "bash_test" { config { - command = "bash" - arguments = ["example_script.sh"] - stdin = var.stdin - working_directory = var.working_directory + command = "bash" + arguments = ["example_script.sh"] + stdin = var.stdin + + # This configuration will get copied to a temporary location without the scripts folder, so for + # acceptance tests we pass the working directory from the Go test environment via a variable. + # If running manually, there is no need to provide the working_directory. + working_directory = ( + var.working_directory != "" ? + var.working_directory : + "${abspath(path.module)}/scripts" + ) } } From 232b9107c155bc0857a1526adee63c71e3e9dac8 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 30 Oct 2025 17:50:57 -0400 Subject: [PATCH 03/35] add count --- internal/provider/action_local_command_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index ec4162b6..573d421a 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -36,6 +36,7 @@ func TestLocalCommandAction(t *testing.T) { }, ConfigDirectory: config.TestNameDirectory(), ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressCount("local_command", 1), actioncheck.ExpectProgressMessageContains("local_command", "Hello Austin!"), }, PostApplyFunc: func() { From bbe42309a5f64771c08f7255596ce8f55a2ffc0c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 31 Oct 2025 15:21:06 -0400 Subject: [PATCH 04/35] add test --- .../provider/action_local_command_test.go | 35 ++++++++++++-- .../testdata/TestLocalCommandAction/main.tf | 34 ------------- .../TestLocalCommandAction_bash/main.tf | 48 +++++++++++++++++++ .../scripts/example_script.sh | 2 + 4 files changed, 80 insertions(+), 39 deletions(-) delete mode 100644 internal/provider/testdata/TestLocalCommandAction/main.tf create mode 100644 internal/provider/testdata/TestLocalCommandAction_bash/main.tf rename internal/provider/testdata/{TestLocalCommandAction => TestLocalCommandAction_bash}/scripts/example_script.sh (55%) diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index 573d421a..f52857f6 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -5,22 +5,34 @@ package provider import ( "fmt" + "math/rand" "os" "path/filepath" "testing" + "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-testing/actioncheck" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -func TestLocalCommandAction(t *testing.T) { +// This test calls the "bash" command and passes the STDIN and arguments to a bash script +// that prints to STDOUT and creates a file in the temporary test directory. +func TestLocalCommandAction_bash(t *testing.T) { wd, err := os.Getwd() if err != nil { t.Fatalf("Failed to get working directory: %v", err) } + testScriptsDir := filepath.Join(wd, "testdata", t.Name(), "scripts") + tempDir := t.TempDir() + stdin := "John" + randomNumber1 := rand.Intn(100) + randomNumber2 := rand.Intn(100) + randomNumber3 := rand.Intn(100) + + expectedFileContent := fmt.Sprintf("%s - args: %d %d %d\n", stdin, randomNumber1, randomNumber2, randomNumber3) resource.UnitTest(t, resource.TestCase{ // Actions are only available in 1.14 and later @@ -31,16 +43,29 @@ func TestLocalCommandAction(t *testing.T) { Steps: []resource.TestStep{ { ConfigVariables: config.Variables{ - "stdin": config.StringVariable("Austin"), - "working_directory": config.StringVariable(testScriptsDir), + "stdin": config.StringVariable(stdin), + "working_directory": config.StringVariable(tempDir), + "scripts_folder_path": config.StringVariable(testScriptsDir), + "arguments": config.ListVariable( + config.IntegerVariable(randomNumber1), + config.IntegerVariable(randomNumber2), + config.IntegerVariable(randomNumber3), + ), }, ConfigDirectory: config.TestNameDirectory(), ActionChecks: []actioncheck.ActionCheck{ actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", "Hello Austin!"), + actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), }, PostApplyFunc: func() { - fmt.Println("we're done!") + testFile, err := os.ReadFile(filepath.Join(tempDir, "test_file.txt")) + if err != nil { + t.Fatalf("error trying to read created test file: %s", err) + } + + if diff := cmp.Diff(expectedFileContent, string(testFile)); diff != "" { + t.Fatalf("unexpected file diff (-expected, +got): %s", diff) + } }, }, }, diff --git a/internal/provider/testdata/TestLocalCommandAction/main.tf b/internal/provider/testdata/TestLocalCommandAction/main.tf deleted file mode 100644 index 9c372a5b..00000000 --- a/internal/provider/testdata/TestLocalCommandAction/main.tf +++ /dev/null @@ -1,34 +0,0 @@ -variable "stdin" { - type = string -} - -variable "working_directory" { - type = string - default = "" -} - -resource "terraform_data" "test" { - lifecycle { - action_trigger { - events = [after_create] - actions = [action.local_command.bash_test] - } - } -} - -action "local_command" "bash_test" { - config { - command = "bash" - arguments = ["example_script.sh"] - stdin = var.stdin - - # This configuration will get copied to a temporary location without the scripts folder, so for - # acceptance tests we pass the working directory from the Go test environment via a variable. - # If running manually, there is no need to provide the working_directory. - working_directory = ( - var.working_directory != "" ? - var.working_directory : - "${abspath(path.module)}/scripts" - ) - } -} diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/main.tf b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf new file mode 100644 index 00000000..82a5df83 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf @@ -0,0 +1,48 @@ +variable "stdin" { + type = string +} + +variable "arguments" { + type = list(string) + default = [] +} + +variable "working_directory" { + type = string + default = null +} + +variable "scripts_folder_path" { + type = string + default = null +} + +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.bash_test] + } + } +} + +locals { + test_script = ( + # This configuration will get copied to a temporary location without the scripts folder, so for + # acceptance tests we pass the folder path from the Go test environment via a variable. + # If running manually, there is no need to provide the scripts_folder_path. + var.scripts_folder_path != null ? + "${var.scripts_folder_path}/example_script.sh" : + "${abspath(path.module)}/scripts/example_script.sh" + ) +} + +action "local_command" "bash_test" { + config { + command = "bash" + arguments = concat([local.test_script], var.arguments) + stdin = var.stdin + + working_directory = var.working_directory + } +} diff --git a/internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh similarity index 55% rename from internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh rename to internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh index 536090ae..6d9180ba 100644 --- a/internal/provider/testdata/TestLocalCommandAction/scripts/example_script.sh +++ b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh @@ -2,3 +2,5 @@ NAME=$(> test_file.txt From 07aa729258acf4eac7afd925ff5f9156b15920f6 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 31 Oct 2025 16:45:33 -0400 Subject: [PATCH 05/35] add tests --- internal/provider/action_local_command.go | 8 +- .../provider/action_local_command_test.go | 203 ++++++++++++++++-- .../TestLocalCommandAction_bash/main.tf | 21 +- .../scripts/example_script.sh | 2 +- .../TestLocalCommandAction_bash/variables.tf | 24 +++ .../TestLocalCommandAction_stderr/main.tf | 31 +++ .../scripts/example_script.sh | 4 + 7 files changed, 253 insertions(+), 40 deletions(-) create mode 100644 internal/provider/testdata/TestLocalCommandAction_bash/variables.tf create mode 100644 internal/provider/testdata/TestLocalCommandAction_stderr/main.tf create mode 100644 internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go index bdd56696..39b5ef11 100644 --- a/internal/provider/action_local_command.go +++ b/internal/provider/action_local_command.go @@ -123,7 +123,7 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques "\n\n"+ fmt.Sprintf("Commmand: %s\n", cmd.String())+ fmt.Sprintf("Command Error: %s\n", stderrStr)+ - fmt.Sprintf("Error Message: %s", err), + fmt.Sprintf("State: %s", err), ) return } @@ -136,7 +136,7 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques // Send the STDOUT to Terraform to display to the practitioner. The underlying action protocol supports streaming the // STDOUT line-by-line in real-time, although each progress message gets a prefix per line, so it'd be difficult - // to read without batching lines together with an arbitrary time interval. (we can do this later if needed) + // to read without batching lines together with an arbitrary time interval (this can be improved later if needed). resp.SendProgress(action.InvokeProgressEvent{ Message: fmt.Sprintf("\n\n%s\n", stdoutStr), }) @@ -164,7 +164,7 @@ func findCommand(command string) diag.Diagnostic { "\n\n"+ fmt.Sprintf("Platform: %s\n", runtime.GOOS)+ fmt.Sprintf("Command: %s\n", command)+ - fmt.Sprintf("Error Message: %s", err), + fmt.Sprintf("Error: %s", err), ) } @@ -178,6 +178,6 @@ func genericCommandDiag(cmd *exec.Cmd, err error) diag.Diagnostic { "The action received an unexpected error while attempting to execute the command."+ "\n\n"+ fmt.Sprintf("Commmand: %s\n", cmd.Path)+ - fmt.Sprintf("Error Message: %s", err), + fmt.Sprintf("Error: %s", err), ) } diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index f52857f6..39961c55 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -7,7 +7,9 @@ import ( "fmt" "math/rand" "os" + "os/exec" "path/filepath" + "regexp" "testing" "github.com/google/go-cmp/cmp" @@ -17,22 +19,91 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -// This test calls the "bash" command and passes the STDIN and arguments to a bash script -// that prints to STDOUT and creates a file in the temporary test directory. +var ( + bashTestDirectory = filepath.Join("testdata", "TestLocalCommandAction_bash") +) + func TestLocalCommandAction_bash(t *testing.T) { wd, err := os.Getwd() if err != nil { t.Fatalf("Failed to get working directory: %v", err) } - testScriptsDir := filepath.Join(wd, "testdata", t.Name(), "scripts") + testScriptsDir := filepath.Join(wd, bashTestDirectory, "scripts") + tempDir := t.TempDir() + expectedFileContent := "stdin: , args: \n" + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "working_directory": config.StringVariable(tempDir), + "scripts_folder_path": config.StringVariable(testScriptsDir), + }, + ConfigDirectory: config.StaticDirectory(bashTestDirectory), + ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressCount("local_command", 1), + actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + }, + PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), + }, + }, + }) +} + +func TestLocalCommandAction_bash_stdin(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + testScriptsDir := filepath.Join(wd, bashTestDirectory, "scripts") + tempDir := t.TempDir() + stdin := "John" + expectedFileContent := fmt.Sprintf("stdin: %s, args: \n", stdin) + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "stdin": config.StringVariable(stdin), + "working_directory": config.StringVariable(tempDir), + "scripts_folder_path": config.StringVariable(testScriptsDir), + }, + ConfigDirectory: config.StaticDirectory(bashTestDirectory), + ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressCount("local_command", 1), + actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), + }, + PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), + }, + }, + }) +} + +func TestLocalCommandAction_bash_all(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + testScriptsDir := filepath.Join(wd, bashTestDirectory, "scripts") tempDir := t.TempDir() stdin := "John" randomNumber1 := rand.Intn(100) randomNumber2 := rand.Intn(100) randomNumber3 := rand.Intn(100) - - expectedFileContent := fmt.Sprintf("%s - args: %d %d %d\n", stdin, randomNumber1, randomNumber2, randomNumber3) + expectedFileContent := fmt.Sprintf("stdin: %s, args: %d %d %d\n", stdin, randomNumber1, randomNumber2, randomNumber3) resource.UnitTest(t, resource.TestCase{ // Actions are only available in 1.14 and later @@ -52,22 +123,124 @@ func TestLocalCommandAction_bash(t *testing.T) { config.IntegerVariable(randomNumber3), ), }, - ConfigDirectory: config.TestNameDirectory(), + ConfigDirectory: config.StaticDirectory(bashTestDirectory), ActionChecks: []actioncheck.ActionCheck{ actioncheck.ExpectProgressCount("local_command", 1), actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), }, - PostApplyFunc: func() { - testFile, err := os.ReadFile(filepath.Join(tempDir, "test_file.txt")) - if err != nil { - t.Fatalf("error trying to read created test file: %s", err) - } - - if diff := cmp.Diff(expectedFileContent, string(testFile)); diff != "" { - t.Fatalf("unexpected file diff (-expected, +got): %s", diff) - } + PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), + }, + }, + }) +} + +func TestLocalCommandAction_absolute_path_bash(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + testScriptsDir := filepath.Join(wd, bashTestDirectory, "scripts") + tempDir := t.TempDir() + expectedFileContent := "stdin: , args: \n" + + bashAbsPath, err := exec.LookPath("bash") + if err != nil { + t.Fatalf("Failed to find bash executable: %v", err) + } + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "bash_path": config.StringVariable(bashAbsPath), + "working_directory": config.StringVariable(tempDir), + "scripts_folder_path": config.StringVariable(testScriptsDir), + }, + ConfigDirectory: config.StaticDirectory(bashTestDirectory), + ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressCount("local_command", 1), + actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + }, + PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), + }, + }, + }) +} + +func TestLocalCommandAction_not_found(t *testing.T) { + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: ` +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.test] + } + } +} + +action "local_command" "test" { + config { + command = "notarealcommand" + } +}`, + ExpectError: regexp.MustCompile(`Error: exec: "notarealcommand": executable file not found in \$PATH`), + }, + }, + }) +} + +func TestLocalCommandAction_stderr(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + testScriptsDir := filepath.Join(wd, "testdata", t.Name(), "scripts") + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "scripts_folder_path": config.StringVariable(testScriptsDir), }, + ConfigDirectory: config.TestNameDirectory(), + ExpectError: regexp.MustCompile(`Command Error: ru roh, an error occurred in the bash script!\n\nState: exit status 1`), }, }, }) } + +func assertTestFile(t *testing.T, filePath, expectedContent string) func() { + return func() { + t.Helper() + + testFile, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("error trying to read created test file: %s", err) + } + + if diff := cmp.Diff(expectedContent, string(testFile)); diff != "" { + t.Fatalf("unexpected file diff (-expected, +got): %s", diff) + } + } +} diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/main.tf b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf index 82a5df83..c30b8298 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash/main.tf +++ b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf @@ -1,22 +1,3 @@ -variable "stdin" { - type = string -} - -variable "arguments" { - type = list(string) - default = [] -} - -variable "working_directory" { - type = string - default = null -} - -variable "scripts_folder_path" { - type = string - default = null -} - resource "terraform_data" "test" { lifecycle { action_trigger { @@ -39,7 +20,7 @@ locals { action "local_command" "bash_test" { config { - command = "bash" + command = var.bash_path arguments = concat([local.test_script], var.arguments) stdin = var.stdin diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh index 6d9180ba..dc72210e 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh +++ b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh @@ -3,4 +3,4 @@ NAME=$(> test_file.txt +echo "stdin: $NAME, args: $@" >> test_file.txt diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/variables.tf b/internal/provider/testdata/TestLocalCommandAction_bash/variables.tf new file mode 100644 index 00000000..5acbe2b8 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_bash/variables.tf @@ -0,0 +1,24 @@ +variable "bash_path" { + type = string + default = "bash" +} + +variable "stdin" { + type = string + default = null +} + +variable "arguments" { + type = list(string) + default = [] +} + +variable "working_directory" { + type = string + default = null +} + +variable "scripts_folder_path" { + type = string + default = null +} diff --git a/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf b/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf new file mode 100644 index 00000000..f6249ae7 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf @@ -0,0 +1,31 @@ +variable "scripts_folder_path" { + type = string + default = null +} + +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.bash_test] + } + } +} + +locals { + test_script = ( + # This configuration will get copied to a temporary location without the scripts folder, so for + # acceptance tests we pass the folder path from the Go test environment via a variable. + # If running manually, there is no need to provide the scripts_folder_path. + var.scripts_folder_path != null ? + "${var.scripts_folder_path}/example_script.sh" : + "${abspath(path.module)}/scripts/example_script.sh" + ) +} + +action "local_command" "bash_test" { + config { + command = "bash" + arguments = [local.test_script] + } +} diff --git a/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh new file mode 100644 index 00000000..6d770d5f --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +echo "ru roh, an error occurred in the bash script!" >&2 +exit 1 \ No newline at end of file From 2653d245301d9a0f69f7c07188128b68ff2d85a9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 09:44:17 -0500 Subject: [PATCH 06/35] don't encode null arguments --- internal/provider/action_local_command.go | 11 +++-- .../provider/action_local_command_test.go | 41 +++++++++++++++++++ .../main.tf | 29 +++++++++++++ .../scripts/example_script.sh | 11 +++++ .../variables.tf | 24 +++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 internal/provider/testdata/TestLocalCommandAction_bash_null_args/main.tf create mode 100644 internal/provider/testdata/TestLocalCommandAction_bash_null_args/scripts/example_script.sh create mode 100644 internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go index 39b5ef11..c352ec1f 100644 --- a/internal/provider/action_local_command.go +++ b/internal/provider/action_local_command.go @@ -91,9 +91,14 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques } arguments := make([]string, 0) - resp.Diagnostics.Append(config.Arguments.ElementsAs(ctx, &arguments, true)...) - if resp.Diagnostics.HasError() { - return + for _, element := range config.Arguments.Elements() { + strElement, ok := element.(types.String) + // Mirroring the underlying os/exec Command support for args (no nil arguments, but does support empty strings) + if element.IsNull() || !ok { + continue + } + + arguments = append(arguments, strElement.ValueString()) } cmd := exec.CommandContext(ctx, command, arguments...) diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index 39961c55..c16e9c0d 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -134,6 +134,47 @@ func TestLocalCommandAction_bash_all(t *testing.T) { }) } +func TestLocalCommandAction_bash_null_args(t *testing.T) { + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + + testScriptsDir := filepath.Join(wd, "testdata", t.Name(), "scripts") + tempDir := t.TempDir() + randomNumber1 := rand.Intn(100) + randomNumber2 := rand.Intn(100) + randomNumber3 := rand.Intn(100) + expectedFileContent := fmt.Sprintf("stdin: , args: %d %d %d\n", randomNumber1, randomNumber2, randomNumber3) + + resource.UnitTest(t, resource.TestCase{ + // Actions are only available in 1.14 and later + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + ConfigVariables: config.Variables{ + "working_directory": config.StringVariable(tempDir), + "scripts_folder_path": config.StringVariable(testScriptsDir), + "arguments": config.ListVariable( // Null arguments will be appended to this list in the test config, then filtered by the action code. + config.IntegerVariable(randomNumber1), + config.IntegerVariable(randomNumber2), + config.IntegerVariable(randomNumber3), + ), + }, + ConfigDirectory: config.TestNameDirectory(), + ActionChecks: []actioncheck.ActionCheck{ + actioncheck.ExpectProgressCount("local_command", 1), + actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + }, + PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), + }, + }, + }) +} + func TestLocalCommandAction_absolute_path_bash(t *testing.T) { wd, err := os.Getwd() if err != nil { diff --git a/internal/provider/testdata/TestLocalCommandAction_bash_null_args/main.tf b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/main.tf new file mode 100644 index 00000000..d6e92bb3 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/main.tf @@ -0,0 +1,29 @@ +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.bash_test] + } + } +} + +locals { + test_script = ( + # This configuration will get copied to a temporary location without the scripts folder, so for + # acceptance tests we pass the folder path from the Go test environment via a variable. + # If running manually, there is no need to provide the scripts_folder_path. + var.scripts_folder_path != null ? + "${var.scripts_folder_path}/example_script.sh" : + "${abspath(path.module)}/scripts/example_script.sh" + ) +} + +action "local_command" "bash_test" { + config { + command = var.bash_path + arguments = concat([local.test_script], [null, null], var.arguments, [null, null, null]) # null arguments will be removed, empty strings preserved + stdin = var.stdin + + working_directory = var.working_directory + } +} diff --git a/internal/provider/testdata/TestLocalCommandAction_bash_null_args/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/scripts/example_script.sh new file mode 100644 index 00000000..a6bce503 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/scripts/example_script.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +if [ "$#" -ne 3 ]; then + echo "You provided $# arguments, expected exactly 3 random number arguments (the 5 null arguments should be removed)." >&2 + exit 1 +fi + +NAME=$(> test_file.txt \ No newline at end of file diff --git a/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf new file mode 100644 index 00000000..5acbe2b8 --- /dev/null +++ b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf @@ -0,0 +1,24 @@ +variable "bash_path" { + type = string + default = "bash" +} + +variable "stdin" { + type = string + default = null +} + +variable "arguments" { + type = list(string) + default = [] +} + +variable "working_directory" { + type = string + default = null +} + +variable "scripts_folder_path" { + type = string + default = null +} From b1ca9558f063e7903e764098b0415b541b2aee03 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 10:45:35 -0500 Subject: [PATCH 07/35] add copyright headers --- internal/provider/testdata/TestLocalCommandAction_bash/main.tf | 3 +++ .../TestLocalCommandAction_bash/scripts/example_script.sh | 3 +++ .../provider/testdata/TestLocalCommandAction_bash/variables.tf | 3 +++ .../testdata/TestLocalCommandAction_bash_null_args/main.tf | 3 +++ .../scripts/example_script.sh | 3 +++ .../TestLocalCommandAction_bash_null_args/variables.tf | 3 +++ .../provider/testdata/TestLocalCommandAction_stderr/main.tf | 3 +++ .../TestLocalCommandAction_stderr/scripts/example_script.sh | 3 +++ 8 files changed, 24 insertions(+) diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/main.tf b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf index c30b8298..25915cc0 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash/main.tf +++ b/internal/provider/testdata/TestLocalCommandAction_bash/main.tf @@ -1,3 +1,6 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + resource "terraform_data" "test" { lifecycle { action_trigger { diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh index dc72210e..d512909c 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh +++ b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + NAME=$(&2 diff --git a/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf index 5acbe2b8..c5b16261 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf +++ b/internal/provider/testdata/TestLocalCommandAction_bash_null_args/variables.tf @@ -1,3 +1,6 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + variable "bash_path" { type = string default = "bash" diff --git a/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf b/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf index f6249ae7..a6284fce 100644 --- a/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf +++ b/internal/provider/testdata/TestLocalCommandAction_stderr/main.tf @@ -1,3 +1,6 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + variable "scripts_folder_path" { type = string default = null diff --git a/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh index 6d770d5f..c0d72bad 100644 --- a/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh +++ b/internal/provider/testdata/TestLocalCommandAction_stderr/scripts/example_script.sh @@ -1,4 +1,7 @@ #!/bin/bash +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + echo "ru roh, an error occurred in the bash script!" >&2 exit 1 \ No newline at end of file From 9b226e4397840fe91e52e456fe7b0c2e5cf40d9b Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 14:53:11 -0500 Subject: [PATCH 08/35] add documentation and example --- docs/actions/command.md | 79 +++++++++++++++++++ examples/actions/local_command/action.tf | 19 +++++ .../actions/local_command/example_script.sh | 4 + internal/provider/action_local_command.go | 14 ++-- templates/actions/command.md.tmpl | 45 +++++++++++ 5 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 docs/actions/command.md create mode 100644 examples/actions/local_command/action.tf create mode 100644 examples/actions/local_command/example_script.sh create mode 100644 templates/actions/command.md.tmpl diff --git a/docs/actions/command.md b/docs/actions/command.md new file mode 100644 index 00000000..d387ac49 --- /dev/null +++ b/docs/actions/command.md @@ -0,0 +1,79 @@ +page_title: "local_command Action - terraform-provider-local" +subcategory: "" +description: |- + Invokes an executable on the local machine. All environment variables visible to the Terraform process are passed through to the child process. After the child process successfully executes, the stdout will be returned for Terraform to display to the user. + Any non-zero exit code will be treated as an error and will return a diagnostic to Terraform containing the stderr message if available. +--- + +# local_command (Action) + +Invokes an executable on the local machine. All environment variables visible to the Terraform process are passed through to the child process. After the child process successfully executes, the `stdout` will be returned for Terraform to display to the user. + +Any non-zero exit code will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available. + +## Example Usage + +For the following bash script (`example_script.sh`): +```bash +#!/bin/bash + +DATA=$( +## Schema + +### Required + +- `command` (String) Executable name to be discovered on the PATH or absolute path to executable. + +### Optional + +- `arguments` (List of String) Arguments to be passed to the given command. Any `null` arguments will be removed from the list. +- `stdin` (String) Data to be passed to the given command's standard input. +- `working_directory` (String) The directory where the command should be executed. Defaults to the Terraform working directory. diff --git a/examples/actions/local_command/action.tf b/examples/actions/local_command/action.tf new file mode 100644 index 00000000..0accb220 --- /dev/null +++ b/examples/actions/local_command/action.tf @@ -0,0 +1,19 @@ +resource "terraform_data" "test" { + lifecycle { + action_trigger { + events = [after_create] + actions = [action.local_command.bash_example] + } + } +} + +action "local_command" "bash_example" { + config { + command = "bash" + arguments = ["example_script.sh", "arg1", "arg2"] + stdin = jsonencode({ + "key1" : "value1" + "key2" : "value2" + }) + } +} diff --git a/examples/actions/local_command/example_script.sh b/examples/actions/local_command/example_script.sh new file mode 100644 index 00000000..afe4b63b --- /dev/null +++ b/examples/actions/local_command/example_script.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +DATA=$( Date: Mon, 3 Nov 2025 15:19:00 -0500 Subject: [PATCH 09/35] remove the tests temporarily for the CI --- go.mod | 4 +- go.sum | 2 + .../provider/action_local_command_test.go | 51 +++++++++++-------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 37c2570f..c914ed8e 100644 --- a/go.mod +++ b/go.mod @@ -2,15 +2,13 @@ module github.com/terraform-providers/terraform-provider-local go 1.24.0 -replace github.com/hashicorp/terraform-plugin-testing => /Users/austin.valle/code/terraform-plugin-testing - require ( github.com/google/go-cmp v0.7.0 github.com/hashicorp/terraform-plugin-framework v1.16.1 github.com/hashicorp/terraform-plugin-framework-validators v0.19.0 github.com/hashicorp/terraform-plugin-go v0.29.0 github.com/hashicorp/terraform-plugin-log v0.9.0 - github.com/hashicorp/terraform-plugin-testing v1.13.3 + github.com/hashicorp/terraform-plugin-testing v1.14.0-beta.1.0.20251029152858-203e6cc410a0 ) require ( diff --git a/go.sum b/go.sum index d1dbb316..e52877eb 100644 --- a/go.sum +++ b/go.sum @@ -93,6 +93,8 @@ github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9T github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 h1:mlAq/OrMlg04IuJT7NpefI1wwtdpWudnEmjuQs04t/4= github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1/go.mod h1:GQhpKVvvuwzD79e8/NZ+xzj+ZpWovdPAe8nfV/skwNU= +github.com/hashicorp/terraform-plugin-testing v1.14.0-beta.1.0.20251029152858-203e6cc410a0 h1:2ZfVb9DwefNk/aN3uJZLIADETfQOdxtOrDZ6iLGtx8o= +github.com/hashicorp/terraform-plugin-testing v1.14.0-beta.1.0.20251029152858-203e6cc410a0/go.mod h1:UrIjRAJLN0kygs0miY1Moy4PxUzy2e9R5WxyRk8aliI= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index c16e9c0d..eae8a24a 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -13,7 +13,6 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/hashicorp/terraform-plugin-testing/actioncheck" "github.com/hashicorp/terraform-plugin-testing/config" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/tfversion" @@ -46,10 +45,12 @@ func TestLocalCommandAction_bash(t *testing.T) { "scripts_folder_path": config.StringVariable(testScriptsDir), }, ConfigDirectory: config.StaticDirectory(bashTestDirectory), - ActionChecks: []actioncheck.ActionCheck{ - actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), - }, + // TODO: Currently action checks don't exist, but eventually we can run these on the progress messages + // https://github.com/hashicorp/terraform-plugin-testing/pull/570 + // ActionChecks: []actioncheck.ActionCheck{ + // actioncheck.ExpectProgressCount("local_command", 1), + // actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + // }, PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), }, }, @@ -81,10 +82,12 @@ func TestLocalCommandAction_bash_stdin(t *testing.T) { "scripts_folder_path": config.StringVariable(testScriptsDir), }, ConfigDirectory: config.StaticDirectory(bashTestDirectory), - ActionChecks: []actioncheck.ActionCheck{ - actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), - }, + // TODO: Currently action checks don't exist, but eventually we can run these on the progress messages + // https://github.com/hashicorp/terraform-plugin-testing/pull/570 + // ActionChecks: []actioncheck.ActionCheck{ + // actioncheck.ExpectProgressCount("local_command", 1), + // actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), + // }, PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), }, }, @@ -124,10 +127,12 @@ func TestLocalCommandAction_bash_all(t *testing.T) { ), }, ConfigDirectory: config.StaticDirectory(bashTestDirectory), - ActionChecks: []actioncheck.ActionCheck{ - actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), - }, + // TODO: Currently action checks don't exist, but eventually we can run these on the progress messages + // https://github.com/hashicorp/terraform-plugin-testing/pull/570 + // ActionChecks: []actioncheck.ActionCheck{ + // actioncheck.ExpectProgressCount("local_command", 1), + // actioncheck.ExpectProgressMessageContains("local_command", fmt.Sprintf("Hello %s!", stdin)), + // }, PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), }, }, @@ -165,10 +170,12 @@ func TestLocalCommandAction_bash_null_args(t *testing.T) { ), }, ConfigDirectory: config.TestNameDirectory(), - ActionChecks: []actioncheck.ActionCheck{ - actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), - }, + // TODO: Currently action checks don't exist, but eventually we can run these on the progress messages + // https://github.com/hashicorp/terraform-plugin-testing/pull/570 + // ActionChecks: []actioncheck.ActionCheck{ + // actioncheck.ExpectProgressCount("local_command", 1), + // actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + // }, PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), }, }, @@ -204,10 +211,12 @@ func TestLocalCommandAction_absolute_path_bash(t *testing.T) { "scripts_folder_path": config.StringVariable(testScriptsDir), }, ConfigDirectory: config.StaticDirectory(bashTestDirectory), - ActionChecks: []actioncheck.ActionCheck{ - actioncheck.ExpectProgressCount("local_command", 1), - actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), - }, + // TODO: Currently action checks don't exist, but eventually we can run these on the progress messages + // https://github.com/hashicorp/terraform-plugin-testing/pull/570 + // ActionChecks: []actioncheck.ActionCheck{ + // actioncheck.ExpectProgressCount("local_command", 1), + // actioncheck.ExpectProgressMessageContains("local_command", "Hello !"), + // }, PostApplyFunc: assertTestFile(t, filepath.Join(tempDir, "test_file.txt"), expectedFileContent), }, }, From f65ffa72c0b1ec122bd170495ed8c6df0652b3a7 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 15:31:25 -0500 Subject: [PATCH 10/35] lint --- internal/provider/action_local_command.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go index 98116f36..f2ed3a43 100644 --- a/internal/provider/action_local_command.go +++ b/internal/provider/action_local_command.go @@ -85,7 +85,7 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques return } - // Prep the commmand + // Prep the command command := config.Command.ValueString() resp.Diagnostics.Append(findCommand(command)) if resp.Diagnostics.HasError() { @@ -128,7 +128,7 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques "Command Execution Failed", "The action received an unexpected error while attempting to execute the command."+ "\n\n"+ - fmt.Sprintf("Commmand: %s\n", cmd.String())+ + fmt.Sprintf("Command: %s\n", cmd.String())+ fmt.Sprintf("Command Error: %s\n", stderrStr)+ fmt.Sprintf("State: %s", err), ) @@ -184,7 +184,7 @@ func genericCommandDiag(cmd *exec.Cmd, err error) diag.Diagnostic { "Command Execution Failed", "The action received an unexpected error while attempting to execute the command."+ "\n\n"+ - fmt.Sprintf("Commmand: %s\n", cmd.Path)+ + fmt.Sprintf("Command: %s\n", cmd.Path)+ fmt.Sprintf("Error: %s", err), ) } From 9640fa95998c396114ba482691ebe90631f6670b Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 15:37:17 -0500 Subject: [PATCH 11/35] use rc1 for generating docs --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cfe215d4..6c4eb42b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,8 @@ jobs: - name: Set up Terraform uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 with: + # TODO: Remove once Terraform 1.14 GA is released + terraform_version: 1.14.0-rc1 terraform_wrapper: false - name: Generate From d87fd02bd4c2871b1921752aed9404dc4c71b55d Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 15:48:53 -0500 Subject: [PATCH 12/35] add changelog and fix tests for windows --- .changes/unreleased/FEATURES-20251103-153952.yaml | 5 +++++ internal/provider/action_local_command_test.go | 2 +- .../TestLocalCommandAction_bash/scripts/example_script.sh | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changes/unreleased/FEATURES-20251103-153952.yaml diff --git a/.changes/unreleased/FEATURES-20251103-153952.yaml b/.changes/unreleased/FEATURES-20251103-153952.yaml new file mode 100644 index 00000000..6b6cc9f9 --- /dev/null +++ b/.changes/unreleased/FEATURES-20251103-153952.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'action/local_command: New action that invokes an executable on the local machine.' +time: 2025-11-03T15:39:52.741799-05:00 +custom: + Issue: "450" diff --git a/internal/provider/action_local_command_test.go b/internal/provider/action_local_command_test.go index eae8a24a..388432d7 100644 --- a/internal/provider/action_local_command_test.go +++ b/internal/provider/action_local_command_test.go @@ -248,7 +248,7 @@ action "local_command" "test" { command = "notarealcommand" } }`, - ExpectError: regexp.MustCompile(`Error: exec: "notarealcommand": executable file not found in \$PATH`), + ExpectError: regexp.MustCompile(`Error: exec: "notarealcommand": executable file not found`), }, }, }) diff --git a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh index d512909c..064e4084 100644 --- a/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh +++ b/internal/provider/testdata/TestLocalCommandAction_bash/scripts/example_script.sh @@ -3,7 +3,7 @@ # SPDX-License-Identifier: MPL-2.0 -NAME=$(> test_file.txt From 2b98309486d7c1136d7e37fbead6c72a1c417e3c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 3 Nov 2025 16:25:10 -0500 Subject: [PATCH 13/35] add vscode gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5982d2c6..98c77a08 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ website/vendor # Test exclusions !command/test-fixtures/**/*.tfstate !command/test-fixtures/**/.terraform/ + +.vscode \ No newline at end of file From 7e68f1a389f104f7a42d9d9768da6eb8f004babb Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 4 Nov 2025 15:23:01 -0500 Subject: [PATCH 14/35] refactor --- internal/provider/action_local_command.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go index f2ed3a43..bf374897 100644 --- a/internal/provider/action_local_command.go +++ b/internal/provider/action_local_command.go @@ -135,7 +135,14 @@ func (a *localCommandAction) Invoke(ctx context.Context, req action.InvokeReques return } - resp.Diagnostics.Append(genericCommandDiag(cmd, err)) + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The action received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.Path)+ + fmt.Sprintf("Error: %s", err), + ) return } @@ -177,14 +184,3 @@ func findCommand(command string) diag.Diagnostic { return nil } - -func genericCommandDiag(cmd *exec.Cmd, err error) diag.Diagnostic { - return diag.NewAttributeErrorDiagnostic( - path.Root("command"), - "Command Execution Failed", - "The action received an unexpected error while attempting to execute the command."+ - "\n\n"+ - fmt.Sprintf("Command: %s\n", cmd.Path)+ - fmt.Sprintf("Error: %s", err), - ) -} From 3103be9c7f314dabbdab4697d29664ce2390a3b1 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 4 Nov 2025 16:47:03 -0500 Subject: [PATCH 15/35] first draft of data source --- .../data-sources/local_command/data-source.tf | 26 +++ .../provider/data_source_local_command.go | 211 ++++++++++++++++++ internal/provider/provider.go | 1 + 3 files changed, 238 insertions(+) create mode 100644 examples/data-sources/local_command/data-source.tf create mode 100644 internal/provider/data_source_local_command.go diff --git a/examples/data-sources/local_command/data-source.tf b/examples/data-sources/local_command/data-source.tf new file mode 100644 index 00000000..3f185a97 --- /dev/null +++ b/examples/data-sources/local_command/data-source.tf @@ -0,0 +1,26 @@ +data "local_command" "example_obj" { + command = "jq" + arguments = ["-n", "{\"foobaz\":\"hello\"}"] +} + + +data "local_command" "example_arr" { + command = "jq" + arguments = ["-n", "[{\"foobaz\":\"hello\"}, {\"foobaz\":\"world\"}]"] +} + +output "jq_obj" { + value = { + stdout = jsondecode(data.local_command.example_obj.stdout) + stderr = data.local_command.example_obj.stderr + exit_code = data.local_command.example_obj.exit_code + } +} + +output "jq_arr" { + value = { + stdout = jsondecode(data.local_command.example_arr.stdout) + stderr = data.local_command.example_arr.stderr + exit_code = data.local_command.example_arr.exit_code + } +} diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go new file mode 100644 index 00000000..8ede3e9f --- /dev/null +++ b/internal/provider/data_source_local_command.go @@ -0,0 +1,211 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ datasource.DataSource = (*localCommandDataSource)(nil) +) + +func NewLocalCommandDataSource() datasource.DataSource { + return &localCommandDataSource{} +} + +type localCommandDataSource struct{} + +func (a *localCommandDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_command" +} + +func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "", // TODO: document + Attributes: map[string]schema.Attribute{ + "command": schema.StringAttribute{ + Description: "Executable name to be discovered on the PATH or absolute path to executable.", + Required: true, + }, + "arguments": schema.ListAttribute{ + MarkdownDescription: "Arguments to be passed to the given command. Any `null` arguments will be removed from the list.", + ElementType: types.StringType, + Optional: true, + }, + "stdin": schema.StringAttribute{ + Description: "Data to be passed to the given command's standard input.", + Optional: true, + }, + "working_directory": schema.StringAttribute{ + Description: "The directory where the command should be executed. Defaults to the Terraform working directory.", + Optional: true, + }, + // TODO: naming + "skip_error": schema.BoolAttribute{ + Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) + Optional: true, + }, + "exit_code": schema.Int64Attribute{ + Description: "The exit code returned after executing the given command.", // TODO: Describe it's relationship to diagnostics + Computed: true, + }, + "stdout": schema.StringAttribute{ + Description: "", // TODO: document what users can expect here and how to use it + Computed: true, + }, + "stderr": schema.StringAttribute{ + Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) + Computed: true, + }, + }, + } +} + +type localCommandDataSourceModel struct { + Command types.String `tfsdk:"command"` + Arguments types.List `tfsdk:"arguments"` + Stdin types.String `tfsdk:"stdin"` + WorkingDirectory types.String `tfsdk:"working_directory"` + SkipError types.Bool `tfsdk:"skip_error"` + ExitCode types.Int64 `tfsdk:"exit_code"` + Stdout types.String `tfsdk:"stdout"` + Stderr types.String `tfsdk:"stderr"` +} + +func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state localCommandDataSourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + // Prep the command + command := state.Command.ValueString() + if _, err := exec.LookPath(command); err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Lookup Failed", + "The data source received an unexpected error while attempting to find the command."+ + "\n\n"+ + "The command must be accessible according to the platform where Terraform is running."+ + "\n\n"+ + "If the expected command should be automatically found on the platform where Terraform is running, "+ + "ensure that the command is in an expected directory. On Unix-based platforms, these directories are "+ + "typically searched based on the '$PATH' environment variable. On Windows-based platforms, these directories "+ + "are typically searched based on the '%PATH%' environment variable."+ + "\n\n"+ + "If the expected command is relative to the Terraform configuration, it is recommended that the command name includes "+ + "the interpolated value of 'path.module' before the command name to ensure that it is compatible with varying module usage. For example: \"${path.module}/my-command\""+ + "\n\n"+ + "The command must also be executable according to the platform where Terraform is running. On Unix-based platforms, the file on the filesystem must have the executable bit set. "+ + "On Windows-based platforms, no action is typically necessary."+ + "\n\n"+ + fmt.Sprintf("Platform: %s\n", runtime.GOOS)+ + fmt.Sprintf("Command: %s\n", command)+ + fmt.Sprintf("Error: %s", err), + ) + return + } + + arguments := make([]string, 0) + for _, element := range state.Arguments.Elements() { + strElement, ok := element.(types.String) + // Mirroring the underlying os/exec Command support for args (no nil arguments, but does support empty strings) + if element.IsNull() || !ok { + continue + } + + arguments = append(arguments, strElement.ValueString()) + } + + cmd := exec.CommandContext(ctx, command, arguments...) + + cmd.Dir = state.WorkingDirectory.ValueString() + + if !state.Stdin.IsNull() { + cmd.Stdin = bytes.NewReader([]byte(state.Stdin.ValueString())) + } + + var stderr strings.Builder + cmd.Stderr = &stderr + var stdout strings.Builder + cmd.Stdout = &stdout + + tflog.Trace(ctx, "Executing local command", map[string]interface{}{"command": cmd.String()}) + + // Run the command + err := cmd.Run() + stdoutStr := stdout.String() + stderrStr := stderr.String() + + if len(stderrStr) > 0 { + // TODO: Should we raise an explicit error if this isn't utf8? + // https://pkg.go.dev/unicode/utf8#example-Valid + state.Stderr = types.StringValue(stderrStr) + } + + if len(stdoutStr) > 0 { + // TODO: Should we raise an explicit error if this isn't utf8? + // https://pkg.go.dev/unicode/utf8#example-Valid + state.Stdout = types.StringValue(stdoutStr) + } + + // ProcessState will always be populated if the command has been was successfully started (regardless of exit code) + if cmd.ProcessState != nil { + exitCode := cmd.ProcessState.ExitCode() + state.ExitCode = types.Int64Value(int64(exitCode)) + } + + tflog.Trace(ctx, "Executed local command", map[string]interface{}{"command": cmd.String(), "stdout": stdoutStr, "stderr": stderrStr}) + + // Set all of the data to state + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) + + // If we received an error, we need to check and see if we should explicitly raise a diagnostic (the default behavior) + if err != nil { + if exitError, ok := err.(*exec.ExitError); ok { + // We won't return a diagnostic because the command was successfully started, it just + // exited with a non-zero code (which the user has indicated they will handle in configuration). + // + // All data has already been saved to state, so we just return. + if state.SkipError.ValueBool() { + return + } + + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The data source executed the command but received a non-zero exit code. If this exit code was expected and can be handled in configuration, set \"skip_error\" to true."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.String())+ + fmt.Sprintf("Command Error: %s\n", stderrStr)+ + fmt.Sprintf("State: %s", exitError), + ) + return + } + + // We can't skip this error because the command wasn't successfully started. + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The data source received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.String())+ + fmt.Sprintf("State: %s", err), + ) + return + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 69294a52..cbc656b8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -42,6 +42,7 @@ func (p *localProvider) DataSources(ctx context.Context) []func() datasource.Dat return []func() datasource.DataSource{ NewLocalFileDataSource, NewLocalSensitiveFileDataSource, + NewLocalCommandDataSource, } } From 945e12d56d13e795fbdade108b652885554bd8c9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 4 Nov 2025 17:32:54 -0500 Subject: [PATCH 16/35] comments --- internal/provider/data_source_local_command.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 8ede3e9f..23773ff1 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -34,7 +34,7 @@ func (a *localCommandDataSource) Metadata(ctx context.Context, req datasource.Me func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: "", // TODO: document + MarkdownDescription: "", // TODO: document (mention no side-effects, similar to the caveats on the external data source) Attributes: map[string]schema.Attribute{ "command": schema.StringAttribute{ Description: "Executable name to be discovered on the PATH or absolute path to executable.", @@ -53,7 +53,7 @@ func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.Sche Description: "The directory where the command should be executed. Defaults to the Terraform working directory.", Optional: true, }, - // TODO: naming + // TODO: naming (allow_non_zero_exit_code ?) "skip_error": schema.BoolAttribute{ Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) Optional: true, @@ -174,7 +174,7 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe // Set all of the data to state resp.Diagnostics.Append(resp.State.Set(ctx, state)...) - // If we received an error, we need to check and see if we should explicitly raise a diagnostic (the default behavior) + // If we received an error, we need to check and see if we should explicitly raise a diagnostic if err != nil { if exitError, ok := err.(*exec.ExitError); ok { // We won't return a diagnostic because the command was successfully started, it just @@ -188,7 +188,7 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe resp.Diagnostics.AddAttributeError( path.Root("command"), "Command Execution Failed", - "The data source executed the command but received a non-zero exit code. If this exit code was expected and can be handled in configuration, set \"skip_error\" to true."+ + "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected and can be handled in configuration, set \"skip_error\" to true."+ "\n\n"+ fmt.Sprintf("Command: %s\n", cmd.String())+ fmt.Sprintf("Command Error: %s\n", stderrStr)+ From bcc43344a7575a5f7e40d0c7ea251248a8fed577 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 5 Nov 2025 18:04:53 -0500 Subject: [PATCH 17/35] add stdout tests --- .github/workflows/test.yml | 4 + .../provider/data_source_local_command.go | 25 +- .../data_source_local_command_test.go | 216 ++++++++++++++++++ 3 files changed, 232 insertions(+), 13 deletions(-) create mode 100644 internal/provider/data_source_local_command_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6c4eb42b..f87bf218 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,10 @@ jobs: terraform: ${{ fromJSON(vars.TF_VERSIONS_PROTOCOL_V5) }} steps: + # https://github.com/actions/runner-images/issues/7443 + - name: Install yq (windows only) + if: "matrix.os == 'windows-latest'" + run: choco install yq - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 23773ff1..116fc4ec 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -53,8 +53,7 @@ func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.Sche Description: "The directory where the command should be executed. Defaults to the Terraform working directory.", Optional: true, }, - // TODO: naming (allow_non_zero_exit_code ?) - "skip_error": schema.BoolAttribute{ + "allow_non_zero_exit_code": schema.BoolAttribute{ Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) Optional: true, }, @@ -75,14 +74,14 @@ func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.Sche } type localCommandDataSourceModel struct { - Command types.String `tfsdk:"command"` - Arguments types.List `tfsdk:"arguments"` - Stdin types.String `tfsdk:"stdin"` - WorkingDirectory types.String `tfsdk:"working_directory"` - SkipError types.Bool `tfsdk:"skip_error"` - ExitCode types.Int64 `tfsdk:"exit_code"` - Stdout types.String `tfsdk:"stdout"` - Stderr types.String `tfsdk:"stderr"` + Command types.String `tfsdk:"command"` + Arguments types.List `tfsdk:"arguments"` + Stdin types.String `tfsdk:"stdin"` + WorkingDirectory types.String `tfsdk:"working_directory"` + AllowNonZeroExitCode types.Bool `tfsdk:"allow_non_zero_exit_code"` + ExitCode types.Int64 `tfsdk:"exit_code"` + Stdout types.String `tfsdk:"stdout"` + Stderr types.String `tfsdk:"stderr"` } func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { @@ -181,14 +180,14 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe // exited with a non-zero code (which the user has indicated they will handle in configuration). // // All data has already been saved to state, so we just return. - if state.SkipError.ValueBool() { + if state.AllowNonZeroExitCode.ValueBool() { return } resp.Diagnostics.AddAttributeError( path.Root("command"), "Command Execution Failed", - "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected and can be handled in configuration, set \"skip_error\" to true."+ + "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected and can be handled in configuration, set \"allow_non_zero_exit_code\" to true."+ "\n\n"+ fmt.Sprintf("Command: %s\n", cmd.String())+ fmt.Sprintf("Command Error: %s\n", stderrStr)+ @@ -197,7 +196,7 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe return } - // We can't skip this error because the command wasn't successfully started. + // We need to raise a diagnostic because the command wasn't successfully started and we have no exit code. resp.Diagnostics.AddAttributeError( path.Root("command"), "Command Execution Failed", diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go new file mode 100644 index 00000000..ade933af --- /dev/null +++ b/internal/provider/data_source_local_command_test.go @@ -0,0 +1,216 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package provider + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +// Test is dependent on: https://github.com/jqlang/jq +func TestLocalCommandDataSource_stdout_json(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN and return single JSON object + Config: `data "local_command" "test" { + command = "jq" + stdin = jsonencode([ + { + arr = [1, 2, 3] + bool = true, + num = 1.23 + str = "obj1" + }, + { + arr = [3, 4, 5] + bool = false, + num = 2.34 + str = "obj2" + }, + ]) + arguments = [".[] | select(.str == \"obj1\")"] + } + + output "parse_stdout" { + value = jsondecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "arr": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + }), + "bool": knownvalue.Bool(true), + "num": knownvalue.Float64Exact(1.23), + "str": knownvalue.StringExact("obj1"), + })), + }, + }, + { + // Parses the incoming STDIN and return the first and third elements in a JSON array + Config: `data "local_command" "test" { + command = "jq" + stdin = jsonencode([ + { + obj1_attr = "hello" + }, + { + obj2_attr = "world!" + }, + { + obj3_attr = 1.23 + }, + ]) + arguments = ["[.[0, 2]]"] + } + + output "parse_stdout" { + value = jsondecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.TupleExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj1_attr": knownvalue.StringExact("hello"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj3_attr": knownvalue.Float64Exact(1.23), + }), + })), + }, + }, + }, + }) +} + +// Test is dependent on: https://github.com/jqlang/jq +func TestLocalCommandDataSource_stdout_csv(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN (3 JSON arrays) and return as rows in CSV format + Config: `data "local_command" "test" { + command = "jq" + stdin = "[\"str\",\"num\",\"bool\"][\"hello\", 1.23, true][\"world!\", 2.34, false]" + arguments = ["-r", "@csv"] + } + + output "parse_stdout" { + value = tolist(csvdecode(data.local_command.test.stdout)) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + // MAINTAINER NOTE: csvdecode function treats all attributes as strings + // https://github.com/zclconf/go-cty/blob/da4c600729aefcf628d6b042ee439e6927d1104e/cty/function/stdlib/csv.go#L72-L77 + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ListExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "str": knownvalue.StringExact("hello"), + "num": knownvalue.StringExact("1.23"), + "bool": knownvalue.StringExact("true"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "str": knownvalue.StringExact("world!"), + "num": knownvalue.StringExact("2.34"), + "bool": knownvalue.StringExact("false"), + }), + })), + }, + }, + }, + }) +} + +// Test is dependent on: https://github.com/mikefarah/yq +func TestLocalCommandDataSource_stdout_yaml(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + // Parses the incoming STDIN and return single YAML object + Config: `data "local_command" "test" { + command = "yq" + stdin = yamlencode([ + { + arr = [1, 2, 3] + bool = true, + num = 1.23 + str = "obj1" + }, + { + arr = [3, 4, 5] + bool = false, + num = 2.34 + str = "obj2" + }, + ]) + arguments = [".[] | select(.str == \"obj1\")"] + } + + output "parse_stdout" { + value = yamldecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ObjectExact(map[string]knownvalue.Check{ + "arr": knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(1), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(3), + }), + "bool": knownvalue.Bool(true), + "num": knownvalue.Float64Exact(1.23), + "str": knownvalue.StringExact("obj1"), + })), + }, + }, + { + // Parses the incoming STDIN and return the first and third elements in a YAML array + Config: `data "local_command" "test" { + command = "yq" + stdin = yamlencode([ + { + obj1_attr = "hello" + }, + { + obj2_attr = "world!" + }, + { + obj3_attr = 1.23 + }, + ]) + arguments = ["[.[0, 2]]"] + } + + output "parse_stdout" { + value = yamldecode(data.local_command.test.stdout) + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), + statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.TupleExact([]knownvalue.Check{ + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj1_attr": knownvalue.StringExact("hello"), + }), + knownvalue.ObjectExact(map[string]knownvalue.Check{ + "obj3_attr": knownvalue.Float64Exact(1.23), + }), + })), + }, + }, + }, + }) +} From b9c3f88256508f424006d0bd5ef63964836ff39e Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 11:49:05 -0500 Subject: [PATCH 18/35] refactor --- .../provider/data_source_local_command.go | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 116fc4ec..06d226f5 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -151,14 +151,10 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe stderrStr := stderr.String() if len(stderrStr) > 0 { - // TODO: Should we raise an explicit error if this isn't utf8? - // https://pkg.go.dev/unicode/utf8#example-Valid state.Stderr = types.StringValue(stderrStr) } if len(stdoutStr) > 0 { - // TODO: Should we raise an explicit error if this isn't utf8? - // https://pkg.go.dev/unicode/utf8#example-Valid state.Stdout = types.StringValue(stdoutStr) } @@ -173,38 +169,40 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe // Set all of the data to state resp.Diagnostics.Append(resp.State.Set(ctx, state)...) - // If we received an error, we need to check and see if we should explicitly raise a diagnostic - if err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - // We won't return a diagnostic because the command was successfully started, it just - // exited with a non-zero code (which the user has indicated they will handle in configuration). - // - // All data has already been saved to state, so we just return. - if state.AllowNonZeroExitCode.ValueBool() { - return - } - - resp.Diagnostics.AddAttributeError( - path.Root("command"), - "Command Execution Failed", - "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected and can be handled in configuration, set \"allow_non_zero_exit_code\" to true."+ - "\n\n"+ - fmt.Sprintf("Command: %s\n", cmd.String())+ - fmt.Sprintf("Command Error: %s\n", stderrStr)+ - fmt.Sprintf("State: %s", exitError), - ) + if err == nil { + return + } + + // If running the command returned an error, we need to check and see if we should explicitly raise a diagnostic + if exitError, ok := err.(*exec.ExitError); ok { + // We won't return a diagnostic because the command was successfully started and then exited + // with a non-zero code (which the user has indicated they will handle in configuration). + // + // All data has already been saved to state, so we just return. + if state.AllowNonZeroExitCode.ValueBool() { return } - // We need to raise a diagnostic because the command wasn't successfully started and we have no exit code. resp.Diagnostics.AddAttributeError( path.Root("command"), "Command Execution Failed", - "The data source received an unexpected error while attempting to execute the command."+ + "The data source executed the command but received a non-zero exit code. If a non-zero exit code is expected "+ + "and can be handled in configuration, set \"allow_non_zero_exit_code\" to true."+ "\n\n"+ fmt.Sprintf("Command: %s\n", cmd.String())+ - fmt.Sprintf("State: %s", err), + fmt.Sprintf("Command Error: %s\n", stderrStr)+ + fmt.Sprintf("State: %s", exitError), ) return } + + // We need to raise a diagnostic because the command wasn't successfully started and we have no exit code. + resp.Diagnostics.AddAttributeError( + path.Root("command"), + "Command Execution Failed", + "The data source received an unexpected error while attempting to execute the command."+ + "\n\n"+ + fmt.Sprintf("Command: %s\n", cmd.String())+ + fmt.Sprintf("State: %s", err), + ) } From c7eb0ec7084f00fd3a47a2d44d6e0f2a8089511b Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 11:52:10 -0500 Subject: [PATCH 19/35] refactor + additional tests --- .../provider/data_source_local_command.go | 13 +- .../data_source_local_command_test.go | 167 +++++++++++++++++- 2 files changed, 172 insertions(+), 8 deletions(-) diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 06d226f5..44c62cb8 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -146,7 +146,7 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe tflog.Trace(ctx, "Executing local command", map[string]interface{}{"command": cmd.String()}) // Run the command - err := cmd.Run() + commandErr := cmd.Run() stdoutStr := stdout.String() stderrStr := stderr.String() @@ -168,15 +168,14 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe // Set all of the data to state resp.Diagnostics.Append(resp.State.Set(ctx, state)...) - - if err == nil { + if commandErr == nil { return } - // If running the command returned an error, we need to check and see if we should explicitly raise a diagnostic - if exitError, ok := err.(*exec.ExitError); ok { + // If running the command returned an exit error, we need to check and see if we should explicitly raise a diagnostic + if exitError, ok := commandErr.(*exec.ExitError); ok { // We won't return a diagnostic because the command was successfully started and then exited - // with a non-zero code (which the user has indicated they will handle in configuration). + // with a non-zero exit code (which the user has indicated they will handle in configuration). // // All data has already been saved to state, so we just return. if state.AllowNonZeroExitCode.ValueBool() { @@ -203,6 +202,6 @@ func (a *localCommandDataSource) Read(ctx context.Context, req datasource.ReadRe "The data source received an unexpected error while attempting to execute the command."+ "\n\n"+ fmt.Sprintf("Command: %s\n", cmd.String())+ - fmt.Sprintf("State: %s", err), + fmt.Sprintf("State: %s", commandErr), ) } diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index ade933af..e182d346 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -4,6 +4,7 @@ package provider import ( + "regexp" "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" @@ -113,7 +114,7 @@ func TestLocalCommandDataSource_stdout_csv(t *testing.T) { ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), - // MAINTAINER NOTE: csvdecode function treats all attributes as strings + // MAINTAINER NOTE: csvdecode function converts all attributes as strings // https://github.com/zclconf/go-cty/blob/da4c600729aefcf628d6b042ee439e6927d1104e/cty/function/stdlib/csv.go#L72-L77 statecheck.ExpectKnownOutputValue("parse_stdout", knownvalue.ListExact([]knownvalue.Check{ knownvalue.ObjectExact(map[string]knownvalue.Check{ @@ -214,3 +215,167 @@ func TestLocalCommandDataSource_stdout_yaml(t *testing.T) { }, }) } + +func TestLocalCommandDataSource_stdout_no_format_null_args(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +echo "args: $@" +EOT + } + + data "local_command" "test" { + command = "bash" + stdin = "stdin-string" + arguments = [local_file.test_script.filename, "first-arg", "second-arg"] + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.StringExact("stdin: stdin-string\n")), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact("args: first-arg second-arg\n")), + }, + }, + }, + }) +} + +func TestLocalCommandDataSource_stdout_invalid_string(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +exit 1 +EOT + } + + data "local_command" "test" { + command = "bash" + arguments = [local_file.test_script.filename] + }`, + ExpectError: regexp.MustCompile(`The data source executed the command but received a non-zero exit code.`), + }, + }, + }) +} + +func TestLocalCommandDataSource_allow_non_zero_exit_code(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = <&2 +exit 1 +EOT + } + + data "local_command" "test" { + command = "bash" + allow_non_zero_exit_code = true + arguments = [local_file.test_script.filename] + }`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(1)), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.StringExact("😒")), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact("😏")), + }, + }, + }, + }) +} + +func TestLocalCommandDataSource_not_found(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `data "local_command" "test" { + command = "notarealcommand" + }`, + ExpectError: regexp.MustCompile(`Error: exec: "notarealcommand": executable file not found`), + }, + { + // You shouldn't be able to skip this error, since it never starts the executable + Config: `data "local_command" "test" { + command = "notarealcommand" + allow_non_zero_exit_code = true + }`, + ExpectError: regexp.MustCompile(`Error: exec: "notarealcommand": executable file not found`), + }, + }, + }) +} From 483a428d07fbc086a7f684f89b460c964ffa93c2 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 12:09:11 -0500 Subject: [PATCH 20/35] add another test --- .../data_source_local_command_test.go | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index e182d346..01111a37 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -4,6 +4,8 @@ package provider import ( + "fmt" + "os/exec" "regexp" "testing" @@ -358,6 +360,42 @@ EOT }) } +func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing.T) { + // Create a temporary testing directory to point to / assert with + tempDir := t.TempDir() + + bashAbsPath, err := exec.LookPath("bash") + if err != nil { + t.Fatalf("Failed to find bash executable: %v", err) + } + + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(`resource "local_file" "test_script" { + filename = "%[1]s/test_script.sh" + content = < Date: Thu, 6 Nov 2025 17:23:11 -0500 Subject: [PATCH 21/35] remove comment (not needed) --- internal/provider/action_local_command.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/provider/action_local_command.go b/internal/provider/action_local_command.go index bf374897..033c06b2 100644 --- a/internal/provider/action_local_command.go +++ b/internal/provider/action_local_command.go @@ -35,7 +35,6 @@ func (a *localCommandAction) Metadata(ctx context.Context, req action.MetadataRe func (a *localCommandAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) { resp.Schema = schema.Schema{ - // TODO: Once we have a local_command data source, reference that to be used if the user needs to the consume the output of the command (and it's idempotent) MarkdownDescription: "Invokes an executable on the local machine. All environment variables visible to the Terraform process are passed through " + "to the child process. After the child process successfully executes, the `stdout` will be returned for Terraform to display to the user.\n\n" + "Any non-zero exit code will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available.", From 691c1f14b478a5dfe3bddae9db76508618194c2f Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 17:23:22 -0500 Subject: [PATCH 22/35] update docs on schema --- .../provider/data_source_local_command.go | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 44c62cb8..50e34077 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -34,7 +34,22 @@ func (a *localCommandDataSource) Metadata(ctx context.Context, req datasource.Me func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - MarkdownDescription: "", // TODO: document (mention no side-effects, similar to the caveats on the external data source) + MarkdownDescription: "Runs an executable on the local machine and returns the exit code, standard output data (`stdout`), and standard error data (`stderr`). " + + "All environment variables visible to the Terraform process are passed through to the child process. Both `stdout` and `stderr` returned by this data source " + + "are UTF-8 strings, which can be decoded into [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) for use elsewhere in the Terraform configuration. " + + "There are built-in decoding functions such as [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode) or [`yamldecode`](https://developer.hashicorp.com/terraform/language/functions/yamldecode), " + + "and more specialized [decoding functions](https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts) can be built with a Terraform provider." + + "\n\n" + + "Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available. " + + "If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`." + + "\n\n" + + "~> **Warning** This mechanism is provided as an \"escape hatch\" for exceptional situations where a first-class Terraform provider is not more appropriate. " + + "Its capabilities are limited in comparison to a true data source, and implementing a data source via a local executable is likely to hurt the " + + "portability of your Terraform configuration by creating dependencies on external programs and libraries that may not be available (or may need to be used differently) " + + "on different operating systems." + + "\n\n" + + "~> **Warning** HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, " + + "so it is not recommended to use this data source within configurations that are applied within either.", Attributes: map[string]schema.Attribute{ "command": schema.StringAttribute{ Description: "Executable name to be discovered on the PATH or absolute path to executable.", @@ -46,28 +61,33 @@ func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.Sche Optional: true, }, "stdin": schema.StringAttribute{ - Description: "Data to be passed to the given command's standard input.", - Optional: true, + MarkdownDescription: "Data to be passed to the given command's standard input as a UTF-8 string. [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) can be encoded " + + "by any Terraform encode function, for example, [`jsonencode`](https://developer.hashicorp.com/terraform/language/functions/jsonencode).", + Optional: true, }, "working_directory": schema.StringAttribute{ Description: "The directory where the command should be executed. Defaults to the Terraform working directory.", Optional: true, }, "allow_non_zero_exit_code": schema.BoolAttribute{ - Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) - Optional: true, + MarkdownDescription: "Indicates that the command returning a non-zero exit code should be treated as a successful execution. " + + "Further assertions can be made of the `exit_code` value with the [`check` block](https://developer.hashicorp.com/terraform/language/block/check). Defaults to false.", + Optional: true, }, "exit_code": schema.Int64Attribute{ - Description: "The exit code returned after executing the given command.", // TODO: Describe it's relationship to diagnostics - Computed: true, + MarkdownDescription: "The exit code returned by the command. By default, if the exit code is non-zero, the data source will return a diagnostic to Terraform. " + + "If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`.", + Computed: true, }, "stdout": schema.StringAttribute{ - Description: "", // TODO: document what users can expect here and how to use it - Computed: true, + MarkdownDescription: "Data returned from the command's standard output stream. The data is returned directly from the command as a UTF-8 string, " + + "which can then be decoded by any Terraform decode function, for example, [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode).", + Computed: true, }, "stderr": schema.StringAttribute{ - Description: "", // TODO: document what users can expect here and how to use it (when it will be populated, defaults) - Computed: true, + Description: "Data returned from the command's standard error stream. The data is returned directly from the command as a UTF-8 string and will be " + + "populated regardless of the exit code returned.", + Computed: true, }, }, } From 21dd9c52ff026041bfe1be97ed67d656825c016f Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 17:43:18 -0500 Subject: [PATCH 23/35] add example to docs --- docs/data-sources/command.md | 63 +++++++++++++++++++ .../data-sources/local_command/data-source.tf | 34 ++++------ 2 files changed, 76 insertions(+), 21 deletions(-) create mode 100644 docs/data-sources/command.md diff --git a/docs/data-sources/command.md b/docs/data-sources/command.md new file mode 100644 index 00000000..19cc13cb --- /dev/null +++ b/docs/data-sources/command.md @@ -0,0 +1,63 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "local_command Data Source - terraform-provider-local" +subcategory: "" +description: |- + Runs an executable on the local machine and returns the exit code, standard output data (stdout), and standard error data (stderr). All environment variables visible to the Terraform process are passed through to the child process. Both stdout and stderr returned by this data source are UTF-8 strings, which can be decoded into Terraform values https://developer.hashicorp.com/terraform/language/expressions/types for use elsewhere in the Terraform configuration. There are built-in decoding functions such as jsondecode https://developer.hashicorp.com/terraform/language/functions/jsondecode or yamldecode https://developer.hashicorp.com/terraform/language/functions/yamldecode, and more specialized decoding functions https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts can be built with a Terraform provider. + Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the stderr message if available. If a non-zero exit code is expected by the command, set allow_non_zero_exit_code to true. + ~> Warning This mechanism is provided as an "escape hatch" for exceptional situations where a first-class Terraform provider is not more appropriate. Its capabilities are limited in comparison to a true data source, and implementing a data source via a local executable is likely to hurt the portability of your Terraform configuration by creating dependencies on external programs and libraries that may not be available (or may need to be used differently) on different operating systems. + ~> Warning HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, so it is not recommended to use this data source within configurations that are applied within either. +--- + +# local_command (Data Source) + +Runs an executable on the local machine and returns the exit code, standard output data (`stdout`), and standard error data (`stderr`). All environment variables visible to the Terraform process are passed through to the child process. Both `stdout` and `stderr` returned by this data source are UTF-8 strings, which can be decoded into [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) for use elsewhere in the Terraform configuration. There are built-in decoding functions such as [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode) or [`yamldecode`](https://developer.hashicorp.com/terraform/language/functions/yamldecode), and more specialized [decoding functions](https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts) can be built with a Terraform provider. + +Any non-zero exit code returned by the command will be treated as an error and will return a diagnostic to Terraform containing the `stderr` message if available. If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`. + +~> **Warning** This mechanism is provided as an "escape hatch" for exceptional situations where a first-class Terraform provider is not more appropriate. Its capabilities are limited in comparison to a true data source, and implementing a data source via a local executable is likely to hurt the portability of your Terraform configuration by creating dependencies on external programs and libraries that may not be available (or may need to be used differently) on different operating systems. + +~> **Warning** HCP Terraform and Terraform Enterprise do not guarantee availability of any particular language runtimes or external programs beyond standard shell utilities, so it is not recommended to use this data source within configurations that are applied within either. + +## Example Usage + +```terraform +// A toy example using the JSON utility `jq` to process Terraform data +// https://jqlang.org/ +data "local_command" "filter_fruit" { + command = "jq" + stdin = jsonencode([{ name = "apple" }, { name = "lemon" }, { name = "apricot" }]) + arguments = [".[:2] | [.[].name]"] # Grab the first two fruit names from the list +} + +output "fruit_tf" { + value = jsondecode(data.local_command.filter_fruit.stdout) +} + +# Outputs: +# +# fruit_tf = [ +# "apple", +# "lemon", +# ] +``` + + +## Schema + +### Required + +- `command` (String) Executable name to be discovered on the PATH or absolute path to executable. + +### Optional + +- `allow_non_zero_exit_code` (Boolean) Indicates that the command returning a non-zero exit code should be treated as a successful execution. Further assertions can be made of the `exit_code` value with the [`check` block](https://developer.hashicorp.com/terraform/language/block/check). Defaults to false. +- `arguments` (List of String) Arguments to be passed to the given command. Any `null` arguments will be removed from the list. +- `stdin` (String) Data to be passed to the given command's standard input as a UTF-8 string. [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) can be encoded by any Terraform encode function, for example, [`jsonencode`](https://developer.hashicorp.com/terraform/language/functions/jsonencode). +- `working_directory` (String) The directory where the command should be executed. Defaults to the Terraform working directory. + +### Read-Only + +- `exit_code` (Number) The exit code returned by the command. By default, if the exit code is non-zero, the data source will return a diagnostic to Terraform. If a non-zero exit code is expected by the command, set `allow_non_zero_exit_code` to `true`. +- `stderr` (String) Data returned from the command's standard error stream. The data is returned directly from the command as a UTF-8 string and will be populated regardless of the exit code returned. +- `stdout` (String) Data returned from the command's standard output stream. The data is returned directly from the command as a UTF-8 string, which can then be decoded by any Terraform decode function, for example, [`jsondecode`](https://developer.hashicorp.com/terraform/language/functions/jsondecode). diff --git a/examples/data-sources/local_command/data-source.tf b/examples/data-sources/local_command/data-source.tf index 3f185a97..ea1bff16 100644 --- a/examples/data-sources/local_command/data-source.tf +++ b/examples/data-sources/local_command/data-source.tf @@ -1,26 +1,18 @@ -data "local_command" "example_obj" { +// A toy example using the JSON utility `jq` to process Terraform data +// https://jqlang.org/ +data "local_command" "filter_fruit" { command = "jq" - arguments = ["-n", "{\"foobaz\":\"hello\"}"] + stdin = jsonencode([{ name = "apple" }, { name = "lemon" }, { name = "apricot" }]) + arguments = [".[:2] | [.[].name]"] # Grab the first two fruit names from the list } - -data "local_command" "example_arr" { - command = "jq" - arguments = ["-n", "[{\"foobaz\":\"hello\"}, {\"foobaz\":\"world\"}]"] +output "fruit_tf" { + value = jsondecode(data.local_command.filter_fruit.stdout) } -output "jq_obj" { - value = { - stdout = jsondecode(data.local_command.example_obj.stdout) - stderr = data.local_command.example_obj.stderr - exit_code = data.local_command.example_obj.exit_code - } -} - -output "jq_arr" { - value = { - stdout = jsondecode(data.local_command.example_arr.stdout) - stderr = data.local_command.example_arr.stderr - exit_code = data.local_command.example_arr.exit_code - } -} +# Outputs: +# +# fruit_tf = [ +# "apple", +# "lemon", +# ] From 3c497b990623998a64aa3fdb6a493b5de4ca8893 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 6 Nov 2025 18:02:32 -0500 Subject: [PATCH 24/35] add changelog --- .changes/unreleased/FEATURES-20251106-180154.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20251106-180154.yaml diff --git a/.changes/unreleased/FEATURES-20251106-180154.yaml b/.changes/unreleased/FEATURES-20251106-180154.yaml new file mode 100644 index 00000000..28454155 --- /dev/null +++ b/.changes/unreleased/FEATURES-20251106-180154.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'data/local_command: New data source that runs an executable on the local machine and returns the exit code, standard output data, and standard error data.' +time: 2025-11-06T18:01:54.341138-05:00 +custom: + Issue: "452" From 85affd35b72c3a09fa0f268d97d9e0f248a9a2e5 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 10 Nov 2025 10:30:34 -0500 Subject: [PATCH 25/35] add doc comment --- docs/data-sources/command.md | 2 +- internal/provider/data_source_local_command.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/data-sources/command.md b/docs/data-sources/command.md index 19cc13cb..e03ab101 100644 --- a/docs/data-sources/command.md +++ b/docs/data-sources/command.md @@ -54,7 +54,7 @@ output "fruit_tf" { - `allow_non_zero_exit_code` (Boolean) Indicates that the command returning a non-zero exit code should be treated as a successful execution. Further assertions can be made of the `exit_code` value with the [`check` block](https://developer.hashicorp.com/terraform/language/block/check). Defaults to false. - `arguments` (List of String) Arguments to be passed to the given command. Any `null` arguments will be removed from the list. - `stdin` (String) Data to be passed to the given command's standard input as a UTF-8 string. [Terraform values](https://developer.hashicorp.com/terraform/language/expressions/types) can be encoded by any Terraform encode function, for example, [`jsonencode`](https://developer.hashicorp.com/terraform/language/functions/jsonencode). -- `working_directory` (String) The directory where the command should be executed. Defaults to the Terraform working directory. +- `working_directory` (String) The directory path where the command should be executed, either an absolute path or relative to the Terraform working directory. If not provided, defaults to the Terraform working directory. ### Read-Only diff --git a/internal/provider/data_source_local_command.go b/internal/provider/data_source_local_command.go index 50e34077..5e768adc 100644 --- a/internal/provider/data_source_local_command.go +++ b/internal/provider/data_source_local_command.go @@ -66,7 +66,7 @@ func (a *localCommandDataSource) Schema(ctx context.Context, req datasource.Sche Optional: true, }, "working_directory": schema.StringAttribute{ - Description: "The directory where the command should be executed. Defaults to the Terraform working directory.", + Description: "The directory path where the command should be executed, either an absolute path or relative to the Terraform working directory. If not provided, defaults to the Terraform working directory.", Optional: true, }, "allow_non_zero_exit_code": schema.BoolAttribute{ From 0bb679bb8e06cd3b19767537c2fed6706ce308c6 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 10 Nov 2025 10:31:24 -0500 Subject: [PATCH 26/35] add invalid working directory test --- .../data_source_local_command_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index 01111a37..6cba01fd 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -396,6 +396,30 @@ EOT }) } +func TestLocalCommandDataSource_invalid_working_directory(t *testing.T) { + resource.UnitTest(t, resource.TestCase{ + ProtoV5ProviderFactories: protoV5ProviderFactories(), + Steps: []resource.TestStep{ + { + Config: `resource "local_file" "test_script" { + filename = "${path.module}/test_script.sh" + content = < Date: Thu, 13 Nov 2025 13:35:32 -0500 Subject: [PATCH 27/35] adjust OS specific tests --- internal/provider/data_source_local_command_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index 6cba01fd..2deee77c 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -6,6 +6,7 @@ package provider import ( "fmt" "os/exec" + "path/filepath" "regexp" "testing" @@ -363,6 +364,7 @@ EOT func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing.T) { // Create a temporary testing directory to point to / assert with tempDir := t.TempDir() + testScriptPath := filepath.Join(tempDir, "test_script.sh") bashAbsPath, err := exec.LookPath("bash") if err != nil { @@ -374,7 +376,7 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. Steps: []resource.TestStep{ { Config: fmt.Sprintf(`resource "local_file" "test_script" { - filename = "%[1]s/test_script.sh" + filename = %[1]q content = < Date: Thu, 13 Nov 2025 13:43:23 -0500 Subject: [PATCH 28/35] explicitly use absolute path, since windows is weird lol --- internal/provider/data_source_local_command_test.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index 2deee77c..bd2bd55c 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -364,11 +364,16 @@ EOT func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing.T) { // Create a temporary testing directory to point to / assert with tempDir := t.TempDir() + absTempDirPath, err := filepath.Abs(tempDir) + if err != nil { + t.Fatalf("Failed to build absolute path for temp directory: %s", err) + } + testScriptPath := filepath.Join(tempDir, "test_script.sh") bashAbsPath, err := exec.LookPath("bash") if err != nil { - t.Fatalf("Failed to find bash executable: %v", err) + t.Fatalf("Failed to find bash executable: %s", err) } resource.UnitTest(t, resource.TestCase{ @@ -387,11 +392,11 @@ EOT command = %[3]q working_directory = %[2]q arguments = [local_file.test_script.filename] - }`, testScriptPath, tempDir, bashAbsPath), + }`, testScriptPath, absTempDirPath, bashAbsPath), ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), - statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact(fmt.Sprintf("current working directory: %s", tempDir))), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact(fmt.Sprintf("current working directory: %s", absTempDirPath))), }, }, }, From c5a258e26289e78918dd8c93b555af785573d640 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 13 Nov 2025 13:57:47 -0500 Subject: [PATCH 29/35] reverting --- internal/provider/data_source_local_command_test.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index bd2bd55c..788ec3e7 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -364,11 +364,6 @@ EOT func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing.T) { // Create a temporary testing directory to point to / assert with tempDir := t.TempDir() - absTempDirPath, err := filepath.Abs(tempDir) - if err != nil { - t.Fatalf("Failed to build absolute path for temp directory: %s", err) - } - testScriptPath := filepath.Join(tempDir, "test_script.sh") bashAbsPath, err := exec.LookPath("bash") @@ -392,11 +387,11 @@ EOT command = %[3]q working_directory = %[2]q arguments = [local_file.test_script.filename] - }`, testScriptPath, absTempDirPath, bashAbsPath), + }`, testScriptPath, tempDir, bashAbsPath), ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), - statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact(fmt.Sprintf("current working directory: %s", absTempDirPath))), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringExact(fmt.Sprintf("current working directory: %s", tempDir))), }, }, }, From b75aba568868d619a55e87f0c5d9ee3ab93d6f08 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 13 Nov 2025 15:24:16 -0500 Subject: [PATCH 30/35] Fix TF 0.13 errors --- .../data_source_local_command_test.go | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index 788ec3e7..85b69fa0 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/knownvalue" "github.com/hashicorp/terraform-plugin-testing/statecheck" "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" ) // Test is dependent on: https://github.com/jqlang/jq @@ -221,6 +222,11 @@ func TestLocalCommandDataSource_stdout_yaml(t *testing.T) { func TestLocalCommandDataSource_stdout_no_format_null_args(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -251,6 +257,11 @@ EOT func TestLocalCommandDataSource_stderr_zero_exit_code(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -281,6 +292,11 @@ EOT func TestLocalCommandDataSource_stdout_invalid_string(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -333,6 +349,11 @@ EOT func TestLocalCommandDataSource_allow_non_zero_exit_code(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -372,6 +393,11 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. } resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { From e3f1139fe51b840562235ba35415b6a605fadfb9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 13 Nov 2025 15:29:25 -0500 Subject: [PATCH 31/35] use pwd command --- internal/provider/data_source_local_command_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index 85b69fa0..bb6dc894 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -405,7 +405,8 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. filename = %[1]q content = < Date: Thu, 13 Nov 2025 15:55:05 -0500 Subject: [PATCH 32/35] fix windows CI --- internal/provider/data_source_local_command_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index bb6dc894..df4f7f22 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -387,6 +387,9 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. tempDir := t.TempDir() testScriptPath := filepath.Join(tempDir, "test_script.sh") + startOfTempDir := filepath.Base(filepath.Dir(tempDir)) + relativeTempDir := filepath.Join(startOfTempDir, filepath.Base(tempDir)) + bashAbsPath, err := exec.LookPath("bash") if err != nil { t.Fatalf("Failed to find bash executable: %s", err) @@ -405,8 +408,7 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. filename = %[1]q content = < Date: Thu, 13 Nov 2025 16:02:22 -0500 Subject: [PATCH 33/35] more WSL fixes --- internal/provider/data_source_local_command_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index df4f7f22..b7287aea 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -388,7 +388,8 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. testScriptPath := filepath.Join(tempDir, "test_script.sh") startOfTempDir := filepath.Base(filepath.Dir(tempDir)) - relativeTempDir := filepath.Join(startOfTempDir, filepath.Base(tempDir)) + // Typically, you'd want to use filepath.Join here, but the Windows CI will use bash in WSL, so the test assertion needs to always be Unix format (forward slashes). + tempWdRegex := regexp.MustCompile(fmt.Sprintf("%s/%s", startOfTempDir, filepath.Base(tempDir))) bashAbsPath, err := exec.LookPath("bash") if err != nil { @@ -422,7 +423,7 @@ EOT statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), // MAINTAINER NOTE: We can't compare with the absolute path `tempDir` here because the Windows GHA runner uses bash in WSL, which will give us a new // absolute path and a failing test :). Comparing with `relativeTempDir` is enough to verify that the working_directory was correctly set. - statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringRegexp(regexp.MustCompile(relativeTempDir))), + statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringRegexp(tempWdRegex)), }, }, }, From 93d0089274913983b564f4392564b9c830997b40 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 13 Nov 2025 16:03:50 -0500 Subject: [PATCH 34/35] skip for consistency --- internal/provider/data_source_local_command_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index b7287aea..ed1e015c 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -324,6 +324,11 @@ EOT func TestLocalCommandDataSource_non_zero_exit_code_error(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { @@ -432,6 +437,11 @@ EOT func TestLocalCommandDataSource_invalid_working_directory(t *testing.T) { resource.UnitTest(t, resource.TestCase{ + // Terraform 0.13.x has a bug where data sources are read before dependent managed resources are created, resulting in a "test script not found" error. + // https://github.com/hashicorp/terraform/pull/26284 + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBetween(tfversion.Version0_13_0, tfversion.Version0_14_0), + }, ProtoV5ProviderFactories: protoV5ProviderFactories(), Steps: []resource.TestStep{ { From 68f3a771378590106ea50a7631a7eada24056437 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 13 Nov 2025 16:13:20 -0500 Subject: [PATCH 35/35] reformat comment --- internal/provider/data_source_local_command_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/provider/data_source_local_command_test.go b/internal/provider/data_source_local_command_test.go index ed1e015c..0e00061c 100644 --- a/internal/provider/data_source_local_command_test.go +++ b/internal/provider/data_source_local_command_test.go @@ -393,7 +393,10 @@ func TestLocalCommandDataSource_absolute_path_with_working_directory(t *testing. testScriptPath := filepath.Join(tempDir, "test_script.sh") startOfTempDir := filepath.Base(filepath.Dir(tempDir)) - // Typically, you'd want to use filepath.Join here, but the Windows CI will use bash in WSL, so the test assertion needs to always be Unix format (forward slashes). + // MAINTAINER NOTE: Typically, you'd want to use filepath.Join here, but the Windows GHA runner will use bash in WSL, so the test assertion needs + // to always be Unix format (forward slashes). On top of that, since it uses WSL, we can't assert with the absolute path `tempDir` because WSL + // will give us a new (UNIX formatted) absolute path and a failing test :). Comparing with the last two directory names is enough to verify that + // the working_directory was correctly set. tempWdRegex := regexp.MustCompile(fmt.Sprintf("%s/%s", startOfTempDir, filepath.Base(tempDir))) bashAbsPath, err := exec.LookPath("bash") @@ -426,8 +429,6 @@ EOT ConfigStateChecks: []statecheck.StateCheck{ statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("exit_code"), knownvalue.Int64Exact(0)), statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stderr"), knownvalue.Null()), - // MAINTAINER NOTE: We can't compare with the absolute path `tempDir` here because the Windows GHA runner uses bash in WSL, which will give us a new - // absolute path and a failing test :). Comparing with `relativeTempDir` is enough to verify that the working_directory was correctly set. statecheck.ExpectKnownValue("data.local_command.test", tfjsonpath.New("stdout"), knownvalue.StringRegexp(tempWdRegex)), }, },