diff --git a/go.work b/go.work index 3caffeb..b2bd777 100644 --- a/go.work +++ b/go.work @@ -1,5 +1,3 @@ -go 1.25.0 - -toolchain go1.25.0 +go 1.25.7 use ./packages/engine diff --git a/go.work.sum b/go.work.sum index 8bfbfec..b8056ac 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= cloud.google.com/go/accessapproval v1.8.6 h1:UkmDPCKvj24bkGVrvgJPcgSDkmIPw/bAmOiDb9avOiE= cloud.google.com/go/accessapproval v1.8.6/go.mod h1:FfmTs7Emex5UvfnnpMkhuNkRCP85URnBFt5ClLxhZaQ= cloud.google.com/go/accesscontextmanager v1.9.6 h1:2LnncRqfYB8NEdh9+FeYxAt9POTW/0zVboktnRlO11w= @@ -226,8 +227,6 @@ cloud.google.com/go/websecurityscanner v1.7.6 h1:cIPKJKZA3l7D8DfL4nxce8HGOWXBw3W cloud.google.com/go/websecurityscanner v1.7.6/go.mod h1:ucaaTO5JESFn5f2pjdX01wGbQ8D6h79KHrmO2uGZeiY= cloud.google.com/go/workflows v1.14.2 h1:phBz5TOAES0YGogxZ6Q7ISSudaf618lRhE3euzBpE9U= cloud.google.com/go/workflows v1.14.2/go.mod h1:5nqKjMD+MsJs41sJhdVrETgvD5cOK3hUcAs8ygqYvXQ= -filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= -filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.43.0 h1:fUR05TrF1GyvLDa/mAQjkx7KbgwdLRffs2n9O3WobtE= @@ -238,8 +237,6 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -247,6 +244,7 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/elastic/go-sysinfo v1.15.4 h1:A3zQcunCxik14MgXu39cXFXcIw2sFXZ0zL886eyiv1Q= github.com/elastic/go-sysinfo v1.15.4/go.mod h1:ZBVXmqS368dOn/jvijV/zHLfakWTYHBZPk3G244lHrU= github.com/elastic/go-windows v1.0.2 h1:yoLLsAsV5cfg9FLhZ9EXZ2n2sQFKeDYrHenkcivY4vI= @@ -262,12 +260,9 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= -github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= -github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= -github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -310,6 +305,7 @@ github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= @@ -319,8 +315,6 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6Ng github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= @@ -329,18 +323,12 @@ github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go. github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/vertica/vertica-sql-go v1.3.5 h1:IrfH2WIgzZ45yDHyjVFrXU2LuKNIjF5Nwi90a6cfgUI= github.com/vertica/vertica-sql-go v1.3.5/go.mod h1:jnn2GFuv+O2Jcjktb7zyc4Utlbu9YVqpHH/lx63+1M4= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37 h1:kUXMT/fM/DpDT66WQgRUf3I8VOAWjypkMf52W5PChwA= github.com/ydb-platform/ydb-go-genproto v0.0.0-20260128080146-c4ed16b24b37/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0 h1:OfHS9ZkZgCy6y/CJ9N8123DXrgaY2BPxWsQiQ8e3wC8= github.com/ydb-platform/ydb-go-sdk/v3 v3.127.0/go.mod h1:stS1mQYjbJvwwYaYzKyFY9eMiuVXWWXQA6T+SpOLg9c= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= diff --git a/packages/engine/go.mod b/packages/engine/go.mod index 527f714..50719e8 100644 --- a/packages/engine/go.mod +++ b/packages/engine/go.mod @@ -1,37 +1,44 @@ module github.com/dvflw/mantle -go 1.25.0 +go 1.25.7 require ( cloud.google.com/go/secretmanager v1.16.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 - github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2 v1.41.9 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 + github.com/aws/aws-sdk-go-v2/service/lambda v1.90.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 + github.com/aws/aws-sdk-go-v2/service/sns v1.39.19 + github.com/aws/aws-sdk-go-v2/service/sqs v1.42.29 github.com/coreos/go-oidc/v3 v3.17.0 github.com/docker/docker v28.5.2+incompatible github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/go-git/go-git/v5 v5.18.0 - github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/go-sql-driver/mysql v1.10.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/cel-go v0.27.0 github.com/googleapis/gax-go/v2 v2.15.0 github.com/jackc/pgx/v5 v5.8.0 + github.com/microsoft/go-mssqldb v1.10.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 github.com/rabbitmq/amqp091-go v1.11.0 github.com/redis/go-redis/v9 v9.20.0 + github.com/snowflakedb/gosnowflake v1.19.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.41.0 + github.com/twmb/franz-go v1.21.2 go.mongodb.org/mongo-driver/v2 v2.6.0 golang.org/x/oauth2 v0.35.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.43.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -43,18 +50,27 @@ require ( cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.2 // indirect dario.cat/mergo v1.0.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect - github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + filippo.io/edwards25519 v1.2.0 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/99designs/keyring v1.2.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/BurntSushi/toml v1.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/apache/arrow-go/v18 v18.4.0 // indirect + github.com/apache/thrift v0.22.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect @@ -65,7 +81,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect - github.com/aws/smithy-go v1.24.2 // indirect + github.com/aws/smithy-go v1.26.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -76,16 +92,19 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/danieljoos/wincred v1.2.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/emersion/go-message v0.18.2 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.7 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -93,22 +112,33 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect + github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/snappy v1.0.0 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/asmfmt v1.3.2 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect + github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect @@ -117,14 +147,16 @@ require ( github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect @@ -133,6 +165,7 @@ require ( github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.2 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -142,12 +175,14 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/twmb/franz-go/pkg/kmsg v1.13.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect @@ -158,12 +193,16 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.247.0 // indirect google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect diff --git a/packages/engine/go.sum b/packages/engine/go.sum index e49e3ae..54b239d 100644 --- a/packages/engine/go.sum +++ b/packages/engine/go.sum @@ -1,7 +1,7 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= -cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= +cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= @@ -14,51 +14,71 @@ cloud.google.com/go/secretmanager v1.16.0 h1:19QT7ZsLJ8FSP1k+4esQvuCD7npMJml6hYz cloud.google.com/go/secretmanager v1.16.0/go.mod h1://C/e4I8D26SDTz1f3TQcddhcmiC3rMEl0S1Cakvs3Q= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 h1:u/LLAOFgsMv7HmNL4Qufg58y+qElGOt5qv0z1mURkRY= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0/go.mod h1:2e8rMJtl2+2j+HXbTBwnyGpm5Nou7KhvSfxOq8JpTag= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/apache/arrow-go/v18 v18.4.0 h1:/RvkGqH517iY8bZKc4FD5/kkdwXJGjxf28JIXbJ/oB0= +github.com/apache/arrow-go/v18 v18.4.0/go.mod h1:Aawvwhj8x2jURIzD9Moy72cF0FyJXOpkYpdmGRHcw14= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4= +github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94= github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15 h1:7Zwtt/lP3KNRkeZre7soMELMGNoBrutx8nobg1jKWmo= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.16.15/go.mod h1:436h2adoHb57yd+8W+gYPrrA9U/R/SuAuOO42Ushzhw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= @@ -73,20 +93,26 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/lambda v1.90.3 h1:6W7ioomB49eiBJLHRK4mg9VsqpTJSK/S1iiqoZt+TnM= +github.com/aws/aws-sdk-go-v2/service/lambda v1.90.3/go.mod h1:LSQ3Y3dkCd0hDGFtb00WUXeMzSOZLGCidlmhwo0DDxo= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4 h1:9aZbO86sraeCIHHCpZhxwN9tnVy9POkSKzi4/TpT54A= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.4/go.mod h1:cxiXDhEzIq7Xx1BtmC4lGBK3SwAZ79+EUWiKawYHo14= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.19 h1:FFhX5wY9zHX1IzSsqHlcd9TZgejkF5+F/SpvWZcdS+k= +github.com/aws/aws-sdk-go-v2/service/sns v1.39.19/go.mod h1:1L0Y96eKbF+uIfA/m6JagGDBprXP8Bzz7fUjjmVCI7A= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.29 h1:h2++NjhgbB7YSPQhmkddQL7XN8FDDz8FDCCty3NcONQ= +github.com/aws/aws-sdk-go-v2/service/sqs v1.42.29/go.mod h1:p3HFjSHb7ZV/1sJuoecjatg5X83iTbH0tf1AiTRIGR4= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s= +github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -120,9 +146,12 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= +github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= @@ -133,6 +162,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= +github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -156,6 +187,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= +github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= @@ -175,16 +208,30 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= +github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= @@ -198,6 +245,8 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -214,10 +263,12 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -239,6 +290,12 @@ github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5L github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/microsoft/go-mssqldb v1.10.0 h1:pHEt+Qz6YFPWqREq10mqSE524QQo+/QremwTCQht7TY= +github.com/microsoft/go-mssqldb v1.10.0/go.mod h1:mnG7lGa9iYJbzJqGCXyuQCegStKMr3kogDLD6+bmggg= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -257,10 +314,13 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -269,6 +329,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -277,8 +339,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= @@ -308,11 +371,15 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/snowflakedb/gosnowflake v1.19.1 h1:NZMErtdZMu6kooehbONNQmu/W5BPsaX8hYdlBBEHgxs= +github.com/snowflakedb/gosnowflake v1.19.1/go.mod h1:9vGW6LYbUD1UqfjpuNN5a5vtha+u4n1AlsR1BqhHwPA= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -345,6 +412,10 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twmb/franz-go v1.21.2 h1:WrvV/spF48JzcRylqDQy02Vm6V6W4lhtD9Y4BOYNMu4= +github.com/twmb/franz-go v1.21.2/go.mod h1:rfoMTnVk7107fhTGxfEKIHP/e7tPe6oyij/ywzO0czk= +github.com/twmb/franz-go/pkg/kmsg v1.13.1 h1:fG5kItwysTk5UXqVwb64EpQEy3TydF3vYYK21nUQ+bI= +github.com/twmb/franz-go/pkg/kmsg v1.13.1/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= 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/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= @@ -353,11 +424,15 @@ github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8= @@ -397,19 +472,21 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= 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-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -431,13 +508,15 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -445,16 +524,20 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= @@ -471,6 +554,7 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/packages/engine/internal/connector/aws.go b/packages/engine/internal/connector/aws.go new file mode 100644 index 0000000..c2c288a --- /dev/null +++ b/packages/engine/internal/connector/aws.go @@ -0,0 +1,220 @@ +package connector + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/aws/aws-sdk-go-v2/service/sns" + "github.com/aws/aws-sdk-go-v2/service/sqs" +) + +// extractAWSCredential pulls access_key_id, secret_access_key, and region +// from the _credential param. Both map[string]string and map[string]any shapes +// are supported. Deletes _credential from params. +func extractAWSCredential(params map[string]any) (accessKeyID, secretAccessKey, region string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", "", fmt.Errorf("credential is required") + } + + accessKeyID = cred["access_key_id"] + secretAccessKey = cred["secret_access_key"] + region = cred["region"] + + if accessKeyID == "" { + return "", "", "", fmt.Errorf("credential must contain an 'access_key_id' field") + } + if secretAccessKey == "" { + return "", "", "", fmt.Errorf("credential must contain a 'secret_access_key' field") + } + if region == "" { + return "", "", "", fmt.Errorf("credential must contain a 'region' field") + } + return accessKeyID, secretAccessKey, region, nil +} + +// newAWSConfig builds an aws.Config with static credentials and region. +func newAWSConfig(ctx context.Context, accessKeyID, secretAccessKey, region string) (aws.Config, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""), + ), + awsconfig.WithRegion(region), + ) + if err != nil { + return aws.Config{}, fmt.Errorf("loading AWS config: %w", err) + } + return cfg, nil +} + +// AWSInvokeLambdaConnector invokes an AWS Lambda function. +// Params: function_name (required), payload (optional JSON string). +// Output: {"status_code": N, "payload": "...", "function_error": "..."} +type AWSInvokeLambdaConnector struct{} + +func (c *AWSInvokeLambdaConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + functionName, _ := params["function_name"].(string) + if functionName == "" { + return nil, fmt.Errorf("aws/invoke_lambda: function_name is required") + } + + payload, _ := params["payload"].(string) + + accessKeyID, secretAccessKey, region, err := extractAWSCredential(params) + if err != nil { + return nil, fmt.Errorf("aws/invoke_lambda: %w", err) + } + + cfg, err := newAWSConfig(ctx, accessKeyID, secretAccessKey, region) + if err != nil { + return nil, fmt.Errorf("aws/invoke_lambda: %w", err) + } + + client := lambda.NewFromConfig(cfg) + input := &lambda.InvokeInput{ + FunctionName: aws.String(functionName), + } + if payload != "" { + input.Payload = []byte(payload) + } + + resp, err := client.Invoke(ctx, input) + if err != nil { + return nil, fmt.Errorf("aws/invoke_lambda: %w", err) + } + + result := map[string]any{ + "status_code": int(resp.StatusCode), + "payload": string(resp.Payload), + } + if resp.FunctionError != nil { + result["function_error"] = *resp.FunctionError + } else { + result["function_error"] = nil + } + return result, nil +} + +// AWSSendSQSConnector sends a message to an SQS queue. +// Params: queue_url (required), message_body (required), +// +// delay_seconds (optional int32), message_group_id (optional). +// +// Output: {"message_id": "..."} +type AWSSendSQSConnector struct{} + +func (c *AWSSendSQSConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + queueURL, _ := params["queue_url"].(string) + if queueURL == "" { + return nil, fmt.Errorf("aws/send_sqs: queue_url is required") + } + + messageBody, _ := params["message_body"].(string) + if messageBody == "" { + return nil, fmt.Errorf("aws/send_sqs: message_body is required") + } + + accessKeyID, secretAccessKey, region, err := extractAWSCredential(params) + if err != nil { + return nil, fmt.Errorf("aws/send_sqs: %w", err) + } + + cfg, err := newAWSConfig(ctx, accessKeyID, secretAccessKey, region) + if err != nil { + return nil, fmt.Errorf("aws/send_sqs: %w", err) + } + + input := &sqs.SendMessageInput{ + QueueUrl: aws.String(queueURL), + MessageBody: aws.String(messageBody), + } + + if ds, ok := extractInt(params["delay_seconds"]); ok && ds > 0 { + input.DelaySeconds = int32(ds) // #nosec G115 -- delay_seconds valid range 0-900, safe narrowing + } + if mgid, _ := params["message_group_id"].(string); mgid != "" { + input.MessageGroupId = aws.String(mgid) + } + + client := sqs.NewFromConfig(cfg) + result, err := client.SendMessage(ctx, input) + if err != nil { + return nil, fmt.Errorf("aws/send_sqs: %w", err) + } + + return map[string]any{ + "message_id": aws.ToString(result.MessageId), + }, nil +} + +// AWSPublishSNSConnector publishes a message to an SNS topic. +// Params: topic_arn (required), message (required), +// +// subject (optional), message_structure (optional). +// +// Output: {"message_id": "..."} +type AWSPublishSNSConnector struct{} + +func (c *AWSPublishSNSConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + topicARN, _ := params["topic_arn"].(string) + if topicARN == "" { + return nil, fmt.Errorf("aws/publish_sns: topic_arn is required") + } + + message, _ := params["message"].(string) + if message == "" { + return nil, fmt.Errorf("aws/publish_sns: message is required") + } + + accessKeyID, secretAccessKey, region, err := extractAWSCredential(params) + if err != nil { + return nil, fmt.Errorf("aws/publish_sns: %w", err) + } + + cfg, err := newAWSConfig(ctx, accessKeyID, secretAccessKey, region) + if err != nil { + return nil, fmt.Errorf("aws/publish_sns: %w", err) + } + + input := &sns.PublishInput{ + TopicArn: aws.String(topicARN), + Message: aws.String(message), + } + + if subject, _ := params["subject"].(string); subject != "" { + input.Subject = aws.String(subject) + } + if ms, _ := params["message_structure"].(string); ms != "" { + input.MessageStructure = aws.String(ms) + } + + client := sns.NewFromConfig(cfg) + result, err := client.Publish(ctx, input) + if err != nil { + return nil, fmt.Errorf("aws/publish_sns: %w", err) + } + + return map[string]any{ + "message_id": aws.ToString(result.MessageId), + }, nil +} diff --git a/packages/engine/internal/connector/aws_test.go b/packages/engine/internal/connector/aws_test.go new file mode 100644 index 0000000..495a42a --- /dev/null +++ b/packages/engine/internal/connector/aws_test.go @@ -0,0 +1,174 @@ +package connector + +import ( + "testing" +) + +// --- AWSInvokeLambdaConnector --- + +func TestAWSInvokeLambdaConnector_MissingFunctionName(t *testing.T) { + c := &AWSInvokeLambdaConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing function_name") + } +} + +func TestAWSInvokeLambdaConnector_MissingCredential(t *testing.T) { + c := &AWSInvokeLambdaConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "function_name": "my-function", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAWSInvokeLambdaConnector_MissingAccessKeyID(t *testing.T) { + c := &AWSInvokeLambdaConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "function_name": "my-function", + "_credential": map[string]string{ + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing access_key_id") + } +} + +func TestAWSInvokeLambdaConnector_MissingSecretKey(t *testing.T) { + c := &AWSInvokeLambdaConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "function_name": "my-function", + "_credential": map[string]string{ + "access_key_id": "AKID", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing secret_access_key") + } +} + +func TestAWSInvokeLambdaConnector_MissingRegion(t *testing.T) { + c := &AWSInvokeLambdaConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "function_name": "my-function", + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + }, + }) + if err == nil { + t.Fatal("expected error for missing region") + } +} + +// --- AWSSendSQSConnector --- + +func TestAWSSendSQSConnector_MissingQueueURL(t *testing.T) { + c := &AWSSendSQSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "message_body": "hello", + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing queue_url") + } +} + +func TestAWSSendSQSConnector_MissingMessageBody(t *testing.T) { + c := &AWSSendSQSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "queue_url": "https://sqs.us-east-1.amazonaws.com/123/myqueue", + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing message_body") + } +} + +func TestAWSSendSQSConnector_MissingCredential(t *testing.T) { + c := &AWSSendSQSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "queue_url": "https://sqs.us-east-1.amazonaws.com/123/myqueue", + "message_body": "hello", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +// --- AWSPublishSNSConnector --- + +func TestAWSPublishSNSConnector_MissingTopicARN(t *testing.T) { + c := &AWSPublishSNSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "message": "hello", + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing topic_arn") + } +} + +func TestAWSPublishSNSConnector_MissingMessage(t *testing.T) { + c := &AWSPublishSNSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic_arn": "arn:aws:sns:us-east-1:123:mytopic", + "_credential": map[string]string{ + "access_key_id": "AKID", + "secret_access_key": "SECRET", + "region": "us-east-1", + }, + }) + if err == nil { + t.Fatal("expected error for missing message") + } +} + +func TestAWSPublishSNSConnector_MissingCredential(t *testing.T) { + c := &AWSPublishSNSConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic_arn": "arn:aws:sns:us-east-1:123:mytopic", + "message": "hello", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +// --- Registry --- + +func TestRegistry_AWSConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "aws/invoke_lambda", + "aws/send_sqs", + "aws/publish_sns", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/azureblob.go b/packages/engine/internal/connector/azureblob.go new file mode 100644 index 0000000..352b629 --- /dev/null +++ b/packages/engine/internal/connector/azureblob.go @@ -0,0 +1,252 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// extractAzureBlobCredential extracts Azure Blob Storage credentials from params. +// Credential shape: {account, container, sas_token} +func extractAzureBlobCredential(params map[string]any) (account, container, sasToken string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", "", fmt.Errorf("credential is required") + } + + account = cred["account"] + if account == "" { + return "", "", "", fmt.Errorf("credential must contain an 'account' field") + } + container = cred["container"] + if container == "" { + return "", "", "", fmt.Errorf("credential must contain a 'container' field") + } + sasToken = cred["sas_token"] + return account, container, sasToken, nil +} + +// extractAzureFunctionCredential extracts Azure Function credentials from params. +// Credential shape: {function_url, function_key (optional)} +func extractAzureFunctionCredential(params map[string]any) (functionURL, functionKey string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", fmt.Errorf("credential is required") + } + + functionURL = cred["function_url"] + if functionURL == "" { + return "", "", fmt.Errorf("credential must contain a 'function_url' field") + } + functionKey = cred["function_key"] + return functionURL, functionKey, nil +} + +// AzureBlobUploadConnector uploads a blob to Azure Blob Storage. +type AzureBlobUploadConnector struct { + Client *http.Client +} + +func (c *AzureBlobUploadConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + account, container, sasToken, err := extractAzureBlobCredential(params) + if err != nil { + return nil, fmt.Errorf("azure/blob_upload: %w", err) + } + + blobName, _ := params["blob_name"].(string) + if blobName == "" { + return nil, fmt.Errorf("azure/blob_upload: blob_name is required") + } + content, _ := params["content"].(string) + if content == "" { + return nil, fmt.Errorf("azure/blob_upload: content is required") + } + + contentType := "application/octet-stream" + if ct, ok := params["content_type"].(string); ok && ct != "" { + contentType = ct + } + + endpoint := fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s", account, container, blobName) + if sasToken != "" { + endpoint += "?" + sasToken + } + + req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader([]byte(content))) + if err != nil { + return nil, fmt.Errorf("azure/blob_upload: creating request: %w", err) + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("x-ms-blob-type", "BlockBlob") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("azure/blob_upload: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("azure/blob_upload: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("azure/blob_upload: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return map[string]any{ + "ok": true, + "blob_name": blobName, + }, nil +} + +// AzureBlobDownloadConnector downloads a blob from Azure Blob Storage. +type AzureBlobDownloadConnector struct { + Client *http.Client +} + +func (c *AzureBlobDownloadConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + account, container, sasToken, err := extractAzureBlobCredential(params) + if err != nil { + return nil, fmt.Errorf("azure/blob_download: %w", err) + } + + blobName, _ := params["blob_name"].(string) + if blobName == "" { + return nil, fmt.Errorf("azure/blob_download: blob_name is required") + } + + endpoint := fmt.Sprintf("https://%s.blob.core.windows.net/%s/%s", account, container, blobName) + if sasToken != "" { + endpoint += "?" + sasToken + } + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("azure/blob_download: creating request: %w", err) + } + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("azure/blob_download: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("azure/blob_download: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("azure/blob_download: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return map[string]any{ + "content": string(body), + "content_type": resp.Header.Get("Content-Type"), + }, nil +} + +// AzureInvokeFunctionConnector invokes an Azure Function. +type AzureInvokeFunctionConnector struct { + Client *http.Client +} + +func (c *AzureInvokeFunctionConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + functionURL, functionKey, err := extractAzureFunctionCredential(params) + if err != nil { + return nil, fmt.Errorf("azure/invoke_function: %w", err) + } + + method := "POST" + if m, ok := params["method"].(string); ok && m != "" { + method = strings.ToUpper(m) + } + + endpoint := functionURL + if functionKey != "" { + if strings.Contains(endpoint, "?") { + endpoint += "&code=" + url.QueryEscape(functionKey) + } else { + endpoint += "?code=" + url.QueryEscape(functionKey) + } + } + + var bodyReader io.Reader + if bodyStr, ok := params["body"].(string); ok && bodyStr != "" { + bodyReader = strings.NewReader(bodyStr) + } + + req, err := http.NewRequestWithContext(ctx, method, endpoint, bodyReader) + if err != nil { + return nil, fmt.Errorf("azure/invoke_function: creating request: %w", err) + } + + if ct, ok := params["content_type"].(string); ok && ct != "" { + req.Header.Set("Content-Type", ct) + } else if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("azure/invoke_function: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("azure/invoke_function: reading response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("azure/invoke_function: request failed with status %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") && len(body) > 0 { + var result map[string]any + if jsonErr := json.Unmarshal(body, &result); jsonErr == nil { + return result, nil + } + } + + return map[string]any{ + "body": string(body), + "status": resp.StatusCode, + }, nil +} diff --git a/packages/engine/internal/connector/azureblob_test.go b/packages/engine/internal/connector/azureblob_test.go new file mode 100644 index 0000000..8a3b3ad --- /dev/null +++ b/packages/engine/internal/connector/azureblob_test.go @@ -0,0 +1,209 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// blobRedirectTransport redirects all requests to a test server by replacing +// the scheme+host while preserving path and query. +type blobRedirectTransport struct { + targetScheme string + targetHost string +} + +func newBlobRedirectTransport(serverURL string) *blobRedirectTransport { + u, _ := url.Parse(serverURL) + return &blobRedirectTransport{ + targetScheme: u.Scheme, + targetHost: u.Host, + } +} + +func (rt *blobRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cloned := req.Clone(req.Context()) + cloned.URL.Scheme = rt.targetScheme + cloned.URL.Host = rt.targetHost + cloned.Host = rt.targetHost + return http.DefaultTransport.RoundTrip(cloned) +} + +func TestAzureBlobUploadConnector_UploadsBlob(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + t.Errorf("expected PUT, got %s", r.Method) + } + if r.Header.Get("x-ms-blob-type") != "BlockBlob" { + t.Errorf("expected x-ms-blob-type=BlockBlob, got %s", r.Header.Get("x-ms-blob-type")) + } + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := &AzureBlobUploadConnector{ + Client: &http.Client{Transport: newBlobRedirectTransport(srv.URL)}, + } + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "account": "storageacct", + "container": "mycontainer", + "sas_token": "sv=2020-01-01&sig=abc", + }, + "blob_name": "hello.txt", + "content": "Hello Mantle!", + }) + if err != nil { + t.Fatal(err) + } + if out["ok"] != true { + t.Errorf("expected ok=true, got %v", out["ok"]) + } + if out["blob_name"] != "hello.txt" { + t.Errorf("expected blob_name=hello.txt, got %v", out["blob_name"]) + } +} + +func TestAzureBlobUploadConnector_MissingBlobName(t *testing.T) { + c := &AzureBlobUploadConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "account": "storageacct", + "container": "mycontainer", + }, + "content": "data", + }) + if err == nil { + t.Fatal("expected error for missing blob_name") + } +} + +func TestAzureBlobUploadConnector_MissingCredential(t *testing.T) { + c := &AzureBlobUploadConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "blob_name": "hello.txt", + "content": "data", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAzureBlobUploadConnector_MissingAccount(t *testing.T) { + c := &AzureBlobUploadConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "container": "mycontainer", + }, + "blob_name": "hello.txt", + "content": "data", + }) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestAzureBlobDownloadConnector_DownloadsBlob(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("blob content here")) + })) + defer srv.Close() + + c := &AzureBlobDownloadConnector{ + Client: &http.Client{Transport: newBlobRedirectTransport(srv.URL)}, + } + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "account": "storageacct", + "container": "mycontainer", + "sas_token": "sv=2020-01-01&sig=abc", + }, + "blob_name": "hello.txt", + }) + if err != nil { + t.Fatal(err) + } + if out["content"] != "blob content here" { + t.Errorf("expected content='blob content here', got %v", out["content"]) + } + if out["content_type"] != "text/plain" { + t.Errorf("expected content_type=text/plain, got %v", out["content_type"]) + } +} + +func TestAzureBlobDownloadConnector_MissingCredential(t *testing.T) { + c := &AzureBlobDownloadConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "blob_name": "hello.txt", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAzureInvokeFunctionConnector_Invokes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Query().Get("code") != "funckey123" { + t.Errorf("expected code=funckey123, got %s", r.URL.Query().Get("code")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"status": "invoked"}) + })) + defer srv.Close() + + c := &AzureInvokeFunctionConnector{} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "function_url": srv.URL + "/api/MyFunc", + "function_key": "funckey123", + }, + "body": `{"input":"data"}`, + }) + if err != nil { + t.Fatal(err) + } + if out["status"] != "invoked" { + t.Errorf("expected status=invoked, got %v", out["status"]) + } +} + +func TestAzureInvokeFunctionConnector_MissingCredential(t *testing.T) { + c := &AzureInvokeFunctionConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "body": `{}`, + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestAzureInvokeFunctionConnector_MissingFunctionURL(t *testing.T) { + c := &AzureInvokeFunctionConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "function_key": "key", + }, + }) + if err == nil { + t.Fatal("expected error for missing function_url") + } +} + +func TestRegistry_AzureConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"azure/blob_upload", "azure/blob_download", "azure/invoke_function"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/bigquery.go b/packages/engine/internal/connector/bigquery.go new file mode 100644 index 0000000..09fc0dc --- /dev/null +++ b/packages/engine/internal/connector/bigquery.go @@ -0,0 +1,412 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const bigQueryBaseURL = "https://bigquery.googleapis.com/bigquery/v2/projects" + +// extractBigQueryCredential extracts BigQuery credentials from params. +// Credential shape: {project_id, token} +func extractBigQueryCredential(params map[string]any) (projectID, token string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", fmt.Errorf("credential is required") + } + + projectID = cred["project_id"] + if projectID == "" { + return "", "", fmt.Errorf("credential must contain a 'project_id' field") + } + token = cred["token"] + if token == "" { + return "", "", fmt.Errorf("credential must contain a 'token' field") + } + return projectID, token, nil +} + +// bqField describes a BigQuery schema field. +type bqField struct { + Name string `json:"name"` + Type string `json:"type"` + Mode string `json:"mode"` + Fields []bqField `json:"fields"` +} + +// bqSchema wraps the fields list returned in a BigQuery query response. +type bqSchema struct { + Fields []bqField `json:"fields"` +} + +// bqRow is a BigQuery row: an ordered list of raw cell values. +type bqRow struct { + F []json.RawMessage `json:"f"` +} + +// bqJobRef identifies a BigQuery job. +type bqJobRef struct { + ProjectID string `json:"projectId"` + JobID string `json:"jobId"` +} + +// bqQueryResp is the common shape for jobs.query and jobs.getQueryResults responses. +type bqQueryResp struct { + JobComplete bool `json:"jobComplete"` + JobReference bqJobRef `json:"jobReference"` + Schema bqSchema `json:"schema"` + Rows []bqRow `json:"rows"` + TotalRows string `json:"totalRows"` + PageToken string `json:"pageToken"` +} + +// BigQueryQueryConnector executes a SQL query on BigQuery. +type BigQueryQueryConnector struct { + Client *http.Client + baseURL string + pollInterval time.Duration +} + +func (c *BigQueryQueryConnector) getBaseURL() string { + if c.baseURL != "" { + return c.baseURL + } + return bigQueryBaseURL +} + +func (c *BigQueryQueryConnector) getPollInterval() time.Duration { + if c.pollInterval > 0 { + return c.pollInterval + } + return 500 * time.Millisecond +} + +func (c *BigQueryQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + projectID, authToken, err := extractBigQueryCredential(params) + if err != nil { + return nil, fmt.Errorf("bigquery/query: %w", err) + } + + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("bigquery/query: query is required") + } + + maxRows := 1000 + if m, ok := extractInt(params["max_rows"]); ok && m > 0 { + maxRows = m + } + + timeoutMs := 30000 + if tm, ok := extractInt(params["timeout_ms"]); ok && tm > 0 { + timeoutMs = tm + } + + useLegacySQL := false + if ul, ok := params["use_legacy_sql"].(bool); ok { + useLegacySQL = ul + } + + payload, err := json.Marshal(map[string]any{ + "query": query, + "timeoutMs": timeoutMs, + "useLegacySql": useLegacySQL, + "maxResults": maxRows, + }) + if err != nil { + return nil, fmt.Errorf("bigquery/query: marshaling body: %w", err) + } + + endpoint := fmt.Sprintf("%s/%s/queries", c.getBaseURL(), projectID) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("bigquery/query: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+authToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("bigquery/query: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("bigquery/query: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bigquery/query: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + var page bqQueryResp + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("bigquery/query: parsing response: %w", err) + } + + // Poll until the job completes; the caller's context deadline is the overall timeout. + for !page.JobComplete { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("bigquery/query: %w", ctx.Err()) + case <-time.After(c.getPollInterval()): + } + next, err := c.getQueryResults(ctx, authToken, page.JobReference.ProjectID, page.JobReference.JobID, "", maxRows, timeoutMs) + if err != nil { + return nil, fmt.Errorf("bigquery/query: polling job: %w", err) + } + page = *next + } + + // Paginate through any remaining pages. + for page.PageToken != "" && len(page.Rows) < maxRows { + next, err := c.getQueryResults(ctx, authToken, page.JobReference.ProjectID, page.JobReference.JobID, page.PageToken, maxRows-len(page.Rows), timeoutMs) + if err != nil { + return nil, fmt.Errorf("bigquery/query: fetching page: %w", err) + } + page.Rows = append(page.Rows, next.Rows...) + page.PageToken = next.PageToken + } + + if len(page.Rows) > maxRows { + page.Rows = page.Rows[:maxRows] + } + + rows, err := bqParseRows(page.Schema.Fields, page.Rows) + if err != nil { + return nil, fmt.Errorf("bigquery/query: parsing rows: %w", err) + } + + totalRows := len(rows) + if tr, ok := extractInt(parseIntString(page.TotalRows)); ok { + totalRows = tr + } + + return map[string]any{ + "rows": rows, + "schema": page.Schema, + "total_rows": totalRows, + }, nil +} + +// getQueryResults fetches a page of results for an already-submitted BigQuery job. +func (c *BigQueryQueryConnector) getQueryResults(ctx context.Context, authToken, projectID, jobID, pageToken string, maxResults, timeoutMs int) (*bqQueryResp, error) { + u := fmt.Sprintf("%s/%s/queries/%s?maxResults=%d&timeoutMs=%d", c.getBaseURL(), projectID, jobID, maxResults, timeoutMs) + if pageToken != "" { + u += "&pageToken=" + url.QueryEscape(pageToken) + } + + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+authToken) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + var page bqQueryResp + if err := json.Unmarshal(body, &page); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + return &page, nil +} + +// bqParseRows converts BigQuery rows into named maps using schema field metadata. +// NULL values become nil, RECORD types become nested maps, REPEATED fields become slices. +func bqParseRows(fields []bqField, rows []bqRow) ([]map[string]any, error) { + result := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + m, err := bqParseRowMap(fields, row.F) + if err != nil { + return nil, err + } + result = append(result, m) + } + return result, nil +} + +// bqParseRowMap zips schema fields with raw cell values into a named map. +func bqParseRowMap(fields []bqField, cells []json.RawMessage) (map[string]any, error) { + m := make(map[string]any, len(fields)) + for i, field := range fields { + if i >= len(cells) { + m[field.Name] = nil + continue + } + v, err := bqParseValue(field, cells[i]) + if err != nil { + return nil, fmt.Errorf("field %s: %w", field.Name, err) + } + m[field.Name] = v + } + return m, nil +} + +// bqParseValue parses a single BigQuery cell value: {"v": }. +// NULL → nil, RECORD → map, REPEATED → slice, scalars → string. +func bqParseValue(field bqField, raw json.RawMessage) (any, error) { + var cell struct { + V json.RawMessage `json:"v"` + } + if err := json.Unmarshal(raw, &cell); err != nil { + return nil, err + } + if len(cell.V) == 0 || string(cell.V) == "null" { + return nil, nil + } + + if field.Mode == "REPEATED" { + var items []json.RawMessage + if err := json.Unmarshal(cell.V, &items); err != nil { + return nil, fmt.Errorf("parsing repeated field: %w", err) + } + elemField := field + elemField.Mode = "NULLABLE" + result := make([]any, 0, len(items)) + for _, item := range items { + v, err := bqParseValue(elemField, item) + if err != nil { + return nil, err + } + result = append(result, v) + } + return result, nil + } + + if field.Type == "RECORD" || field.Type == "STRUCT" { + var subRow bqRow + if err := json.Unmarshal(cell.V, &subRow); err != nil { + return nil, fmt.Errorf("parsing record field: %w", err) + } + return bqParseRowMap(field.Fields, subRow.F) + } + + // Scalar values come as JSON strings from the BigQuery REST API. + var s string + if err := json.Unmarshal(cell.V, &s); err != nil { + var v any + if err2 := json.Unmarshal(cell.V, &v); err2 != nil { + return nil, err + } + return v, nil + } + return s, nil +} + +// parseIntString converts a string integer to int for use with extractInt. +func parseIntString(s string) any { + if s == "" { + return nil + } + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return nil + } + return n +} + +// BigQueryInsertRowsConnector inserts rows into a BigQuery table via streaming insertAll. +type BigQueryInsertRowsConnector struct { + Client *http.Client + baseURL string +} + +func (c *BigQueryInsertRowsConnector) getBaseURL() string { + if c.baseURL != "" { + return c.baseURL + } + return bigQueryBaseURL +} + +func (c *BigQueryInsertRowsConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + projectID, token, err := extractBigQueryCredential(params) + if err != nil { + return nil, fmt.Errorf("bigquery/insert_rows: %w", err) + } + + datasetID, _ := params["dataset_id"].(string) + if datasetID == "" { + return nil, fmt.Errorf("bigquery/insert_rows: dataset_id is required") + } + tableID, _ := params["table_id"].(string) + if tableID == "" { + return nil, fmt.Errorf("bigquery/insert_rows: table_id is required") + } + rows, ok := params["rows"].([]any) + if !ok || rows == nil { + return nil, fmt.Errorf("bigquery/insert_rows: rows is required") + } + + // Wrap each row in {"json": row}. + insertRows := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + insertRows = append(insertRows, map[string]any{"json": row}) + } + + payload, err := json.Marshal(map[string]any{ + "rows": insertRows, + }) + if err != nil { + return nil, fmt.Errorf("bigquery/insert_rows: marshaling body: %w", err) + } + + endpoint := fmt.Sprintf("%s/%s/datasets/%s/tables/%s/insertAll", + c.getBaseURL(), projectID, datasetID, tableID) + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("bigquery/insert_rows: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("bigquery/insert_rows: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("bigquery/insert_rows: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bigquery/insert_rows: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "bigquery/insert_rows") +} diff --git a/packages/engine/internal/connector/bigquery_test.go b/packages/engine/internal/connector/bigquery_test.go new file mode 100644 index 0000000..25b9dd9 --- /dev/null +++ b/packages/engine/internal/connector/bigquery_test.go @@ -0,0 +1,469 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +func bqTestCredential(projectID string) map[string]string { + return map[string]string{ + "project_id": projectID, + "token": "bq-token", + } +} + +func TestBigQueryQueryConnector_ExecutesQuery(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer bq-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + if body["query"] != "SELECT 1" { + t.Errorf("expected query='SELECT 1', got %v", body["query"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{ + "projectId": "my-project", + "jobId": "job-1", + }, + "schema": map[string]any{ + "fields": []any{ + map[string]any{"name": "col1", "type": "STRING", "mode": "NULLABLE"}, + }, + }, + "rows": []any{ + map[string]any{"f": []any{map[string]any{"v": "value1"}}}, + map[string]any{"f": []any{map[string]any{"v": "value2"}}}, + }, + "totalRows": "2", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + "query": "SELECT 1", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 2 { + t.Errorf("expected 2 rows, got %d", len(rows)) + } + if rows[0]["col1"] != "value1" { + t.Errorf("expected rows[0][col1]=value1, got %v", rows[0]["col1"]) + } + if out["schema"] == nil { + t.Error("expected schema in response") + } + if out["total_rows"].(int) != 2 { + t.Errorf("expected total_rows=2, got %v", out["total_rows"]) + } +} + +func TestBigQueryQueryConnector_NullValues(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "j"}, + "schema": map[string]any{ + "fields": []any{ + map[string]any{"name": "a", "type": "STRING", "mode": "NULLABLE"}, + map[string]any{"name": "b", "type": "INTEGER", "mode": "NULLABLE"}, + }, + }, + "rows": []any{ + map[string]any{"f": []any{map[string]any{"v": "hello"}, map[string]any{"v": nil}}}, + }, + "totalRows": "1", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("p"), + "query": "SELECT a, b FROM t", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0]["a"] != "hello" { + t.Errorf("expected a=hello, got %v", rows[0]["a"]) + } + if rows[0]["b"] != nil { + t.Errorf("expected b=nil (NULL), got %v", rows[0]["b"]) + } +} + +func TestBigQueryQueryConnector_RepeatedField(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "j"}, + "schema": map[string]any{ + "fields": []any{ + map[string]any{"name": "tags", "type": "STRING", "mode": "REPEATED"}, + }, + }, + "rows": []any{ + map[string]any{ + "f": []any{ + map[string]any{ + "v": []any{ + map[string]any{"v": "go"}, + map[string]any{"v": "sql"}, + }, + }, + }, + }, + }, + "totalRows": "1", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("p"), + "query": "SELECT tags FROM t", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + tags, _ := rows[0]["tags"].([]any) + if len(tags) != 2 { + t.Errorf("expected 2 tags, got %v", tags) + } + if tags[0] != "go" || tags[1] != "sql" { + t.Errorf("unexpected tag values: %v", tags) + } +} + +func TestBigQueryQueryConnector_RecordType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "j"}, + "schema": map[string]any{ + "fields": []any{ + map[string]any{ + "name": "addr", "type": "RECORD", "mode": "NULLABLE", + "fields": []any{ + map[string]any{"name": "city", "type": "STRING", "mode": "NULLABLE"}, + map[string]any{"name": "zip", "type": "STRING", "mode": "NULLABLE"}, + }, + }, + }, + }, + "rows": []any{ + map[string]any{ + "f": []any{ + map[string]any{ + "v": map[string]any{ + "f": []any{ + map[string]any{"v": "Portland"}, + map[string]any{"v": "97201"}, + }, + }, + }, + }, + }, + }, + "totalRows": "1", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("p"), + "query": "SELECT addr FROM t", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + addr, _ := rows[0]["addr"].(map[string]any) + if addr["city"] != "Portland" || addr["zip"] != "97201" { + t.Errorf("unexpected addr: %v", addr) + } +} + +func TestBigQueryQueryConnector_Pagination(t *testing.T) { + var callCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + n := callCount.Add(1) + + fields := []any{ + map[string]any{"name": "val", "type": "STRING", "mode": "NULLABLE"}, + } + + if r.Method == "POST" { + // Initial query — return first page with pageToken + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "job-1"}, + "schema": map[string]any{"fields": fields}, + "rows": []any{ + map[string]any{"f": []any{map[string]any{"v": "row1"}}}, + }, + "totalRows": "2", + "pageToken": "page2", + }) + return + } + + // GET getQueryResults — second page + if pageToken := r.URL.Query().Get("pageToken"); pageToken != "page2" { + t.Errorf("call %d: unexpected pageToken %q", n, pageToken) + } + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "job-1"}, + "schema": map[string]any{"fields": fields}, + "rows": []any{ + map[string]any{"f": []any{map[string]any{"v": "row2"}}}, + }, + "totalRows": "2", + "pageToken": "", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("p"), + "query": "SELECT val FROM t", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 2 { + t.Errorf("expected 2 rows after pagination, got %d", len(rows)) + } + if rows[0]["val"] != "row1" || rows[1]["val"] != "row2" { + t.Errorf("unexpected row values: %v", rows) + } +} + +func TestBigQueryQueryConnector_JobNotComplete(t *testing.T) { + var callCount atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + n := callCount.Add(1) + + fields := []any{ + map[string]any{"name": "x", "type": "STRING", "mode": "NULLABLE"}, + } + + if r.Method == "POST" { + // Initial query — job not done yet + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": false, + "jobReference": map[string]any{"projectId": "p", "jobId": "job-async"}, + }) + return + } + + // Polling calls + if n < 3 { + // Still not done on second call + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": false, + "jobReference": map[string]any{"projectId": "p", "jobId": "job-async"}, + }) + return + } + + // Done on third call + json.NewEncoder(w).Encode(map[string]any{ + "jobComplete": true, + "jobReference": map[string]any{"projectId": "p", "jobId": "job-async"}, + "schema": map[string]any{"fields": fields}, + "rows": []any{ + map[string]any{"f": []any{map[string]any{"v": "done"}}}, + }, + "totalRows": "1", + }) + })) + defer srv.Close() + + c := &BigQueryQueryConnector{ + baseURL: srv.URL, + pollInterval: time.Millisecond, + } + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("p"), + "query": "SELECT x FROM slow_table", + }) + if err != nil { + t.Fatal(err) + } + rows, _ := out["rows"].([]map[string]any) + if len(rows) != 1 || rows[0]["x"] != "done" { + t.Errorf("unexpected rows: %v", rows) + } +} + +func TestBigQueryQueryConnector_MissingCredential(t *testing.T) { + c := &BigQueryQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestBigQueryQueryConnector_MissingProjectID(t *testing.T) { + c := &BigQueryQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "token": "bq-token", + }, + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing project_id") + } +} + +func TestBigQueryQueryConnector_MissingQuery(t *testing.T) { + c := &BigQueryQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestBigQueryInsertRowsConnector_InsertsRows(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer bq-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + rows, _ := body["rows"].([]any) + if len(rows) != 2 { + t.Errorf("expected 2 rows in request, got %d", len(rows)) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "kind": "bigquery#tableDataInsertAllResponse", + "insertErrors": nil, + }) + })) + defer srv.Close() + + c := &BigQueryInsertRowsConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + "dataset_id": "my_dataset", + "table_id": "my_table", + "rows": []any{ + map[string]any{"col1": "val1", "col2": "val2"}, + map[string]any{"col1": "val3", "col2": "val4"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if out["kind"] != "bigquery#tableDataInsertAllResponse" { + t.Errorf("unexpected response kind: %v", out["kind"]) + } +} + +func TestBigQueryInsertRowsConnector_MissingDatasetID(t *testing.T) { + c := &BigQueryInsertRowsConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + "table_id": "my_table", + "rows": []any{}, + }) + if err == nil { + t.Fatal("expected error for missing dataset_id") + } +} + +func TestBigQueryInsertRowsConnector_MissingTableID(t *testing.T) { + c := &BigQueryInsertRowsConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + "dataset_id": "my_dataset", + "rows": []any{}, + }) + if err == nil { + t.Fatal("expected error for missing table_id") + } +} + +func TestBigQueryInsertRowsConnector_MissingRows(t *testing.T) { + c := &BigQueryInsertRowsConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": bqTestCredential("my-project"), + "dataset_id": "my_dataset", + "table_id": "my_table", + }) + if err == nil { + t.Fatal("expected error for missing rows") + } +} + +func TestBigQueryInsertRowsConnector_MissingCredential(t *testing.T) { + c := &BigQueryInsertRowsConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "dataset_id": "my_dataset", + "table_id": "my_table", + "rows": []any{}, + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_BigQueryConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"bigquery/query", "bigquery/insert_rows"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 0a98ca7..a907d0a 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -42,6 +42,7 @@ func NewRegistry() *Registry { r.Register("browser/run", &BrowserRunConnector{}) r.Register("github/create_issue", &GitHubCreateIssueConnector{}) r.Register("github/dispatch", &GitHubDispatchConnector{}) + r.Register("github/dispatch_workflow", &GitHubDispatchWorkflowConnector{}) r.Register("linear/create_issue", &LinearCreateIssueConnector{}) r.Register("linear/search", &LinearSearchConnector{}) r.Register("notion/create_page", &NotionCreatePageConnector{}) @@ -81,6 +82,44 @@ func NewRegistry() *Registry { r.Register("shopify/create_order", &ShopifyCreateOrderConnector{}) r.Register("mailchimp/list_members", &MailchimpListMembersConnector{}) r.Register("mailchimp/add_member", &MailchimpAddMemberConnector{}) + r.Register("entra/list_users", &EntraListUsersConnector{}) + r.Register("entra/create_user", &EntraCreateUserConnector{}) + r.Register("entra/add_group_member", &EntraAddGroupMemberConnector{}) + r.Register("teams/send_message", &TeamsSendMessageConnector{}) + r.Register("teams/send_adaptive_card", &TeamsSendAdaptiveCardConnector{}) + r.Register("hubspot/create_contact", &HubSpotCreateContactConnector{}) + r.Register("hubspot/search_contacts", &HubSpotSearchContactsConnector{}) + r.Register("jira/create_issue", &JiraCreateIssueConnector{}) + r.Register("jira/search_issues", &JiraSearchIssuesConnector{}) + r.Register("salesforce/query", &SalesforceQueryConnector{}) + r.Register("salesforce/create_record", &SalesforceCreateRecordConnector{}) + r.Register("drive/upload", &GoogleDriveUploadConnector{}) + r.Register("drive/list_files", &GoogleDriveListFilesConnector{}) + r.Register("sheets/read_range", &GoogleSheetsReadRangeConnector{}) + r.Register("sheets/append_rows", &GoogleSheetsAppendRowsConnector{}) + r.Register("gcp/publish", &GCPPubSubPublishConnector{}) + r.Register("gcp/invoke_cloud_run", &GCPInvokeCloudRunConnector{}) + r.Register("azure/blob_upload", &AzureBlobUploadConnector{}) + r.Register("azure/blob_download", &AzureBlobDownloadConnector{}) + r.Register("azure/invoke_function", &AzureInvokeFunctionConnector{}) + r.Register("databricks/execute_sql", &DatabricksExecuteSQLConnector{}) + r.Register("databricks/submit_job", &DatabricksSubmitJobConnector{}) + r.Register("bigquery/query", &BigQueryQueryConnector{}) + r.Register("bigquery/insert_rows", &BigQueryInsertRowsConnector{}) + r.Register("aws/invoke_lambda", &AWSInvokeLambdaConnector{}) + r.Register("aws/send_sqs", &AWSSendSQSConnector{}) + r.Register("aws/publish_sns", &AWSPublishSNSConnector{}) + r.Register("kafka/produce", &KafkaProduceConnector{}) + r.Register("kafka/consume", &KafkaConsumeConnector{}) + r.Register("k8s/apply", &K8sApplyConnector{}) + r.Register("k8s/create_job", &K8sCreateJobConnector{}) + r.Register("k8s/get_pod_status", &K8sGetPodStatusConnector{}) + r.Register("snowflake/query", &SnowflakeQueryConnector{}) + r.Register("mysql/query", &MySQLQueryConnector{}) + r.Register("mysql/execute", &MySQLExecuteConnector{}) + r.Register("mssql/query", &MSSQLQueryConnector{}) + r.Register("mssql/execute", &MSSQLExecuteConnector{}) + r.Register("redshift/query", &RedshiftQueryConnector{}) return r } diff --git a/packages/engine/internal/connector/databricks.go b/packages/engine/internal/connector/databricks.go new file mode 100644 index 0000000..00df9d8 --- /dev/null +++ b/packages/engine/internal/connector/databricks.go @@ -0,0 +1,168 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// extractDatabricksCredential extracts Databricks credentials from params. +// Credential shape: {host, token} +func extractDatabricksCredential(params map[string]any) (host, token string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", fmt.Errorf("credential is required") + } + + host = cred["host"] + if host == "" { + return "", "", fmt.Errorf("credential must contain a 'host' field") + } + token = cred["token"] + if token == "" { + return "", "", fmt.Errorf("credential must contain a 'token' field") + } + return host, token, nil +} + +// DatabricksExecuteSQLConnector executes a SQL statement on a Databricks SQL warehouse. +type DatabricksExecuteSQLConnector struct { + Client *http.Client +} + +func (c *DatabricksExecuteSQLConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + host, token, err := extractDatabricksCredential(params) + if err != nil { + return nil, fmt.Errorf("databricks/execute_sql: %w", err) + } + + warehouseID, _ := params["warehouse_id"].(string) + if warehouseID == "" { + return nil, fmt.Errorf("databricks/execute_sql: warehouse_id is required") + } + statement, _ := params["statement"].(string) + if statement == "" { + return nil, fmt.Errorf("databricks/execute_sql: statement is required") + } + + timeoutSeconds := 30 + if ts, ok := extractInt(params["timeout_seconds"]); ok && ts > 0 { + timeoutSeconds = ts + } + + bodyMap := map[string]any{ + "warehouse_id": warehouseID, + "statement": statement, + "wait_timeout": fmt.Sprintf("%ds", timeoutSeconds), + } + if catalog, ok := params["catalog"].(string); ok && catalog != "" { + bodyMap["catalog"] = catalog + } + if schema, ok := params["schema"].(string); ok && schema != "" { + bodyMap["schema"] = schema + } + + payload, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("databricks/execute_sql: marshaling body: %w", err) + } + + endpoint := host + "/api/2.0/sql/statements" + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("databricks/execute_sql: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("databricks/execute_sql: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("databricks/execute_sql: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("databricks/execute_sql: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "databricks/execute_sql") +} + +// DatabricksSubmitJobConnector submits a one-time job run on Databricks. +type DatabricksSubmitJobConnector struct { + Client *http.Client +} + +func (c *DatabricksSubmitJobConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + host, token, err := extractDatabricksCredential(params) + if err != nil { + return nil, fmt.Errorf("databricks/submit_job: %w", err) + } + + tasks, ok := params["tasks"].([]any) + if !ok || tasks == nil { + return nil, fmt.Errorf("databricks/submit_job: tasks is required") + } + + bodyMap := map[string]any{ + "tasks": tasks, + } + if runName, ok := params["run_name"].(string); ok && runName != "" { + bodyMap["run_name"] = runName + } + if ts, ok := extractInt(params["timeout_seconds"]); ok && ts > 0 { + bodyMap["timeout_seconds"] = ts + } + + payload, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("databricks/submit_job: marshaling body: %w", err) + } + + endpoint := host + "/api/2.1/jobs/runs/submit" + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("databricks/submit_job: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("databricks/submit_job: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("databricks/submit_job: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("databricks/submit_job: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "databricks/submit_job") +} diff --git a/packages/engine/internal/connector/databricks_test.go b/packages/engine/internal/connector/databricks_test.go new file mode 100644 index 0000000..b0a1dd9 --- /dev/null +++ b/packages/engine/internal/connector/databricks_test.go @@ -0,0 +1,189 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestDatabricksExecuteSQLConnector_ExecutesSQL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/2.0/sql/statements" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer dapi-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + if body["warehouse_id"] != "wh-123" { + t.Errorf("expected warehouse_id=wh-123, got %v", body["warehouse_id"]) + } + if body["statement"] != "SELECT 1" { + t.Errorf("expected statement='SELECT 1', got %v", body["statement"]) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "statement_id": "stmt-1", + "status": map[string]any{"state": "SUCCEEDED"}, + "result": map[string]any{"data_array": []any{}}, + }) + })) + defer srv.Close() + + c := &DatabricksExecuteSQLConnector{} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": srv.URL, + "token": "dapi-token", + }, + "warehouse_id": "wh-123", + "statement": "SELECT 1", + }) + if err != nil { + t.Fatal(err) + } + if out["statement_id"] != "stmt-1" { + t.Errorf("expected statement_id=stmt-1, got %v", out["statement_id"]) + } +} + +func TestDatabricksExecuteSQLConnector_MissingWarehouseID(t *testing.T) { + c := &DatabricksExecuteSQLConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "https://adb-1234.azuredatabricks.net", + "token": "dapi-token", + }, + "statement": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing warehouse_id") + } +} + +func TestDatabricksExecuteSQLConnector_MissingStatement(t *testing.T) { + c := &DatabricksExecuteSQLConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "https://adb-1234.azuredatabricks.net", + "token": "dapi-token", + }, + "warehouse_id": "wh-123", + }) + if err == nil { + t.Fatal("expected error for missing statement") + } +} + +func TestDatabricksExecuteSQLConnector_MissingCredential(t *testing.T) { + c := &DatabricksExecuteSQLConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "warehouse_id": "wh-123", + "statement": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestDatabricksExecuteSQLConnector_MissingHost(t *testing.T) { + c := &DatabricksExecuteSQLConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "token": "dapi-token", + }, + "warehouse_id": "wh-123", + "statement": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing host") + } +} + +func TestDatabricksSubmitJobConnector_SubmitsJob(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/2.1/jobs/runs/submit" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer dapi-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "run_id": float64(42), + }) + })) + defer srv.Close() + + c := &DatabricksSubmitJobConnector{} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": srv.URL, + "token": "dapi-token", + }, + "run_name": "my-run", + "tasks": []any{ + map[string]any{ + "task_key": "task1", + "existing_cluster_id": "cluster-123", + "notebook_task": map[string]any{ + "notebook_path": "/Users/me/notebook", + }, + }, + }, + }) + if err != nil { + t.Fatal(err) + } + runID, ok := extractInt(out["run_id"]) + if !ok || runID != 42 { + t.Errorf("expected run_id=42, got %v", out["run_id"]) + } +} + +func TestDatabricksSubmitJobConnector_MissingTasks(t *testing.T) { + c := &DatabricksSubmitJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "https://adb-1234.azuredatabricks.net", + "token": "dapi-token", + }, + "run_name": "my-run", + }) + if err == nil { + t.Fatal("expected error for missing tasks") + } +} + +func TestDatabricksSubmitJobConnector_MissingCredential(t *testing.T) { + c := &DatabricksSubmitJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "tasks": []any{}, + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_DatabricksConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"databricks/execute_sql", "databricks/submit_job"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/entra.go b/packages/engine/internal/connector/entra.go new file mode 100644 index 0000000..478ccda --- /dev/null +++ b/packages/engine/internal/connector/entra.go @@ -0,0 +1,222 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const entraBaseURL = "https://graph.microsoft.com/v1.0" + +// EntraListUsersConnector lists users from Microsoft Entra ID (Azure AD). +type EntraListUsersConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *EntraListUsersConnector) apiURL(path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return entraBaseURL + path +} + +func (c *EntraListUsersConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("entra/list_users: %w", err) + } + + query := url.Values{} + query.Set("$select", "id,displayName,mail,userPrincipalName,accountEnabled") + if filter, ok := params["filter"].(string); ok && filter != "" { + query.Set("$filter", filter) + } + if top, ok := extractInt(params["top"]); ok && top > 0 { + query.Set("$top", fmt.Sprintf("%d", top)) + } + + endpoint := c.apiURL("/users?" + query.Encode()) + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("entra/list_users: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("entra/list_users: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("entra/list_users: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("entra/list_users: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + var result struct { + Value []any `json:"value"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("entra/list_users: parsing response: %w", err) + } + + return map[string]any{ + "users": result.Value, + "count": len(result.Value), + }, nil +} + +// EntraCreateUserConnector creates a user in Microsoft Entra ID. +type EntraCreateUserConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *EntraCreateUserConnector) apiURL(path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return entraBaseURL + path +} + +func (c *EntraCreateUserConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("entra/create_user: %w", err) + } + + displayName, _ := params["display_name"].(string) + if displayName == "" { + return nil, fmt.Errorf("entra/create_user: display_name is required") + } + upn, _ := params["user_principal_name"].(string) + if upn == "" { + return nil, fmt.Errorf("entra/create_user: user_principal_name is required") + } + password, _ := params["password"].(string) + if password == "" { + return nil, fmt.Errorf("entra/create_user: password is required") + } + + mailNickname, _ := params["mail_nickname"].(string) + if mailNickname == "" { + // Default to the part of UPN before @ + if idx := strings.Index(upn, "@"); idx > 0 { + mailNickname = upn[:idx] + } else { + mailNickname = upn + } + } + + bodyMap := map[string]any{ + "displayName": displayName, + "userPrincipalName": upn, + "mailNickname": mailNickname, + "accountEnabled": true, + "passwordProfile": map[string]any{ + "password": password, + "forceChangePasswordNextSignIn": false, + }, + } + + reqJSON, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("entra/create_user: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/users"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("entra/create_user: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("entra/create_user: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("entra/create_user: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("entra/create_user: API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + return parseJSONBody(respBody, "entra/create_user") +} + +// EntraAddGroupMemberConnector adds a user to an Entra ID (Azure AD) group. +type EntraAddGroupMemberConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *EntraAddGroupMemberConnector) apiURL(path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return entraBaseURL + path +} + +func (c *EntraAddGroupMemberConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("entra/add_group_member: %w", err) + } + + groupID, _ := params["group_id"].(string) + if groupID == "" { + return nil, fmt.Errorf("entra/add_group_member: group_id is required") + } + userID, _ := params["user_id"].(string) + if userID == "" { + return nil, fmt.Errorf("entra/add_group_member: user_id is required") + } + + // The @odata.id value always points to the production Graph endpoint. + refBody := map[string]any{ + "@odata.id": "https://graph.microsoft.com/v1.0/directoryObjects/" + userID, + } + reqJSON, err := json.Marshal(refBody) + if err != nil { + return nil, fmt.Errorf("entra/add_group_member: marshaling request: %w", err) + } + + path := fmt.Sprintf("/groups/%s/members/$ref", groupID) + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("entra/add_group_member: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("entra/add_group_member: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("entra/add_group_member: reading response: %w", err) + } + if resp.StatusCode != http.StatusNoContent { + return nil, fmt.Errorf("entra/add_group_member: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return map[string]any{"ok": true}, nil +} diff --git a/packages/engine/internal/connector/entra_test.go b/packages/engine/internal/connector/entra_test.go new file mode 100644 index 0000000..70a868b --- /dev/null +++ b/packages/engine/internal/connector/entra_test.go @@ -0,0 +1,156 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestEntraListUsersConnector_ListsUsers(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer entra-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "value": []any{ + map[string]any{"id": "u1", "displayName": "Alice"}, + map[string]any{"id": "u2", "displayName": "Bob"}, + }, + }) + })) + defer srv.Close() + + c := &EntraListUsersConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "entra-token"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } + users, ok := out["users"].([]any) + if !ok || len(users) != 2 { + t.Errorf("expected 2 users, got %v", out["users"]) + } +} + +func TestEntraListUsersConnector_MissingCredential(t *testing.T) { + c := &EntraListUsersConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestEntraCreateUserConnector_CreatesUser(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "user-123", + "displayName": "Alice Smith", + "userPrincipalName": "alice@example.com", + }) + })) + defer srv.Close() + + c := &EntraCreateUserConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "entra-token"}, + "display_name": "Alice Smith", + "user_principal_name": "alice@example.com", + "password": "P@ssw0rd!", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotBody["displayName"] != "Alice Smith" { + t.Errorf("displayName = %v, want Alice Smith", gotBody["displayName"]) + } + if gotBody["userPrincipalName"] != "alice@example.com" { + t.Errorf("userPrincipalName = %v", gotBody["userPrincipalName"]) + } + // mailNickname should default to part before @ + if gotBody["mailNickname"] != "alice" { + t.Errorf("mailNickname = %v, want alice", gotBody["mailNickname"]) + } + if out["id"] != "user-123" { + t.Errorf("id = %v, want user-123", out["id"]) + } +} + +func TestEntraCreateUserConnector_MissingDisplayName(t *testing.T) { + c := &EntraCreateUserConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "user_principal_name": "alice@example.com", + "password": "P@ssw0rd!", + }) + if err == nil { + t.Fatal("expected error for missing display_name") + } +} + +func TestEntraAddGroupMemberConnector_AddsMember(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := &EntraAddGroupMemberConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "entra-token"}, + "group_id": "group-abc", + "user_id": "user-123", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["ok"] != true { + t.Errorf("ok = %v, want true", out["ok"]) + } + if gotBody["@odata.id"] != "https://graph.microsoft.com/v1.0/directoryObjects/user-123" { + t.Errorf("@odata.id = %v", gotBody["@odata.id"]) + } +} + +func TestEntraAddGroupMemberConnector_MissingGroupID(t *testing.T) { + c := &EntraAddGroupMemberConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "user_id": "user-123", + }) + if err == nil { + t.Fatal("expected error for missing group_id") + } +} + +func TestRegistry_EntraConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"entra/list_users", "entra/create_user", "entra/add_group_member"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/gcpconnector.go b/packages/engine/internal/connector/gcpconnector.go new file mode 100644 index 0000000..f9170f1 --- /dev/null +++ b/packages/engine/internal/connector/gcpconnector.go @@ -0,0 +1,158 @@ +package connector + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +const gcpPubSubBaseURL = "https://pubsub.googleapis.com/v1" + +// GCPPubSubPublishConnector publishes a message to a Google Cloud Pub/Sub topic. +type GCPPubSubPublishConnector struct { + Client *http.Client + baseURL string +} + +func (c *GCPPubSubPublishConnector) getBaseURL() string { + if c.baseURL != "" { + return c.baseURL + } + return gcpPubSubBaseURL +} + +func (c *GCPPubSubPublishConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("gcp/publish: %w", err) + } + + projectID, _ := params["project_id"].(string) + if projectID == "" { + return nil, fmt.Errorf("gcp/publish: project_id is required") + } + topicID, _ := params["topic_id"].(string) + if topicID == "" { + return nil, fmt.Errorf("gcp/publish: topic_id is required") + } + message, _ := params["message"].(string) + if message == "" { + return nil, fmt.Errorf("gcp/publish: message is required") + } + + attributes, _ := params["attributes"].(map[string]any) + + encoded := base64.StdEncoding.EncodeToString([]byte(message)) + msg := map[string]any{ + "data": encoded, + } + if len(attributes) > 0 { + msg["attributes"] = attributes + } + + payload, err := json.Marshal(map[string]any{ + "messages": []any{msg}, + }) + if err != nil { + return nil, fmt.Errorf("gcp/publish: marshaling body: %w", err) + } + + endpoint := fmt.Sprintf("%s/projects/%s/topics/%s:publish", + c.getBaseURL(), projectID, topicID) + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("gcp/publish: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("gcp/publish: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("gcp/publish: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gcp/publish: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "gcp/publish") +} + +// GCPInvokeCloudRunConnector makes an authenticated HTTP request to a Cloud Run service. +type GCPInvokeCloudRunConnector struct { + Client *http.Client +} + +func (c *GCPInvokeCloudRunConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("gcp/invoke_cloud_run: %w", err) + } + + serviceURL, _ := params["url"].(string) + if serviceURL == "" { + return nil, fmt.Errorf("gcp/invoke_cloud_run: url is required") + } + + method := "POST" + if m, ok := params["method"].(string); ok && m != "" { + method = strings.ToUpper(m) + } + + var bodyReader io.Reader + if bodyStr, ok := params["body"].(string); ok && bodyStr != "" { + bodyReader = strings.NewReader(bodyStr) + } + + req, err := http.NewRequestWithContext(ctx, method, serviceURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("gcp/invoke_cloud_run: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + if headers, ok := params["headers"].(map[string]any); ok { + for k, v := range headers { + if s, ok := v.(string); ok { + req.Header.Set(k, s) + } + } + } + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("gcp/invoke_cloud_run: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("gcp/invoke_cloud_run: reading response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("gcp/invoke_cloud_run: request failed with status %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + contentType := resp.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") && len(body) > 0 { + result, parseErr := parseJSONBody(body, "gcp/invoke_cloud_run") + if parseErr == nil { + return result, nil + } + } + + return map[string]any{ + "body": string(body), + "status": resp.StatusCode, + }, nil +} diff --git a/packages/engine/internal/connector/gcpconnector_test.go b/packages/engine/internal/connector/gcpconnector_test.go new file mode 100644 index 0000000..02eda0e --- /dev/null +++ b/packages/engine/internal/connector/gcpconnector_test.go @@ -0,0 +1,140 @@ +package connector + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGCPPubSubPublishConnector_Publishes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer gcp-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding body: %v", err) + } + msgs, _ := body["messages"].([]any) + if len(msgs) == 0 { + t.Error("expected at least one message") + } else { + msg, _ := msgs[0].(map[string]any) + data, _ := msg["data"].(string) + decoded, _ := base64.StdEncoding.DecodeString(data) + if string(decoded) != "hello pubsub" { + t.Errorf("expected decoded data='hello pubsub', got %s", string(decoded)) + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "messageIds": []string{"1"}, + }) + })) + defer srv.Close() + + c := &GCPPubSubPublishConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "gcp-token"}, + "project_id": "my-project", + "topic_id": "my-topic", + "message": "hello pubsub", + }) + if err != nil { + t.Fatal(err) + } + ids, _ := out["messageIds"].([]any) + if len(ids) == 0 { + t.Error("expected messageIds in response") + } +} + +func TestGCPPubSubPublishConnector_MissingProjectID(t *testing.T) { + c := &GCPPubSubPublishConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "topic_id": "my-topic", + "message": "hello", + }) + if err == nil { + t.Fatal("expected error for missing project_id") + } +} + +func TestGCPPubSubPublishConnector_MissingMessage(t *testing.T) { + c := &GCPPubSubPublishConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "project_id": "my-project", + "topic_id": "my-topic", + }) + if err == nil { + t.Fatal("expected error for missing message") + } +} + +func TestGCPPubSubPublishConnector_MissingCredential(t *testing.T) { + c := &GCPPubSubPublishConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "project_id": "my-project", + "topic_id": "my-topic", + "message": "hello", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestGCPInvokeCloudRunConnector_Invokes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Errorf("expected Bearer auth, got %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "result": "ok", + }) + })) + defer srv.Close() + + c := &GCPInvokeCloudRunConnector{} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "run-token"}, + "url": srv.URL + "/run", + "body": `{"input":"data"}`, + }) + if err != nil { + t.Fatal(err) + } + if out["result"] != "ok" { + t.Errorf("expected result=ok, got %v", out["result"]) + } +} + +func TestGCPInvokeCloudRunConnector_MissingURL(t *testing.T) { + c := &GCPInvokeCloudRunConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + }) + if err == nil { + t.Fatal("expected error for missing url") + } +} + +func TestRegistry_GCPConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"gcp/publish", "gcp/invoke_cloud_run"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/github.go b/packages/engine/internal/connector/github.go index 3ab5a20..a90514e 100644 --- a/packages/engine/internal/connector/github.go +++ b/packages/engine/internal/connector/github.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "net/url" "time" ) @@ -174,6 +175,78 @@ func (c *GitHubDispatchConnector) Execute(ctx context.Context, params map[string return map[string]any{"ok": true}, nil } +// GitHubDispatchWorkflowConnector triggers a GitHub Actions workflow_dispatch event. +type GitHubDispatchWorkflowConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *GitHubDispatchWorkflowConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = githubBaseURL + } + return base + path +} + +func (c *GitHubDispatchWorkflowConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("github/dispatch_workflow: %w", err) + } + + owner, _ := params["owner"].(string) + if owner == "" { + return nil, fmt.Errorf("github/dispatch_workflow: owner is required") + } + repo, _ := params["repo"].(string) + if repo == "" { + return nil, fmt.Errorf("github/dispatch_workflow: repo is required") + } + workflowID, _ := params["workflow_id"].(string) + if workflowID == "" { + return nil, fmt.Errorf("github/dispatch_workflow: workflow_id is required") + } + ref, _ := params["ref"].(string) + if ref == "" { + return nil, fmt.Errorf("github/dispatch_workflow: ref is required") + } + + body := map[string]any{"ref": ref} + if inputs, ok := params["inputs"].(map[string]any); ok && len(inputs) > 0 { + body["inputs"] = inputs + } + + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("github/dispatch_workflow: marshaling request: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/actions/workflows/%s/dispatches", owner, repo, url.PathEscape(workflowID)) + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("github/dispatch_workflow: creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("github/dispatch_workflow: %w", err) + } + defer resp.Body.Close() + + // GitHub returns 204 No Content on success. + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) + return nil, fmt.Errorf("github/dispatch_workflow: GitHub API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + return map[string]any{"ok": true}, nil +} + // extractGitHubToken pulls the GitHub token from _credential. func extractGitHubToken(params map[string]any) (string, error) { cred, ok := params["_credential"].(map[string]string) diff --git a/packages/engine/internal/connector/github_test.go b/packages/engine/internal/connector/github_test.go index 03c1516..cd716f9 100644 --- a/packages/engine/internal/connector/github_test.go +++ b/packages/engine/internal/connector/github_test.go @@ -202,9 +202,76 @@ func TestGitHubDispatchConnector_APIError(t *testing.T) { } } +func TestGitHubDispatchWorkflowConnector_DispatchesWorkflow(t *testing.T) { + var gotBody map[string]any + var gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := &GitHubDispatchWorkflowConnector{baseURL: server.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "workflow_id": "deploy.yml", + "ref": "main", + "inputs": map[string]any{"env": "production"}, + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if gotPath != "/repos/acme/repo/actions/workflows/deploy.yml/dispatches" { + t.Errorf("path = %q", gotPath) + } + if gotBody["ref"] != "main" { + t.Errorf("ref = %q, want main", gotBody["ref"]) + } + if inputs, ok := gotBody["inputs"].(map[string]any); !ok || inputs["env"] != "production" { + t.Errorf("inputs = %v", gotBody["inputs"]) + } + if out["ok"] != true { + t.Errorf("ok = %v, want true", out["ok"]) + } +} + +func TestGitHubDispatchWorkflowConnector_MissingWorkflowID(t *testing.T) { + c := &GitHubDispatchWorkflowConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "ref": "main", + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err == nil { + t.Fatal("expected error for missing workflow_id") + } +} + +func TestGitHubDispatchWorkflowConnector_MissingCredential(t *testing.T) { + c := &GitHubDispatchWorkflowConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "workflow_id": "deploy.yml", + "ref": "main", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + func TestRegistry_GitHubConnectors(t *testing.T) { r := NewRegistry() - for _, action := range []string{"github/create_issue", "github/dispatch"} { + for _, action := range []string{"github/create_issue", "github/dispatch", "github/dispatch_workflow"} { if _, err := r.Get(action); err != nil { t.Errorf("Get(%q) error: %v", action, err) } diff --git a/packages/engine/internal/connector/googledrive.go b/packages/engine/internal/connector/googledrive.go new file mode 100644 index 0000000..52f6fed --- /dev/null +++ b/packages/engine/internal/connector/googledrive.go @@ -0,0 +1,188 @@ +package connector + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" +) + +const ( + googleDriveUploadURL = "https://www.googleapis.com/upload/drive/v3/files" + googleDriveFilesURL = "https://www.googleapis.com/drive/v3/files" +) + +// GoogleDriveUploadConnector uploads a file to Google Drive via multipart upload. +type GoogleDriveUploadConnector struct { + Client *http.Client + uploadURL string +} + +func (c *GoogleDriveUploadConnector) getUploadURL() string { + if c.uploadURL != "" { + return c.uploadURL + } + return googleDriveUploadURL +} + +func (c *GoogleDriveUploadConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("drive/upload: %w", err) + } + + name, _ := params["name"].(string) + if name == "" { + return nil, fmt.Errorf("drive/upload: name is required") + } + content, _ := params["content"].(string) + if content == "" { + return nil, fmt.Errorf("drive/upload: content is required") + } + + mimeType := "application/octet-stream" + if mt, ok := params["mime_type"].(string); ok && mt != "" { + mimeType = mt + } + parentID, _ := params["parent_id"].(string) + + // Build metadata JSON. + var metaBody string + if parentID != "" { + metaBody = fmt.Sprintf(`{"name":%q,"parents":[%q]}`, name, parentID) + } else { + metaBody = fmt.Sprintf(`{"name":%q}`, name) + } + + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + + metaHeader := make(textproto.MIMEHeader) + metaHeader.Set("Content-Type", "application/json") + metaPart, err := mw.CreatePart(metaHeader) + if err != nil { + return nil, fmt.Errorf("drive/upload: creating metadata part: %w", err) + } + if _, err := io.WriteString(metaPart, metaBody); err != nil { + return nil, fmt.Errorf("drive/upload: writing metadata: %w", err) + } + + contentHeader := make(textproto.MIMEHeader) + contentHeader.Set("Content-Type", mimeType) + contentPart, err := mw.CreatePart(contentHeader) + if err != nil { + return nil, fmt.Errorf("drive/upload: creating content part: %w", err) + } + if _, err := io.WriteString(contentPart, content); err != nil { + return nil, fmt.Errorf("drive/upload: writing content: %w", err) + } + + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("drive/upload: closing writer: %w", err) + } + + endpoint := c.getUploadURL() + "?uploadType=multipart" + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, &buf) + if err != nil { + return nil, fmt.Errorf("drive/upload: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "multipart/related; boundary="+mw.Boundary()) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("drive/upload: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("drive/upload: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("drive/upload: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "drive/upload") +} + +// GoogleDriveListFilesConnector lists files in Google Drive. +type GoogleDriveListFilesConnector struct { + Client *http.Client + filesURL string +} + +func (c *GoogleDriveListFilesConnector) getFilesURL() string { + if c.filesURL != "" { + return c.filesURL + } + return googleDriveFilesURL +} + +func (c *GoogleDriveListFilesConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("drive/list_files: %w", err) + } + + pageSize := 20 + if ps, ok := extractInt(params["page_size"]); ok && ps > 0 { + pageSize = ps + } + + query, _ := params["query"].(string) + folderID, _ := params["folder_id"].(string) + if folderID != "" { + prefix := fmt.Sprintf("'%s' in parents", folderID) + if query != "" { + query = prefix + " and " + query + } else { + query = prefix + } + } + + q := url.Values{} + q.Set("fields", "files(id,name,mimeType,size,modifiedTime)") + q.Set("pageSize", fmt.Sprintf("%d", pageSize)) + if query != "" { + q.Set("q", query) + } + + endpoint := c.getFilesURL() + "?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("drive/list_files: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("drive/list_files: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("drive/list_files: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("drive/list_files: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + result, err := parseJSONBody(body, "drive/list_files") + if err != nil { + return nil, err + } + + files, _ := result["files"].([]any) + return map[string]any{ + "files": files, + "count": len(files), + }, nil +} diff --git a/packages/engine/internal/connector/googledrive_test.go b/packages/engine/internal/connector/googledrive_test.go new file mode 100644 index 0000000..caf379e --- /dev/null +++ b/packages/engine/internal/connector/googledrive_test.go @@ -0,0 +1,136 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGoogleDriveUploadConnector_UploadsFile(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + ct := r.Header.Get("Content-Type") + if !strings.Contains(ct, "multipart/related") { + t.Errorf("expected multipart/related Content-Type, got %s", ct) + } + if r.Header.Get("Authorization") != "Bearer drive-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "id": "file-id-123", + "name": "report.txt", + "mimeType": "text/plain", + }) + })) + defer srv.Close() + + c := &GoogleDriveUploadConnector{uploadURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "drive-token"}, + "name": "report.txt", + "content": "Hello from Mantle", + "mime_type": "text/plain", + }) + if err != nil { + t.Fatal(err) + } + if out["id"] != "file-id-123" { + t.Errorf("expected id=file-id-123, got %v", out["id"]) + } + if out["name"] != "report.txt" { + t.Errorf("expected name=report.txt, got %v", out["name"]) + } +} + +func TestGoogleDriveUploadConnector_MissingName(t *testing.T) { + c := &GoogleDriveUploadConnector{uploadURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "content": "data", + }) + if err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestGoogleDriveUploadConnector_MissingContent(t *testing.T) { + c := &GoogleDriveUploadConnector{uploadURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "name": "file.txt", + }) + if err == nil { + t.Fatal("expected error for missing content") + } +} + +func TestGoogleDriveUploadConnector_MissingCredential(t *testing.T) { + c := &GoogleDriveUploadConnector{uploadURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "name": "file.txt", + "content": "data", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestGoogleDriveListFilesConnector_ListsFiles(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer list-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "files": []any{ + map[string]any{"id": "f1", "name": "doc1.txt", "mimeType": "text/plain"}, + map[string]any{"id": "f2", "name": "doc2.txt", "mimeType": "text/plain"}, + }, + }) + })) + defer srv.Close() + + c := &GoogleDriveListFilesConnector{filesURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "list-token"}, + "page_size": 10, + }) + if err != nil { + t.Fatal(err) + } + files, _ := out["files"].([]any) + if len(files) != 2 { + t.Errorf("expected 2 files, got %d", len(files)) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } +} + +func TestGoogleDriveListFilesConnector_MissingCredential(t *testing.T) { + c := &GoogleDriveListFilesConnector{filesURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "page_size": 10, + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_GoogleDriveConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"drive/upload", "drive/list_files"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/googlesheets.go b/packages/engine/internal/connector/googlesheets.go new file mode 100644 index 0000000..8026bfa --- /dev/null +++ b/packages/engine/internal/connector/googlesheets.go @@ -0,0 +1,147 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const googleSheetsBaseURL = "https://sheets.googleapis.com/v4/spreadsheets" + +// GoogleSheetsReadRangeConnector reads values from a Google Sheets range. +type GoogleSheetsReadRangeConnector struct { + Client *http.Client + baseURL string +} + +func (c *GoogleSheetsReadRangeConnector) getBaseURL() string { + if c.baseURL != "" { + return c.baseURL + } + return googleSheetsBaseURL +} + +func (c *GoogleSheetsReadRangeConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("sheets/read_range: %w", err) + } + + spreadsheetID, _ := params["spreadsheet_id"].(string) + if spreadsheetID == "" { + return nil, fmt.Errorf("sheets/read_range: spreadsheet_id is required") + } + rangeStr, _ := params["range"].(string) + if rangeStr == "" { + return nil, fmt.Errorf("sheets/read_range: range is required") + } + + endpoint := fmt.Sprintf("%s/%s/values/%s", + c.getBaseURL(), + url.PathEscape(spreadsheetID), + url.PathEscape(rangeStr), + ) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("sheets/read_range: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("sheets/read_range: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("sheets/read_range: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("sheets/read_range: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + var result struct { + Values [][]any `json:"values"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("sheets/read_range: parsing response: %w", err) + } + + return map[string]any{ + "values": result.Values, + "row_count": len(result.Values), + }, nil +} + +// GoogleSheetsAppendRowsConnector appends rows to a Google Sheets range. +type GoogleSheetsAppendRowsConnector struct { + Client *http.Client + baseURL string +} + +func (c *GoogleSheetsAppendRowsConnector) getBaseURL() string { + if c.baseURL != "" { + return c.baseURL + } + return googleSheetsBaseURL +} + +func (c *GoogleSheetsAppendRowsConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("sheets/append_rows: %w", err) + } + + spreadsheetID, _ := params["spreadsheet_id"].(string) + if spreadsheetID == "" { + return nil, fmt.Errorf("sheets/append_rows: spreadsheet_id is required") + } + rangeStr, _ := params["range"].(string) + if rangeStr == "" { + return nil, fmt.Errorf("sheets/append_rows: range is required") + } + values, ok := params["values"].([]any) + if !ok || values == nil { + return nil, fmt.Errorf("sheets/append_rows: values is required") + } + + endpoint := fmt.Sprintf("%s/%s/values/%s:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS", + c.getBaseURL(), + url.PathEscape(spreadsheetID), + url.PathEscape(rangeStr), + ) + + bodyData, err := json.Marshal(map[string]any{"values": values}) + if err != nil { + return nil, fmt.Errorf("sheets/append_rows: marshaling body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(bodyData)) + if err != nil { + return nil, fmt.Errorf("sheets/append_rows: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("sheets/append_rows: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("sheets/append_rows: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("sheets/append_rows: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return parseJSONBody(body, "sheets/append_rows") +} diff --git a/packages/engine/internal/connector/googlesheets_test.go b/packages/engine/internal/connector/googlesheets_test.go new file mode 100644 index 0000000..8493663 --- /dev/null +++ b/packages/engine/internal/connector/googlesheets_test.go @@ -0,0 +1,135 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGoogleSheetsReadRangeConnector_ReadsRange(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer sheets-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "values": []any{ + []any{"A1", "B1"}, + []any{"A2", "B2"}, + }, + }) + })) + defer srv.Close() + + c := &GoogleSheetsReadRangeConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "sheets-token"}, + "spreadsheet_id": "spreadsheet-1", + "range": "Sheet1!A1:B2", + }) + if err != nil { + t.Fatal(err) + } + values, _ := out["values"].([][]any) + if len(values) != 2 { + t.Errorf("expected 2 rows, got %d", len(values)) + } + if out["row_count"].(int) != 2 { + t.Errorf("expected row_count=2, got %v", out["row_count"]) + } +} + +func TestGoogleSheetsReadRangeConnector_MissingSpreadsheetID(t *testing.T) { + c := &GoogleSheetsReadRangeConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "range": "Sheet1!A1:B2", + }) + if err == nil { + t.Fatal("expected error for missing spreadsheet_id") + } +} + +func TestGoogleSheetsReadRangeConnector_MissingRange(t *testing.T) { + c := &GoogleSheetsReadRangeConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "spreadsheet_id": "spreadsheet-1", + }) + if err == nil { + t.Fatal("expected error for missing range") + } +} + +func TestGoogleSheetsReadRangeConnector_MissingCredential(t *testing.T) { + c := &GoogleSheetsReadRangeConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "spreadsheet_id": "spreadsheet-1", + "range": "Sheet1!A1:B2", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestGoogleSheetsAppendRowsConnector_AppendsRows(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer append-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "spreadsheetId": "spreadsheet-1", + "tableRange": "Sheet1!A1:B2", + "updates": map[string]any{ + "updatedRows": 2, + }, + }) + })) + defer srv.Close() + + c := &GoogleSheetsAppendRowsConnector{baseURL: srv.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "append-token"}, + "spreadsheet_id": "spreadsheet-1", + "range": "Sheet1!A1", + "values": []any{ + []any{"val1", "val2"}, + []any{"val3", "val4"}, + }, + }) + if err != nil { + t.Fatal(err) + } + if out["spreadsheetId"] != "spreadsheet-1" { + t.Errorf("expected spreadsheetId=spreadsheet-1, got %v", out["spreadsheetId"]) + } +} + +func TestGoogleSheetsAppendRowsConnector_MissingValues(t *testing.T) { + c := &GoogleSheetsAppendRowsConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + "spreadsheet_id": "spreadsheet-1", + "range": "Sheet1!A1", + }) + if err == nil { + t.Fatal("expected error for missing values") + } +} + +func TestRegistry_GoogleSheetsConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"sheets/read_range", "sheets/append_rows"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/hubspot.go b/packages/engine/internal/connector/hubspot.go new file mode 100644 index 0000000..d1b0cdd --- /dev/null +++ b/packages/engine/internal/connector/hubspot.go @@ -0,0 +1,153 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const hubspotBaseURL = "https://api.hubapi.com" + +// HubSpotCreateContactConnector creates a contact in HubSpot CRM. +type HubSpotCreateContactConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *HubSpotCreateContactConnector) apiURL(path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return hubspotBaseURL + path +} + +func (c *HubSpotCreateContactConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("hubspot/create_contact: %w", err) + } + + email, _ := params["email"].(string) + if email == "" { + return nil, fmt.Errorf("hubspot/create_contact: email is required") + } + + properties := map[string]any{"email": email} + if v, ok := params["firstname"].(string); ok && v != "" { + properties["firstname"] = v + } + if v, ok := params["lastname"].(string); ok && v != "" { + properties["lastname"] = v + } + if v, ok := params["phone"].(string); ok && v != "" { + properties["phone"] = v + } + if v, ok := params["company"].(string); ok && v != "" { + properties["company"] = v + } + + bodyMap := map[string]any{"properties": properties} + reqJSON, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("hubspot/create_contact: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/crm/v3/objects/contacts"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("hubspot/create_contact: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("hubspot/create_contact: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("hubspot/create_contact: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hubspot/create_contact: API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + return parseJSONBody(respBody, "hubspot/create_contact") +} + +// HubSpotSearchContactsConnector searches contacts in HubSpot CRM. +type HubSpotSearchContactsConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *HubSpotSearchContactsConnector) apiURL(path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return hubspotBaseURL + path +} + +func (c *HubSpotSearchContactsConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractBearerToken(params) + if err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: %w", err) + } + + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("hubspot/search_contacts: query is required") + } + + limit := 10 + if l, ok := extractInt(params["limit"]); ok && l > 0 { + limit = l + } + + bodyMap := map[string]any{ + "query": query, + "limit": limit, + "properties": []string{"email", "firstname", "lastname", "phone", "company"}, + } + reqJSON, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/crm/v3/objects/contacts/search"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hubspot/search_contacts: API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Results []any `json:"results"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("hubspot/search_contacts: parsing response: %w", err) + } + + return map[string]any{ + "results": result.Results, + "count": len(result.Results), + }, nil +} diff --git a/packages/engine/internal/connector/hubspot_test.go b/packages/engine/internal/connector/hubspot_test.go new file mode 100644 index 0000000..60f6e18 --- /dev/null +++ b/packages/engine/internal/connector/hubspot_test.go @@ -0,0 +1,120 @@ +package connector + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHubSpotCreateContactConnector_CreatesContact(t *testing.T) { + var gotBody map[string]any + var gotMethod string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "contact-1", + "properties": map[string]any{"email": "alice@example.com"}, + }) + })) + defer srv.Close() + + c := &HubSpotCreateContactConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "hs-token"}, + "email": "alice@example.com", + "firstname": "Alice", + "lastname": "Smith", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if gotMethod != "POST" { + t.Errorf("expected POST, got %s", gotMethod) + } + props, _ := gotBody["properties"].(map[string]any) + if props["email"] != "alice@example.com" { + t.Errorf("email = %v", props["email"]) + } + if out["id"] != "contact-1" { + t.Errorf("id = %v, want contact-1", out["id"]) + } +} + +func TestHubSpotCreateContactConnector_MissingEmail(t *testing.T) { + c := &HubSpotCreateContactConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + }) + if err == nil { + t.Fatal("expected error for missing email") + } +} + +func TestHubSpotCreateContactConnector_MissingCredential(t *testing.T) { + c := &HubSpotCreateContactConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "email": "alice@example.com", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestHubSpotSearchContactsConnector_SearchesContacts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "results": []any{ + map[string]any{"id": "c1", "properties": map[string]any{"email": "alice@example.com"}}, + map[string]any{"id": "c2", "properties": map[string]any{"email": "bob@example.com"}}, + }, + }) + })) + defer srv.Close() + + c := &HubSpotSearchContactsConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "hs-token"}, + "query": "alice", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } + results, ok := out["results"].([]any) + if !ok || len(results) != 2 { + t.Errorf("expected 2 results, got %v", out["results"]) + } +} + +func TestHubSpotSearchContactsConnector_MissingQuery(t *testing.T) { + c := &HubSpotSearchContactsConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "tok"}, + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestRegistry_HubSpotConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"hubspot/create_contact", "hubspot/search_contacts"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/jira.go b/packages/engine/internal/connector/jira.go new file mode 100644 index 0000000..9e1d4ba --- /dev/null +++ b/packages/engine/internal/connector/jira.go @@ -0,0 +1,219 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// extractJiraCredential extracts domain, email, and api_token from _credential. +// Accepts both map[string]string and map[string]any credential shapes. +func extractJiraCredential(params map[string]any) (domain, email, token string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", "", fmt.Errorf("credential is required") + } + + domain = cred["domain"] + if domain == "" { + return "", "", "", fmt.Errorf("credential must contain a 'domain' field (e.g. mycompany.atlassian.net)") + } + email = cred["email"] + if email == "" { + return "", "", "", fmt.Errorf("credential must contain an 'email' field") + } + token = cred["api_token"] + if token == "" { + return "", "", "", fmt.Errorf("credential must contain an 'api_token' field") + } + return domain, email, token, nil +} + +// JiraCreateIssueConnector creates an issue in Jira. +type JiraCreateIssueConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *JiraCreateIssueConnector) apiURL(domain, path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return "https://" + domain + path +} + +func (c *JiraCreateIssueConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + domain, email, token, err := extractJiraCredential(params) + if err != nil { + return nil, fmt.Errorf("jira/create_issue: %w", err) + } + + projectKey, _ := params["project_key"].(string) + if projectKey == "" { + return nil, fmt.Errorf("jira/create_issue: project_key is required") + } + summary, _ := params["summary"].(string) + if summary == "" { + return nil, fmt.Errorf("jira/create_issue: summary is required") + } + + issueType, _ := params["issue_type"].(string) + if issueType == "" { + issueType = "Task" + } + + fields := map[string]any{ + "project": map[string]any{"key": projectKey}, + "summary": summary, + "issuetype": map[string]any{"name": issueType}, + } + + if desc, ok := params["description"].(string); ok && desc != "" { + fields["description"] = map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": desc}}, + }, + }, + } + } + if priority, ok := params["priority"].(string); ok && priority != "" { + fields["priority"] = map[string]any{"name": priority} + } + if assignee, ok := params["assignee"].(string); ok && assignee != "" { + fields["assignee"] = map[string]any{"accountId": assignee} + } + + bodyMap := map[string]any{"fields": fields} + reqJSON, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("jira/create_issue: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(domain, "/rest/api/3/issue"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("jira/create_issue: creating request: %w", err) + } + req.SetBasicAuth(email, token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("jira/create_issue: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("jira/create_issue: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jira/create_issue: Jira API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + return parseJSONBody(respBody, "jira/create_issue") +} + +// JiraSearchIssuesConnector searches for Jira issues using JQL. +type JiraSearchIssuesConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *JiraSearchIssuesConnector) apiURL(domain, path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return "https://" + domain + path +} + +func (c *JiraSearchIssuesConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + domain, email, token, err := extractJiraCredential(params) + if err != nil { + return nil, fmt.Errorf("jira/search_issues: %w", err) + } + + jql, _ := params["jql"].(string) + if jql == "" { + return nil, fmt.Errorf("jira/search_issues: jql is required") + } + + maxResults := 20 + if m, ok := extractInt(params["max_results"]); ok && m > 0 { + maxResults = m + } + + fields := []string{"summary", "status", "assignee", "priority", "issuetype", "created", "updated"} + if f := toStringSlice(params["fields"]); len(f) > 0 { + fields = f + } + + bodyMap := map[string]any{ + "jql": jql, + "maxResults": maxResults, + "fields": fields, + } + reqJSON, err := json.Marshal(bodyMap) + if err != nil { + return nil, fmt.Errorf("jira/search_issues: marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(domain, "/rest/api/3/issue/search"), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("jira/search_issues: creating request: %w", err) + } + req.SetBasicAuth(email, token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("jira/search_issues: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("jira/search_issues: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("jira/search_issues: Jira API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var result struct { + Issues []any `json:"issues"` + Total int `json:"total"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("jira/search_issues: parsing response: %w", err) + } + + return map[string]any{ + "issues": result.Issues, + "total": result.Total, + "count": len(result.Issues), + }, nil +} diff --git a/packages/engine/internal/connector/jira_test.go b/packages/engine/internal/connector/jira_test.go new file mode 100644 index 0000000..ffeab22 --- /dev/null +++ b/packages/engine/internal/connector/jira_test.go @@ -0,0 +1,185 @@ +package connector + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestJiraCreateIssueConnector_CreatesIssue(t *testing.T) { + var gotBody map[string]any + var gotAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/rest/api/3/issue" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + gotAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "10001", + "key": "PROJ-1", + "self": "https://mycompany.atlassian.net/rest/api/3/issue/10001", + }) + })) + defer srv.Close() + + c := &JiraCreateIssueConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "domain": "mycompany.atlassian.net", + "email": "user@example.com", + "api_token": "secret-token", + }, + "project_key": "PROJ", + "summary": "Fix the bug", + "issue_type": "Bug", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + // Basic auth should be set + if gotAuth == "" { + t.Error("expected Authorization header to be set") + } + + fields, _ := gotBody["fields"].(map[string]any) + if fields["summary"] != "Fix the bug" { + t.Errorf("summary = %v", fields["summary"]) + } + project, _ := fields["project"].(map[string]any) + if project["key"] != "PROJ" { + t.Errorf("project.key = %v", project["key"]) + } + issuetype, _ := fields["issuetype"].(map[string]any) + if issuetype["name"] != "Bug" { + t.Errorf("issuetype.name = %v", issuetype["name"]) + } + + if out["key"] != "PROJ-1" { + t.Errorf("key = %v, want PROJ-1", out["key"]) + } +} + +func TestJiraCreateIssueConnector_DefaultsToTask(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": "1", "key": "PROJ-1", "self": ""}) + })) + defer srv.Close() + + c := &JiraCreateIssueConnector{baseURL: srv.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "domain": "mycompany.atlassian.net", "email": "u@e.com", "api_token": "tok", + }, + "project_key": "PROJ", + "summary": "Do something", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + fields, _ := gotBody["fields"].(map[string]any) + issuetype, _ := fields["issuetype"].(map[string]any) + if issuetype["name"] != "Task" { + t.Errorf("expected default issue_type=Task, got %v", issuetype["name"]) + } +} + +func TestJiraCreateIssueConnector_MissingProjectKey(t *testing.T) { + c := &JiraCreateIssueConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "domain": "x.atlassian.net", "email": "u@e.com", "api_token": "tok", + }, + "summary": "Test", + }) + if err == nil { + t.Fatal("expected error for missing project_key") + } +} + +func TestJiraCreateIssueConnector_MissingCredential(t *testing.T) { + c := &JiraCreateIssueConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "project_key": "PROJ", + "summary": "Test", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestJiraSearchIssuesConnector_SearchesIssues(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/rest/api/3/issue/search" { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issues": []any{ + map[string]any{"id": "10001", "key": "PROJ-1"}, + map[string]any{"id": "10002", "key": "PROJ-2"}, + }, + "total": 2, + }) + })) + defer srv.Close() + + c := &JiraSearchIssuesConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "domain": "mycompany.atlassian.net", "email": "u@e.com", "api_token": "tok", + }, + "jql": "project = PROJ AND status = Open", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } + if out["total"].(int) != 2 { + t.Errorf("expected total=2, got %v", out["total"]) + } + if gotBody["jql"] != "project = PROJ AND status = Open" { + t.Errorf("jql = %v", gotBody["jql"]) + } +} + +func TestJiraSearchIssuesConnector_MissingJQL(t *testing.T) { + c := &JiraSearchIssuesConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "domain": "x.atlassian.net", "email": "u@e.com", "api_token": "tok", + }, + }) + if err == nil { + t.Fatal("expected error for missing jql") + } +} + +func TestRegistry_JiraConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"jira/create_issue", "jira/search_issues"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/kafka.go b/packages/engine/internal/connector/kafka.go new file mode 100644 index 0000000..7ac16b8 --- /dev/null +++ b/packages/engine/internal/connector/kafka.go @@ -0,0 +1,192 @@ +package connector + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/twmb/franz-go/pkg/kgo" +) + +// extractKafkaCredential pulls the brokers list from the _credential param. +// Expected credential shape: {brokers: "broker1:9092,broker2:9092"}. +// Deletes _credential from params. +func extractKafkaCredential(params map[string]any) (brokers []string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return nil, fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return nil, fmt.Errorf("credential is required") + } + + brokersStr := cred["brokers"] + if brokersStr == "" { + return nil, fmt.Errorf("credential must contain a 'brokers' field") + } + + parts := strings.Split(brokersStr, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + brokers = append(brokers, p) + } + } + if len(brokers) == 0 { + return nil, fmt.Errorf("credential must contain at least one broker in 'brokers' field") + } + return brokers, nil +} + +// KafkaProduceConnector produces a message to a Kafka topic. +// Params: topic (required), message (required), key (optional). +// Output: {"ok": true} +type KafkaProduceConnector struct{} + +func (c *KafkaProduceConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + topic, _ := params["topic"].(string) + if topic == "" { + return nil, fmt.Errorf("kafka/produce: topic is required") + } + + message, _ := params["message"].(string) + if message == "" { + return nil, fmt.Errorf("kafka/produce: message is required") + } + + brokers, err := extractKafkaCredential(params) + if err != nil { + return nil, fmt.Errorf("kafka/produce: %w", err) + } + + opts := []kgo.Opt{ + kgo.SeedBrokers(brokers...), + kgo.DefaultProduceTopic(topic), + } + + client, err := kgo.NewClient(opts...) + if err != nil { + return nil, fmt.Errorf("kafka/produce: creating client: %w", err) + } + defer client.Close() + + record := &kgo.Record{ + Value: []byte(message), + } + + if key, _ := params["key"].(string); key != "" { + record.Key = []byte(key) + } + + if err := client.ProduceSync(ctx, record).FirstErr(); err != nil { + return nil, fmt.Errorf("kafka/produce: producing message: %w", err) + } + + return map[string]any{"ok": true}, nil +} + +// KafkaConsumeConnector consumes messages from a Kafka topic. +// Params: topic (required), group_id (required), max_messages (optional, default 10), +// +// timeout_ms (optional, default 5000). +// +// Output: {"messages": [...], "count": N} +// Each message: {"key": "...", "value": "...", "offset": N, "partition": N} +type KafkaConsumeConnector struct{} + +func (c *KafkaConsumeConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + topic, _ := params["topic"].(string) + if topic == "" { + return nil, fmt.Errorf("kafka/consume: topic is required") + } + + groupID, _ := params["group_id"].(string) + if groupID == "" { + return nil, fmt.Errorf("kafka/consume: group_id is required") + } + + brokers, err := extractKafkaCredential(params) + if err != nil { + return nil, fmt.Errorf("kafka/consume: %w", err) + } + + maxMessages := 10 + if m, ok := extractInt(params["max_messages"]); ok && m > 0 { + maxMessages = m + } + + timeoutMS := 5000 + if t, ok := extractInt(params["timeout_ms"]); ok && t > 0 { + timeoutMS = t + } + + client, err := kgo.NewClient( + kgo.SeedBrokers(brokers...), + kgo.ConsumerGroup(groupID), + kgo.ConsumeTopics(topic), + ) + if err != nil { + return nil, fmt.Errorf("kafka/consume: creating client: %w", err) + } + defer client.Close() + + tctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMS)*time.Millisecond) + defer cancel() + + var messages []any + for len(messages) < maxMessages { + fetches := client.PollFetches(tctx) + if fetches.IsClientClosed() { + break + } + if errs := fetches.Errors(); len(errs) > 0 { + // Check for context cancellation (timeout reached). + for _, fe := range errs { + if fe.Err == context.DeadlineExceeded || fe.Err == context.Canceled { + goto done + } + } + return nil, fmt.Errorf("kafka/consume: fetch error: %v", errs[0].Err) + } + + fetches.EachRecord(func(r *kgo.Record) { + if len(messages) >= maxMessages { + return + } + msg := map[string]any{ + "key": string(r.Key), + "value": string(r.Value), + "offset": r.Offset, + "partition": r.Partition, + } + messages = append(messages, msg) + }) + + if tctx.Err() != nil { + break + } + } +done: + + if messages == nil { + messages = []any{} + } + return map[string]any{ + "messages": messages, + "count": len(messages), + }, nil +} diff --git a/packages/engine/internal/connector/kafka_test.go b/packages/engine/internal/connector/kafka_test.go new file mode 100644 index 0000000..3388ffb --- /dev/null +++ b/packages/engine/internal/connector/kafka_test.go @@ -0,0 +1,121 @@ +package connector + +import ( + "testing" +) + +// --- KafkaProduceConnector --- + +func TestKafkaProduceConnector_MissingTopic(t *testing.T) { + c := &KafkaProduceConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "message": "hello", + "_credential": map[string]string{ + "brokers": "localhost:9092", + }, + }) + if err == nil { + t.Fatal("expected error for missing topic") + } +} + +func TestKafkaProduceConnector_MissingMessage(t *testing.T) { + c := &KafkaProduceConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "_credential": map[string]string{ + "brokers": "localhost:9092", + }, + }) + if err == nil { + t.Fatal("expected error for missing message") + } +} + +func TestKafkaProduceConnector_MissingCredential(t *testing.T) { + c := &KafkaProduceConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "message": "hello", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestKafkaProduceConnector_MissingBrokers(t *testing.T) { + c := &KafkaProduceConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "message": "hello", + "_credential": map[string]string{}, + }) + if err == nil { + t.Fatal("expected error for missing brokers in credential") + } +} + +// --- KafkaConsumeConnector --- + +func TestKafkaConsumeConnector_MissingTopic(t *testing.T) { + c := &KafkaConsumeConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "group_id": "my-group", + "_credential": map[string]string{ + "brokers": "localhost:9092", + }, + }) + if err == nil { + t.Fatal("expected error for missing topic") + } +} + +func TestKafkaConsumeConnector_MissingGroupID(t *testing.T) { + c := &KafkaConsumeConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "_credential": map[string]string{ + "brokers": "localhost:9092", + }, + }) + if err == nil { + t.Fatal("expected error for missing group_id") + } +} + +func TestKafkaConsumeConnector_MissingCredential(t *testing.T) { + c := &KafkaConsumeConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "group_id": "my-group", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestKafkaConsumeConnector_MissingBrokers(t *testing.T) { + c := &KafkaConsumeConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "topic": "my-topic", + "group_id": "my-group", + "_credential": map[string]string{}, + }) + if err == nil { + t.Fatal("expected error for missing brokers in credential") + } +} + +// --- Registry --- + +func TestRegistry_KafkaConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "kafka/produce", + "kafka/consume", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/kubernetes.go b/packages/engine/internal/connector/kubernetes.go new file mode 100644 index 0000000..259fdab --- /dev/null +++ b/packages/engine/internal/connector/kubernetes.go @@ -0,0 +1,399 @@ +package connector + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// extractK8sCredential pulls server, token, and namespace from the _credential param. +// Namespace defaults to "default" if empty. +// Deletes _credential from params. +func extractK8sCredential(params map[string]any) (server, token, namespace string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", "", fmt.Errorf("credential is required") + } + + server = cred["server"] + if server == "" { + return "", "", "", fmt.Errorf("credential must contain a 'server' field") + } + token = cred["token"] + if token == "" { + return "", "", "", fmt.Errorf("credential must contain a 'token' field") + } + namespace = cred["namespace"] + return server, token, namespace, nil +} + +// k8sInsecureClient returns an HTTP client that skips TLS verification. +// This is appropriate for Kubernetes clusters with self-signed certs. +func k8sInsecureClient(c *http.Client) *http.Client { + if c != nil { + return c + } + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, // #nosec G402 -- K8s clusters commonly use self-signed certs; callers that need cert verification should supply their own *http.Client + }, + } +} + +// k8sPlural returns the lowercase plural resource type for a Kubernetes kind. +// Handles common English pluralization rules that appear in the K8s API. +func k8sPlural(kind string) string { + lower := strings.ToLower(kind) + switch { + case strings.HasSuffix(lower, "s"): + return lower + "es" // ingress → ingresses, storageclass → storageclasses + case strings.HasSuffix(lower, "y"): + return lower[:len(lower)-1] + "ies" // networkpolicy → networkpolicies + default: + return lower + "s" + } +} + +// k8sResourcePath builds the REST path for a Kubernetes resource. +// Core API (apiVersion="v1"): /api/v1/namespaces/{ns}/{resourceType}/{name} +// Other APIs: /apis/{apiVersion}/namespaces/{ns}/{resourceType}/{name} +// An empty namespace produces a cluster-scoped path (omits /namespaces/{ns}). +func k8sResourcePath(apiVersion, kind, namespace, name string) string { + resourceType := k8sPlural(kind) + + var basePath string + if apiVersion == "v1" { + basePath = "/api/v1" + } else { + basePath = "/apis/" + apiVersion + } + + if namespace == "" { + if name != "" { + return fmt.Sprintf("%s/%s/%s", basePath, resourceType, url.PathEscape(name)) + } + return fmt.Sprintf("%s/%s", basePath, resourceType) + } + + if name != "" { + return fmt.Sprintf("%s/namespaces/%s/%s/%s", basePath, url.PathEscape(namespace), resourceType, url.PathEscape(name)) + } + return fmt.Sprintf("%s/namespaces/%s/%s", basePath, url.PathEscape(namespace), resourceType) +} + +// k8sDoRequest executes an HTTP request against the Kubernetes API. +func k8sDoRequest(ctx context.Context, client *http.Client, method, url, token string, body []byte) (map[string]any, int, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("reading response: %w", err) + } + + var result map[string]any + if len(respBody) > 0 { + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, resp.StatusCode, fmt.Errorf("parsing response: %w", err) + } + } + + return result, resp.StatusCode, nil +} + +// K8sApplyConnector applies a Kubernetes resource manifest using server-side apply +// (PATCH with application/apply-patch+yaml and fieldManager=mantle). +// Creates or updates without requiring resourceVersion in the manifest. +// Params: manifest (required — map[string]any Kubernetes resource manifest). +// Output: {"ok": true, "name": "...", "kind": "..."} +type K8sApplyConnector struct { + Client *http.Client + baseURL string +} + +func (c *K8sApplyConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + manifest, ok := params["manifest"].(map[string]any) + if !ok || manifest == nil { + return nil, fmt.Errorf("k8s/apply: manifest is required") + } + + server, token, credNamespace, err := extractK8sCredential(params) + if err != nil { + return nil, fmt.Errorf("k8s/apply: %w", err) + } + + baseURL := server + if c.baseURL != "" { + baseURL = c.baseURL + } + + apiVersion, _ := manifest["apiVersion"].(string) + kind, _ := manifest["kind"].(string) + if apiVersion == "" || kind == "" { + return nil, fmt.Errorf("k8s/apply: manifest must contain apiVersion and kind") + } + + namespace := credNamespace + if meta, ok := manifest["metadata"].(map[string]any); ok { + if ns, _ := meta["namespace"].(string); ns != "" { + namespace = ns + } + } + + name := "" + if meta, ok := manifest["metadata"].(map[string]any); ok { + name, _ = meta["name"].(string) + } + if name == "" { + return nil, fmt.Errorf("k8s/apply: manifest.metadata.name is required") + } + + body, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("k8s/apply: marshalling manifest: %w", err) + } + + client := k8sInsecureClient(c.Client) + path := k8sResourcePath(apiVersion, kind, namespace, name) + applyURL := baseURL + path + "?fieldManager=mantle&force=true" + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, applyURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("k8s/apply: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + // Server-side apply content type: creates or updates without requiring resourceVersion. + req.Header.Set("Content-Type", "application/apply-patch+yaml") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("k8s/apply: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("k8s/apply: reading response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("k8s/apply: server-side apply returned %d: %s", resp.StatusCode, truncate(string(respBody), 200)) + } + + return map[string]any{ + "ok": true, + "name": name, + "kind": kind, + }, nil +} + +// K8sCreateJobConnector creates a Kubernetes batch/v1 Job. +// Params: name (required), image (required), command (required []string), +// +// namespace (optional), restart_policy (optional, default "Never"), +// backoff_limit (optional int, default 0). +// +// Output: parsed job object from Kubernetes API. +type K8sCreateJobConnector struct { + Client *http.Client + baseURL string +} + +func (c *K8sCreateJobConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + name, _ := params["name"].(string) + if name == "" { + return nil, fmt.Errorf("k8s/create_job: name is required") + } + + image, _ := params["image"].(string) + if image == "" { + return nil, fmt.Errorf("k8s/create_job: image is required") + } + + var command []string + switch v := params["command"].(type) { + case []string: + command = v + case []any: + for _, s := range v { + if str, ok := s.(string); ok { + command = append(command, str) + } + } + default: + return nil, fmt.Errorf("k8s/create_job: command is required") + } + if len(command) == 0 { + return nil, fmt.Errorf("k8s/create_job: command is required") + } + + server, token, credNamespace, err := extractK8sCredential(params) + if err != nil { + return nil, fmt.Errorf("k8s/create_job: %w", err) + } + + baseURL := server + if c.baseURL != "" { + baseURL = c.baseURL + } + + namespace := credNamespace + if ns, _ := params["namespace"].(string); ns != "" { + namespace = ns + } + if namespace == "" { + namespace = "default" + } + + restartPolicy := "Never" + if rp, _ := params["restart_policy"].(string); rp != "" { + restartPolicy = rp + } + + backoffLimit := 0 + if bl, ok := extractInt(params["backoff_limit"]); ok { + backoffLimit = bl + } + + jobManifest := map[string]any{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + }, + "spec": map[string]any{ + "backoffLimit": backoffLimit, + "template": map[string]any{ + "spec": map[string]any{ + "restartPolicy": restartPolicy, + "containers": []any{ + map[string]any{ + "name": name, + "image": image, + "command": command, + }, + }, + }, + }, + }, + } + + body, err := json.Marshal(jobManifest) + if err != nil { + return nil, fmt.Errorf("k8s/create_job: marshalling manifest: %w", err) + } + + client := k8sInsecureClient(c.Client) + url := fmt.Sprintf("%s/apis/batch/v1/namespaces/%s/jobs", baseURL, namespace) + + result, statusCode, err := k8sDoRequest(ctx, client, http.MethodPost, url, token, body) + if err != nil { + return nil, fmt.Errorf("k8s/create_job: %w", err) + } + if statusCode < 200 || statusCode >= 300 { + return nil, fmt.Errorf("k8s/create_job: API returned status %d", statusCode) + } + + return result, nil +} + +// K8sGetPodStatusConnector fetches the status of a Kubernetes Pod. +// Params: pod_name (required), namespace (optional, fallback to credential namespace). +// Output: {"name": ..., "phase": ..., "conditions": [...], "container_statuses": [...]} +type K8sGetPodStatusConnector struct { + Client *http.Client + baseURL string +} + +func (c *K8sGetPodStatusConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + podName, _ := params["pod_name"].(string) + if podName == "" { + return nil, fmt.Errorf("k8s/get_pod_status: pod_name is required") + } + + server, token, credNamespace, err := extractK8sCredential(params) + if err != nil { + return nil, fmt.Errorf("k8s/get_pod_status: %w", err) + } + + baseURL := server + if c.baseURL != "" { + baseURL = c.baseURL + } + + namespace := credNamespace + if ns, _ := params["namespace"].(string); ns != "" { + namespace = ns + } + if namespace == "" { + namespace = "default" + } + + client := k8sInsecureClient(c.Client) + podURL := fmt.Sprintf("%s/api/v1/namespaces/%s/pods/%s", baseURL, url.PathEscape(namespace), url.PathEscape(podName)) + + result, statusCode, err := k8sDoRequest(ctx, client, http.MethodGet, podURL, token, nil) + if err != nil { + return nil, fmt.Errorf("k8s/get_pod_status: %w", err) + } + if statusCode < 200 || statusCode >= 300 { + return nil, fmt.Errorf("k8s/get_pod_status: API returned status %d", statusCode) + } + + // Extract relevant status fields. + out := map[string]any{ + "name": podName, + } + + if meta, ok := result["metadata"].(map[string]any); ok { + if n, _ := meta["name"].(string); n != "" { + out["name"] = n + } + } + + if status, ok := result["status"].(map[string]any); ok { + out["phase"] = status["phase"] + out["conditions"] = status["conditions"] + out["container_statuses"] = status["containerStatuses"] + } + + return out, nil +} diff --git a/packages/engine/internal/connector/kubernetes_test.go b/packages/engine/internal/connector/kubernetes_test.go new file mode 100644 index 0000000..03c21c1 --- /dev/null +++ b/packages/engine/internal/connector/kubernetes_test.go @@ -0,0 +1,249 @@ +package connector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func testK8sCredential() map[string]string { + return map[string]string{ + "server": "https://k8s.example.com", + "token": "test-token", + "namespace": "default", + } +} + +// --- K8sApplyConnector --- + +func TestK8sApplyConnector_ServerSideApply(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/apply-patch+yaml" { + t.Errorf("unexpected Content-Type: %s", r.Header.Get("Content-Type")) + } + if r.URL.Query().Get("fieldManager") != "mantle" { + t.Errorf("expected fieldManager=mantle, got %q", r.URL.Query().Get("fieldManager")) + } + if r.URL.Query().Get("force") != "true" { + t.Errorf("expected force=true, got %q", r.URL.Query().Get("force")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + c := &K8sApplyConnector{Client: ts.Client(), baseURL: ts.URL} + out, err := c.Execute(t.Context(), map[string]any{ + "_credential": testK8sCredential(), + "manifest": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "my-config", "namespace": "default"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out["ok"] != true { + t.Errorf("expected ok=true, got %v", out["ok"]) + } +} + +func TestK8sApplyConnector_MissingManifest(t *testing.T) { + c := &K8sApplyConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": testK8sCredential(), + }) + if err == nil { + t.Fatal("expected error for missing manifest") + } +} + +func TestK8sApplyConnector_MissingMetadataName(t *testing.T) { + c := &K8sApplyConnector{baseURL: "http://unused"} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": testK8sCredential(), + "manifest": map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{}, + }, + }) + if err == nil { + t.Fatal("expected error for missing metadata.name") + } +} + +// --- K8sCreateJobConnector --- + +func TestK8sCreateJobConnector_CreatesJob(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if !containsStr(r.URL.Path, "/apis/batch/v1/namespaces/default/jobs") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + resp := map[string]any{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]any{ + "name": "my-job", + "namespace": "default", + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + c := &K8sCreateJobConnector{ + Client: ts.Client(), + baseURL: ts.URL, + } + result, err := c.Execute(t.Context(), map[string]any{ + "name": "my-job", + "image": "ubuntu:22.04", + "command": []string{"echo", "hello"}, + "_credential": testK8sCredential(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if kind, _ := result["kind"].(string); kind != "Job" { + t.Errorf("expected kind=Job, got %q", kind) + } +} + +func TestK8sCreateJobConnector_MissingName(t *testing.T) { + c := &K8sCreateJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "image": "ubuntu:22.04", + "command": []string{"echo", "hello"}, + "_credential": testK8sCredential(), + }) + if err == nil { + t.Fatal("expected error for missing name") + } +} + +func TestK8sCreateJobConnector_MissingImage(t *testing.T) { + c := &K8sCreateJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "name": "my-job", + "command": []string{"echo", "hello"}, + "_credential": testK8sCredential(), + }) + if err == nil { + t.Fatal("expected error for missing image") + } +} + +func TestK8sCreateJobConnector_MissingCommand(t *testing.T) { + c := &K8sCreateJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "name": "my-job", + "image": "ubuntu:22.04", + "_credential": testK8sCredential(), + }) + if err == nil { + t.Fatal("expected error for missing command") + } +} + +func TestK8sCreateJobConnector_MissingCredential(t *testing.T) { + c := &K8sCreateJobConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "name": "my-job", + "image": "ubuntu:22.04", + "command": []string{"echo", "hello"}, + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +// --- K8sGetPodStatusConnector --- + +func TestK8sGetPodStatusConnector_GetsPodStatus(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if !containsStr(r.URL.Path, "/api/v1/namespaces/default/pods/my-pod") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + resp := map[string]any{ + "metadata": map[string]any{"name": "my-pod"}, + "status": map[string]any{ + "phase": "Running", + "conditions": []any{ + map[string]any{"type": "Ready", "status": "True"}, + }, + "containerStatuses": []any{ + map[string]any{"name": "main", "ready": true}, + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + c := &K8sGetPodStatusConnector{ + Client: ts.Client(), + baseURL: ts.URL, + } + result, err := c.Execute(t.Context(), map[string]any{ + "pod_name": "my-pod", + "_credential": testK8sCredential(), + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if phase, _ := result["phase"].(string); phase != "Running" { + t.Errorf("expected phase=Running, got %q", phase) + } +} + +func TestK8sGetPodStatusConnector_MissingPodName(t *testing.T) { + c := &K8sGetPodStatusConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": testK8sCredential(), + }) + if err == nil { + t.Fatal("expected error for missing pod_name") + } +} + +// --- Registry --- + +func TestRegistry_K8sConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "k8s/apply", + "k8s/create_job", + "k8s/get_pod_status", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} + +func containsStr(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/packages/engine/internal/connector/mysql.go b/packages/engine/internal/connector/mysql.go new file mode 100644 index 0000000..33d62f8 --- /dev/null +++ b/packages/engine/internal/connector/mysql.go @@ -0,0 +1,465 @@ +package connector + +import ( + "context" + "database/sql" + "fmt" + "net/url" + + mysqldrv "github.com/go-sql-driver/mysql" // register "mysql" driver + Config type + _ "github.com/jackc/pgx/v5/stdlib" // register "pgx" driver for Redshift + _ "github.com/microsoft/go-mssqldb" // register "sqlserver" driver +) + +// ---- MySQL ---- + +// extractMySQLCredential builds a MySQL DSN from the _credential param. +// Credential: {host, port, user, password, database}. +// Deletes _credential from params. +func extractMySQLCredential(params map[string]any) (dsn string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", fmt.Errorf("credential is required") + } + + host := cred["host"] + if host == "" { + return "", fmt.Errorf("credential must contain a 'host' field") + } + port := cred["port"] + if port == "" { + port = "3306" + } + user := cred["user"] + if user == "" { + return "", fmt.Errorf("credential must contain a 'user' field") + } + password := cred["password"] + database := cred["database"] + if database == "" { + return "", fmt.Errorf("credential must contain a 'database' field") + } + + cfg := mysqldrv.Config{ + User: user, + Passwd: password, + Net: "tcp", + Addr: host + ":" + port, + DBName: database, + ParseTime: true, + } + return cfg.FormatDSN(), nil +} + +// scanSQLRows scans database/sql rows into a slice of maps, stopping at maxRows. +func scanSQLRows(rows *sql.Rows, maxRows int) ([]map[string]any, error) { + cols, err := rows.Columns() + if err != nil { + return nil, err + } + + var result []map[string]any + for rows.Next() && len(result) < maxRows { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, err + } + row := make(map[string]any, len(cols)) + for i, col := range cols { + row[col] = vals[i] + } + result = append(result, row) + } + return result, rows.Err() +} + +// MySQLQueryConnector executes a SELECT query against MySQL. +// Params: query (required), max_rows (optional, default 1000). +// Output: {"rows": [...], "count": N} +type MySQLQueryConnector struct{} + +func (c *MySQLQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("mysql/query: query is required") + } + + maxRows := 1000 + if m, ok := extractInt(params["max_rows"]); ok && m > 0 { + maxRows = m + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + dsn, err := extractMySQLCredential(params) + if err != nil { + return nil, fmt.Errorf("mysql/query: %w", err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("mysql/query: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("mysql/query: connecting: %w", err) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("mysql/query: executing query: %w", err) + } + defer rows.Close() + + result, err := scanSQLRows(rows, maxRows) + if err != nil { + return nil, fmt.Errorf("mysql/query: scanning rows: %w", err) + } + + if result == nil { + result = []map[string]any{} + } + return map[string]any{ + "rows": result, + "count": len(result), + }, nil +} + +// MySQLExecuteConnector executes a non-SELECT statement against MySQL. +// Params: statement (required). +// Output: {"rows_affected": N, "last_insert_id": N} +type MySQLExecuteConnector struct{} + +func (c *MySQLExecuteConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + statement, _ := params["statement"].(string) + if statement == "" { + return nil, fmt.Errorf("mysql/execute: statement is required") + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + dsn, err := extractMySQLCredential(params) + if err != nil { + return nil, fmt.Errorf("mysql/execute: %w", err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("mysql/execute: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("mysql/execute: connecting: %w", err) + } + + res, err := db.ExecContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("mysql/execute: executing statement: %w", err) + } + + rowsAffected, _ := res.RowsAffected() + lastInsertID, _ := res.LastInsertId() + + return map[string]any{ + "rows_affected": rowsAffected, + "last_insert_id": lastInsertID, + }, nil +} + +// ---- MSSQL ---- + +// extractMSSQLCredential builds a SQL Server connection string from the _credential param. +// Credential: {host, port, user, password, database}. +// Deletes _credential from params. +func extractMSSQLCredential(params map[string]any) (dsn string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", fmt.Errorf("credential is required") + } + + host := cred["host"] + if host == "" { + return "", fmt.Errorf("credential must contain a 'host' field") + } + port := cred["port"] + if port == "" { + port = "1433" + } + user := cred["user"] + if user == "" { + return "", fmt.Errorf("credential must contain a 'user' field") + } + password := cred["password"] + database := cred["database"] + if database == "" { + return "", fmt.Errorf("credential must contain a 'database' field") + } + + u := &url.URL{ + Scheme: "sqlserver", + User: url.UserPassword(user, password), + Host: host + ":" + port, + } + q := u.Query() + q.Set("database", database) + u.RawQuery = q.Encode() + return u.String(), nil +} + +// MSSQLQueryConnector executes a SELECT query against SQL Server. +// Params: query (required), max_rows (optional, default 1000). +// Output: {"rows": [...], "count": N} +type MSSQLQueryConnector struct{} + +func (c *MSSQLQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("mssql/query: query is required") + } + + maxRows := 1000 + if m, ok := extractInt(params["max_rows"]); ok && m > 0 { + maxRows = m + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + dsn, err := extractMSSQLCredential(params) + if err != nil { + return nil, fmt.Errorf("mssql/query: %w", err) + } + + db, err := sql.Open("sqlserver", dsn) + if err != nil { + return nil, fmt.Errorf("mssql/query: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("mssql/query: connecting: %w", err) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("mssql/query: executing query: %w", err) + } + defer rows.Close() + + result, err := scanSQLRows(rows, maxRows) + if err != nil { + return nil, fmt.Errorf("mssql/query: scanning rows: %w", err) + } + + if result == nil { + result = []map[string]any{} + } + return map[string]any{ + "rows": result, + "count": len(result), + }, nil +} + +// MSSQLExecuteConnector executes a non-SELECT statement against SQL Server. +// Params: statement (required). +// Output: {"rows_affected": N, "last_insert_id": N} +type MSSQLExecuteConnector struct{} + +func (c *MSSQLExecuteConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + statement, _ := params["statement"].(string) + if statement == "" { + return nil, fmt.Errorf("mssql/execute: statement is required") + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + dsn, err := extractMSSQLCredential(params) + if err != nil { + return nil, fmt.Errorf("mssql/execute: %w", err) + } + + db, err := sql.Open("sqlserver", dsn) + if err != nil { + return nil, fmt.Errorf("mssql/execute: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("mssql/execute: connecting: %w", err) + } + + res, err := db.ExecContext(ctx, statement, args...) + if err != nil { + return nil, fmt.Errorf("mssql/execute: executing statement: %w", err) + } + + rowsAffected, _ := res.RowsAffected() + + out := map[string]any{"rows_affected": rowsAffected} + if lastInsertID, err := res.LastInsertId(); err == nil { + out["last_insert_id"] = lastInsertID + } + return out, nil +} + +// ---- Redshift ---- + +// extractRedshiftCredential builds a pgx connection string for Redshift. +// Credential: {host, port, user, password, database}. +// Deletes _credential from params. +func extractRedshiftCredential(params map[string]any) (connStr string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", fmt.Errorf("credential is required") + } + + host := cred["host"] + if host == "" { + return "", fmt.Errorf("credential must contain a 'host' field") + } + port := cred["port"] + if port == "" { + port = "5439" + } + user := cred["user"] + if user == "" { + return "", fmt.Errorf("credential must contain a 'user' field") + } + password := cred["password"] + database := cred["database"] + if database == "" { + return "", fmt.Errorf("credential must contain a 'database' field") + } + + u := &url.URL{ + Scheme: "postgres", + User: url.UserPassword(user, password), + Host: host + ":" + port, + Path: "/" + database, + } + q := u.Query() + q.Set("sslmode", "require") + u.RawQuery = q.Encode() + return u.String(), nil +} + +// RedshiftQueryConnector executes a SQL query against Amazon Redshift (Postgres-compatible). +// Params: query (required), max_rows (optional, default 1000). +// Output: {"rows": [...], "count": N} +type RedshiftQueryConnector struct{} + +func (c *RedshiftQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("redshift/query: query is required") + } + + maxRows := 1000 + if m, ok := extractInt(params["max_rows"]); ok && m > 0 { + maxRows = m + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + connStr, err := extractRedshiftCredential(params) + if err != nil { + return nil, fmt.Errorf("redshift/query: %w", err) + } + + db, err := sql.Open("pgx", connStr) + if err != nil { + return nil, fmt.Errorf("redshift/query: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("redshift/query: connecting: %w", err) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("redshift/query: executing query: %w", err) + } + defer rows.Close() + + result, err := scanSQLRows(rows, maxRows) + if err != nil { + return nil, fmt.Errorf("redshift/query: scanning rows: %w", err) + } + + if result == nil { + result = []map[string]any{} + } + return map[string]any{ + "rows": result, + "count": len(result), + }, nil +} diff --git a/packages/engine/internal/connector/mysql_test.go b/packages/engine/internal/connector/mysql_test.go new file mode 100644 index 0000000..4a5d59f --- /dev/null +++ b/packages/engine/internal/connector/mysql_test.go @@ -0,0 +1,203 @@ +package connector + +import ( + "testing" +) + +// ---- MySQL ---- + +func TestMySQLQueryConnector_MissingQuery(t *testing.T) { + c := &MySQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "localhost", + "user": "root", + "password": "secret", + "database": "mydb", + }, + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestMySQLQueryConnector_MissingCredential(t *testing.T) { + c := &MySQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestMySQLQueryConnector_MissingHost(t *testing.T) { + c := &MySQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + "_credential": map[string]string{ + "user": "root", + "password": "secret", + "database": "mydb", + }, + }) + if err == nil { + t.Fatal("expected error for missing host") + } +} + +func TestMySQLExecuteConnector_MissingStatement(t *testing.T) { + c := &MySQLExecuteConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "localhost", + "user": "root", + "password": "secret", + "database": "mydb", + }, + }) + if err == nil { + t.Fatal("expected error for missing statement") + } +} + +func TestMySQLExecuteConnector_MissingCredential(t *testing.T) { + c := &MySQLExecuteConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "statement": "INSERT INTO t VALUES (1)", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestMySQLQueryConnector_MissingArgs(t *testing.T) { + // args is optional — omitting it should not cause a validation error. + c := &MySQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + // no args, no credential — should fail on credential, not on args + "_credential": map[string]string{ + "host": "localhost", + "user": "root", + "password": "secret", + "database": "mydb", + }, + }) + // Will fail trying to connect — that's fine; the point is no panic on missing args. + _ = err +} + +func TestRegistry_MySQLConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "mysql/query", + "mysql/execute", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} + +// ---- MSSQL ---- + +func TestMSSQLQueryConnector_MissingQuery(t *testing.T) { + c := &MSSQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "localhost", + "user": "sa", + "password": "secret", + "database": "mydb", + }, + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestMSSQLQueryConnector_MissingCredential(t *testing.T) { + c := &MSSQLQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestMSSQLExecuteConnector_MissingStatement(t *testing.T) { + c := &MSSQLExecuteConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "localhost", + "user": "sa", + "password": "secret", + "database": "mydb", + }, + }) + if err == nil { + t.Fatal("expected error for missing statement") + } +} + +func TestMSSQLExecuteConnector_MissingCredential(t *testing.T) { + c := &MSSQLExecuteConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "statement": "INSERT INTO t VALUES (1)", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_MSSQLConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "mssql/query", + "mssql/execute", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} + +// ---- Redshift ---- + +func TestRedshiftQueryConnector_MissingQuery(t *testing.T) { + c := &RedshiftQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "host": "my-cluster.us-east-1.redshift.amazonaws.com", + "user": "awsuser", + "password": "secret", + "database": "dev", + }, + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestRedshiftQueryConnector_MissingCredential(t *testing.T) { + c := &RedshiftQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_RedshiftConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "redshift/query", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/provider_bedrock.go b/packages/engine/internal/connector/provider_bedrock.go index f903512..5383255 100644 --- a/packages/engine/internal/connector/provider_bedrock.go +++ b/packages/engine/internal/connector/provider_bedrock.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "strings" "github.com/aws/aws-sdk-go-v2/aws" @@ -128,6 +129,9 @@ func (p *BedrockProvider) ChatCompletion(ctx context.Context, req *ChatRequest) // Set max tokens if specified. if req.MaxTokens > 0 { + if req.MaxTokens > math.MaxInt32 { + return nil, fmt.Errorf("bedrock: max_tokens value %d exceeds maximum allowed (%d)", req.MaxTokens, math.MaxInt32) + } input.InferenceConfig = &brtypes.InferenceConfiguration{ MaxTokens: aws.Int32(int32(req.MaxTokens)), } diff --git a/packages/engine/internal/connector/salesforce.go b/packages/engine/internal/connector/salesforce.go new file mode 100644 index 0000000..1ce79d4 --- /dev/null +++ b/packages/engine/internal/connector/salesforce.go @@ -0,0 +1,168 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const salesforceAPIVersion = "v59.0" + +// extractSalesforceCredential extracts access_token and instance_url from _credential. +// Accepts both map[string]string and map[string]any credential shapes. +func extractSalesforceCredential(params map[string]any) (instanceURL, token string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", fmt.Errorf("credential is required") + } + + instanceURL = cred["instance_url"] + if instanceURL == "" { + return "", "", fmt.Errorf("credential must contain an 'instance_url' field") + } + token = cred["access_token"] + if token == "" { + return "", "", fmt.Errorf("credential must contain an 'access_token' field") + } + return instanceURL, token, nil +} + +// SalesforceQueryConnector executes a SOQL query against Salesforce. +type SalesforceQueryConnector struct { + Client *http.Client + baseURL string // override for testing (replaces instanceURL+/services/data/v59.0) +} + +func (c *SalesforceQueryConnector) dataURL(instanceURL, path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return instanceURL + "/services/data/" + salesforceAPIVersion + path +} + +func (c *SalesforceQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + instanceURL, token, err := extractSalesforceCredential(params) + if err != nil { + return nil, fmt.Errorf("salesforce/query: %w", err) + } + + soql, _ := params["soql"].(string) + if soql == "" { + return nil, fmt.Errorf("salesforce/query: soql is required") + } + + endpoint := c.dataURL(instanceURL, "/query?q="+url.QueryEscape(soql)) + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("salesforce/query: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("salesforce/query: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("salesforce/query: reading response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("salesforce/query: API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + var result struct { + Records []any `json:"records"` + TotalSize int `json:"totalSize"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("salesforce/query: parsing response: %w", err) + } + + return map[string]any{ + "records": result.Records, + "total_size": result.TotalSize, + "count": len(result.Records), + }, nil +} + +// SalesforceCreateRecordConnector creates a Salesforce sObject record. +type SalesforceCreateRecordConnector struct { + Client *http.Client + baseURL string // override for testing (replaces instanceURL+/services/data/v59.0) +} + +func (c *SalesforceCreateRecordConnector) dataURL(instanceURL, path string) string { + if c.baseURL != "" { + return c.baseURL + path + } + return instanceURL + "/services/data/" + salesforceAPIVersion + path +} + +func (c *SalesforceCreateRecordConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + instanceURL, token, err := extractSalesforceCredential(params) + if err != nil { + return nil, fmt.Errorf("salesforce/create_record: %w", err) + } + + objectType, _ := params["object_type"].(string) + if objectType == "" { + return nil, fmt.Errorf("salesforce/create_record: object_type is required") + } + fields, ok := params["fields"].(map[string]any) + if !ok || len(fields) == 0 { + return nil, fmt.Errorf("salesforce/create_record: fields is required") + } + + reqJSON, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("salesforce/create_record: marshaling request: %w", err) + } + + path := fmt.Sprintf("/sobjects/%s", url.PathEscape(objectType)) + req, err := http.NewRequestWithContext(ctx, "POST", c.dataURL(instanceURL, path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("salesforce/create_record: creating request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("salesforce/create_record: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("salesforce/create_record: reading response: %w", err) + } + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("salesforce/create_record: API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + return parseJSONBody(respBody, "salesforce/create_record") +} diff --git a/packages/engine/internal/connector/salesforce_test.go b/packages/engine/internal/connector/salesforce_test.go new file mode 100644 index 0000000..5089dd9 --- /dev/null +++ b/packages/engine/internal/connector/salesforce_test.go @@ -0,0 +1,193 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestSalesforceQueryConnector_QueriesRecords(t *testing.T) { + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer sf-token" { + t.Errorf("unexpected Authorization: %s", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "records": []any{map[string]any{"Id": "001"}, map[string]any{"Id": "002"}}, + "totalSize": 2, + }) + })) + defer srv.Close() + + c := &SalesforceQueryConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "sf-token", + "instance_url": "https://myorg.salesforce.com", + }, + "soql": "SELECT Id FROM Account", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if gotPath != "/query" { + t.Errorf("path = %q, want /query", gotPath) + } + if out["count"].(int) != 2 { + t.Errorf("expected count=2, got %v", out["count"]) + } + if out["total_size"].(int) != 2 { + t.Errorf("expected total_size=2, got %v", out["total_size"]) + } +} + +func TestSalesforceQueryConnector_SOQLEncoded(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + decoded, _ := url.QueryUnescape(q) + if !strings.Contains(decoded, "SELECT") { + t.Errorf("expected SOQL in query param, got %q", q) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"records": []any{}, "totalSize": 0}) + })) + defer srv.Close() + + c := &SalesforceQueryConnector{baseURL: srv.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "tok", + "instance_url": "https://myorg.salesforce.com", + }, + "soql": "SELECT Id, Name FROM Contact WHERE Email = 'alice@example.com'", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } +} + +func TestSalesforceQueryConnector_MissingSOQL(t *testing.T) { + c := &SalesforceQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "tok", + "instance_url": "https://myorg.salesforce.com", + }, + }) + if err == nil { + t.Fatal("expected error for missing soql") + } +} + +func TestSalesforceQueryConnector_MissingCredential(t *testing.T) { + c := &SalesforceQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "soql": "SELECT Id FROM Account", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestSalesforceQueryConnector_MissingInstanceURL(t *testing.T) { + c := &SalesforceQueryConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"access_token": "tok"}, + "soql": "SELECT Id FROM Account", + }) + if err == nil { + t.Fatal("expected error for missing instance_url") + } +} + +func TestSalesforceCreateRecordConnector_CreatesRecord(t *testing.T) { + var gotBody map[string]any + var gotPath string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "id": "001xx000003GYn1", + "success": true, + }) + })) + defer srv.Close() + + c := &SalesforceCreateRecordConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "sf-token", + "instance_url": "https://myorg.salesforce.com", + }, + "object_type": "Account", + "fields": map[string]any{ + "Name": "Acme Corp", + "Website": "https://acme.com", + }, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if gotPath != "/sobjects/Account" { + t.Errorf("path = %q, want /sobjects/Account", gotPath) + } + if gotBody["Name"] != "Acme Corp" { + t.Errorf("Name = %v", gotBody["Name"]) + } + if out["id"] != "001xx000003GYn1" { + t.Errorf("id = %v", out["id"]) + } +} + +func TestSalesforceCreateRecordConnector_MissingObjectType(t *testing.T) { + c := &SalesforceCreateRecordConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "tok", + "instance_url": "https://myorg.salesforce.com", + }, + "fields": map[string]any{"Name": "Acme"}, + }) + if err == nil { + t.Fatal("expected error for missing object_type") + } +} + +func TestSalesforceCreateRecordConnector_MissingFields(t *testing.T) { + c := &SalesforceCreateRecordConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{ + "access_token": "tok", + "instance_url": "https://myorg.salesforce.com", + }, + "object_type": "Account", + }) + if err == nil { + t.Fatal("expected error for missing fields") + } +} + +func TestRegistry_SalesforceConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"salesforce/query", "salesforce/create_record"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/snowflake.go b/packages/engine/internal/connector/snowflake.go new file mode 100644 index 0000000..41d3e86 --- /dev/null +++ b/packages/engine/internal/connector/snowflake.go @@ -0,0 +1,123 @@ +package connector + +import ( + "context" + "database/sql" + "fmt" + + sf "github.com/snowflakedb/gosnowflake" // named import registers "snowflake" driver via init() +) + +// extractSnowflakeCredential pulls Snowflake connection fields from the _credential param. +// Schema defaults to "PUBLIC" if empty. +// Deletes _credential from params. +func extractSnowflakeCredential(params map[string]any) (account, user, password, database, schema, warehouse string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", "", "", "", "", "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", "", "", "", "", "", fmt.Errorf("credential is required") + } + + account = cred["account"] + if account == "" { + return "", "", "", "", "", "", fmt.Errorf("credential must contain an 'account' field") + } + user = cred["user"] + if user == "" { + return "", "", "", "", "", "", fmt.Errorf("credential must contain a 'user' field") + } + password = cred["password"] + if password == "" { + return "", "", "", "", "", "", fmt.Errorf("credential must contain a 'password' field") + } + database = cred["database"] + schema = cred["schema"] + if schema == "" { + schema = "PUBLIC" + } + warehouse = cred["warehouse"] + return account, user, password, database, schema, warehouse, nil +} + +// SnowflakeQueryConnector executes a SQL query against Snowflake. +// Params: query (required), max_rows (optional, default 1000). +// Output: {"rows": [...], "count": N} +type SnowflakeQueryConnector struct{} + +func (c *SnowflakeQueryConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + query, _ := params["query"].(string) + if query == "" { + return nil, fmt.Errorf("snowflake/query: query is required") + } + + maxRows := 1000 + if m, ok := extractInt(params["max_rows"]); ok && m > 0 { + maxRows = m + } + + var args []any + if rawArgs, ok := params["args"].([]any); ok { + args = rawArgs + } + + account, user, password, database, schema, warehouse, err := extractSnowflakeCredential(params) + if err != nil { + return nil, fmt.Errorf("snowflake/query: %w", err) + } + + dsn, err := sf.DSN(&sf.Config{ + Account: account, + User: user, + Password: password, + Database: database, + Schema: schema, + Warehouse: warehouse, + }) + if err != nil { + return nil, fmt.Errorf("snowflake/query: building DSN: %w", err) + } + + db, err := sql.Open("snowflake", dsn) + if err != nil { + return nil, fmt.Errorf("snowflake/query: opening connection: %w", err) + } + defer db.Close() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("snowflake/query: connecting: %w", err) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("snowflake/query: executing query: %w", err) + } + defer rows.Close() + + result, err := scanSQLRows(rows, maxRows) + if err != nil { + return nil, fmt.Errorf("snowflake/query: scanning rows: %w", err) + } + + if result == nil { + result = []map[string]any{} + } + return map[string]any{ + "rows": result, + "count": len(result), + }, nil +} diff --git a/packages/engine/internal/connector/snowflake_test.go b/packages/engine/internal/connector/snowflake_test.go new file mode 100644 index 0000000..6acf607 --- /dev/null +++ b/packages/engine/internal/connector/snowflake_test.go @@ -0,0 +1,97 @@ +package connector + +import ( + "testing" +) + +func TestSnowflakeQueryConnector_MissingCredential(t *testing.T) { + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestSnowflakeQueryConnector_MissingQuery(t *testing.T) { + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "_credential": map[string]string{ + "account": "myaccount.snowflakecomputing.com", + "user": "myuser", + "password": "mypassword", + }, + }) + if err == nil { + t.Fatal("expected error for missing query") + } +} + +func TestSnowflakeQueryConnector_MissingAccount(t *testing.T) { + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + "_credential": map[string]string{ + "user": "myuser", + "password": "mypassword", + }, + }) + if err == nil { + t.Fatal("expected error for missing account") + } +} + +func TestSnowflakeQueryConnector_MissingUser(t *testing.T) { + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + "_credential": map[string]string{ + "account": "myaccount.snowflakecomputing.com", + "password": "mypassword", + }, + }) + if err == nil { + t.Fatal("expected error for missing user") + } +} + +func TestSnowflakeQueryConnector_MissingPassword(t *testing.T) { + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + "_credential": map[string]string{ + "account": "myaccount.snowflakecomputing.com", + "user": "myuser", + }, + }) + if err == nil { + t.Fatal("expected error for missing password") + } +} + +func TestSnowflakeQueryConnector_ArgsOptional(t *testing.T) { + // args is optional — omitting it should not cause a validation error. + c := &SnowflakeQueryConnector{} + _, err := c.Execute(t.Context(), map[string]any{ + "query": "SELECT 1", + "_credential": map[string]string{ + "account": "myaccount", + "user": "myuser", + "password": "mypassword", + }, + }) + // Will fail trying to connect — that's fine; no panic on missing args. + _ = err +} + +func TestRegistry_SnowflakeConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{ + "snowflake/query", + } { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/teams.go b/packages/engine/internal/connector/teams.go new file mode 100644 index 0000000..e53a0db --- /dev/null +++ b/packages/engine/internal/connector/teams.go @@ -0,0 +1,152 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// extractTeamsCredential extracts the webhook_url from _credential. +// Accepts both map[string]string and map[string]any credential shapes. +func extractTeamsCredential(params map[string]any) (webhookURL string, err error) { + raw, ok := params["_credential"] + if !ok || raw == nil { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + var cred map[string]string + switch v := raw.(type) { + case map[string]string: + cred = v + case map[string]any: + cred = make(map[string]string, len(v)) + for k, val := range v { + if s, ok := val.(string); ok { + cred[k] = s + } + } + default: + return "", fmt.Errorf("credential is required") + } + + webhookURL = cred["webhook_url"] + if webhookURL == "" { + return "", fmt.Errorf("credential must contain a 'webhook_url' field") + } + return webhookURL, nil +} + +// TeamsSendMessageConnector sends a plain text message to a Teams incoming webhook. +type TeamsSendMessageConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *TeamsSendMessageConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + webhookURL, err := extractTeamsCredential(params) + if err != nil { + return nil, fmt.Errorf("teams/send_message: %w", err) + } + + text, _ := params["text"].(string) + if text == "" { + return nil, fmt.Errorf("teams/send_message: text is required") + } + + body := map[string]any{"text": text} + if title, ok := params["title"].(string); ok && title != "" { + body["title"] = title + } + + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("teams/send_message: marshaling request: %w", err) + } + + // Use baseURL for testing; otherwise use the webhook URL from credential. + endpoint := webhookURL + if c.baseURL != "" { + endpoint = c.baseURL + } + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("teams/send_message: creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("teams/send_message: %w", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("teams/send_message: Teams returned %d", resp.StatusCode) + } + + return map[string]any{"ok": true}, nil +} + +// TeamsSendAdaptiveCardConnector sends an adaptive card to a Teams incoming webhook. +type TeamsSendAdaptiveCardConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *TeamsSendAdaptiveCardConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + webhookURL, err := extractTeamsCredential(params) + if err != nil { + return nil, fmt.Errorf("teams/send_adaptive_card: %w", err) + } + + card, ok := params["card"].(map[string]any) + if !ok || card == nil { + return nil, fmt.Errorf("teams/send_adaptive_card: card is required") + } + + body := map[string]any{ + "type": "message", + "attachments": []any{ + map[string]any{ + "contentType": "application/vnd.microsoft.card.adaptive", + "content": card, + }, + }, + } + + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("teams/send_adaptive_card: marshaling request: %w", err) + } + + // Use baseURL for testing; otherwise use the webhook URL from credential. + endpoint := webhookURL + if c.baseURL != "" { + endpoint = c.baseURL + } + + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("teams/send_adaptive_card: creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("teams/send_adaptive_card: %w", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("teams/send_adaptive_card: Teams returned %d", resp.StatusCode) + } + + return map[string]any{"ok": true}, nil +} diff --git a/packages/engine/internal/connector/teams_test.go b/packages/engine/internal/connector/teams_test.go new file mode 100644 index 0000000..935febb --- /dev/null +++ b/packages/engine/internal/connector/teams_test.go @@ -0,0 +1,135 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTeamsSendMessageConnector_SendsMessage(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + w.Write([]byte("1")) + })) + defer srv.Close() + + c := &TeamsSendMessageConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"webhook_url": "https://teams.example.com/webhook"}, + "text": "Hello, Teams!", + "title": "Alert", + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["ok"] != true { + t.Errorf("ok = %v, want true", out["ok"]) + } + if gotBody["text"] != "Hello, Teams!" { + t.Errorf("text = %v", gotBody["text"]) + } + if gotBody["title"] != "Alert" { + t.Errorf("title = %v", gotBody["title"]) + } +} + +func TestTeamsSendMessageConnector_MissingCredential(t *testing.T) { + c := &TeamsSendMessageConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "text": "Hello", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestTeamsSendMessageConnector_MissingWebhookURL(t *testing.T) { + c := &TeamsSendMessageConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{}, + "text": "Hello", + }) + if err == nil { + t.Fatal("expected error for missing webhook_url") + } +} + +func TestTeamsSendMessageConnector_MissingText(t *testing.T) { + c := &TeamsSendMessageConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"webhook_url": "https://teams.example.com/webhook"}, + }) + if err == nil { + t.Fatal("expected error for missing text") + } +} + +func TestTeamsSendAdaptiveCardConnector_SendsCard(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&gotBody) + w.WriteHeader(http.StatusOK) + w.Write([]byte("1")) + })) + defer srv.Close() + + card := map[string]any{ + "type": "AdaptiveCard", + "version": "1.4", + "body": []any{map[string]any{"type": "TextBlock", "text": "Hello"}}, + } + + c := &TeamsSendAdaptiveCardConnector{baseURL: srv.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"webhook_url": "https://teams.example.com/webhook"}, + "card": card, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if out["ok"] != true { + t.Errorf("ok = %v, want true", out["ok"]) + } + if gotBody["type"] != "message" { + t.Errorf("type = %v, want message", gotBody["type"]) + } + attachments, ok := gotBody["attachments"].([]any) + if !ok || len(attachments) == 0 { + t.Fatalf("expected attachments, got %v", gotBody["attachments"]) + } + att := attachments[0].(map[string]any) + if att["contentType"] != "application/vnd.microsoft.card.adaptive" { + t.Errorf("contentType = %v", att["contentType"]) + } +} + +func TestTeamsSendAdaptiveCardConnector_MissingCard(t *testing.T) { + c := &TeamsSendAdaptiveCardConnector{baseURL: "http://unused"} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"webhook_url": "https://teams.example.com/webhook"}, + }) + if err == nil { + t.Fatal("expected error for missing card") + } +} + +func TestRegistry_TeamsConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"teams/send_message", "teams/send_adaptive_card"} { + if _, err := r.Get(action); err != nil { + t.Errorf("%s not registered: %v", action, err) + } + } +}