From d67e2fb752c287dfccac578a9297af8521ffb7d9 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 11:41:59 -0800 Subject: [PATCH 01/13] wip working e2b remotes --- go.work.sum | 17 +- sandbox-sidecar/.gitignore | 4 + sandbox-sidecar/README.md | 104 + sandbox-sidecar/package-lock.json | 3018 +++++++++++++++++ sandbox-sidecar/package.json | 32 + sandbox-sidecar/src/config.ts | 48 + sandbox-sidecar/src/index.ts | 44 + sandbox-sidecar/src/jobs/jobRunner.ts | 39 + sandbox-sidecar/src/jobs/jobStore.ts | 49 + sandbox-sidecar/src/jobs/jobTypes.ts | 39 + sandbox-sidecar/src/logger.ts | 16 + sandbox-sidecar/src/routes/runRoutes.ts | 80 + sandbox-sidecar/src/runners/e2bRunner.ts | 301 ++ sandbox-sidecar/src/runners/index.ts | 14 + sandbox-sidecar/src/runners/localRunner.ts | 228 ++ sandbox-sidecar/src/runners/types.ts | 12 + sandbox-sidecar/src/types/runTypes.ts | 45 + sandbox-sidecar/templates/1.5.5.ts | 39 + sandbox-sidecar/templates/Dockerfile | 31 + sandbox-sidecar/templates/build.ts | 21 + sandbox-sidecar/templates/debug-template.ts | 81 + sandbox-sidecar/templates/test-template.ts | 19 + sandbox-sidecar/tsconfig.json | 16 + taco/cmd/statesman/main.go | 23 +- taco/internal/api/internal.go | 9 +- taco/internal/api/routes.go | 8 + taco/internal/domain/interfaces.go | 62 +- taco/internal/query/types/models.go | 154 +- .../remote_run_activity_repository.go | 106 + taco/internal/sandbox/config.go | 89 + taco/internal/sandbox/e2b.go | 316 ++ taco/internal/sandbox/types.go | 58 + taco/internal/tfe/apply_executor.go | 205 +- taco/internal/tfe/plan_executor.go | 304 +- taco/internal/tfe/runs.go | 34 +- taco/internal/tfe/sandbox_helpers.go | 43 + taco/internal/tfe/tfe.go | 23 +- taco/internal/unit/handler.go | 84 + ...51117000000_create_remote_run_activity.sql | 25 + ...51117000000_create_remote_run_activity.sql | 37 + ...51117000000_create_remote_run_activity.sql | 26 + ui/src/components/UnitCreateForm.tsx | 28 +- 42 files changed, 5775 insertions(+), 156 deletions(-) create mode 100644 sandbox-sidecar/.gitignore create mode 100644 sandbox-sidecar/README.md create mode 100644 sandbox-sidecar/package-lock.json create mode 100644 sandbox-sidecar/package.json create mode 100644 sandbox-sidecar/src/config.ts create mode 100644 sandbox-sidecar/src/index.ts create mode 100644 sandbox-sidecar/src/jobs/jobRunner.ts create mode 100644 sandbox-sidecar/src/jobs/jobStore.ts create mode 100644 sandbox-sidecar/src/jobs/jobTypes.ts create mode 100644 sandbox-sidecar/src/logger.ts create mode 100644 sandbox-sidecar/src/routes/runRoutes.ts create mode 100644 sandbox-sidecar/src/runners/e2bRunner.ts create mode 100644 sandbox-sidecar/src/runners/index.ts create mode 100644 sandbox-sidecar/src/runners/localRunner.ts create mode 100644 sandbox-sidecar/src/runners/types.ts create mode 100644 sandbox-sidecar/src/types/runTypes.ts create mode 100644 sandbox-sidecar/templates/1.5.5.ts create mode 100644 sandbox-sidecar/templates/Dockerfile create mode 100644 sandbox-sidecar/templates/build.ts create mode 100644 sandbox-sidecar/templates/debug-template.ts create mode 100644 sandbox-sidecar/templates/test-template.ts create mode 100644 sandbox-sidecar/tsconfig.json create mode 100644 taco/internal/repositories/remote_run_activity_repository.go create mode 100644 taco/internal/sandbox/config.go create mode 100644 taco/internal/sandbox/e2b.go create mode 100644 taco/internal/sandbox/types.go create mode 100644 taco/internal/tfe/sandbox_helpers.go create mode 100644 taco/migrations/mysql/20251117000000_create_remote_run_activity.sql create mode 100644 taco/migrations/postgres/20251117000000_create_remote_run_activity.sql create mode 100644 taco/migrations/sqlite/20251117000000_create_remote_run_activity.sql diff --git a/go.work.sum b/go.work.sum index 1062a55ac..a470daa24 100644 --- a/go.work.sum +++ b/go.work.sum @@ -2,8 +2,6 @@ ariga.io/atlas v0.14.3-0.20231010104048-0c071bfc9161 h1:xZS2wAf1AzRNA/8iD2LTAXtI ariga.io/atlas v0.14.3-0.20231010104048-0c071bfc9161/go.mod h1:isZrlzJ5cpoCoKFoY9knZug7Lq4pP1cm8g3XciLZ0Pw= ariga.io/atlas v0.32.0 h1:y+77nueMrExLiKlz1CcPKh/nU7VSlWfBbwCShsJyvCw= ariga.io/atlas v0.32.0/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w= -ariga.io/atlas-provider-gorm v0.5.4 h1:64xboUDrP+JHdZOy4juPydHT5UP1kY152b5Gh/xNzmM= -ariga.io/atlas-provider-gorm v0.5.4/go.mod h1:cXt4kxq8KIldPXHoWXC0HvSr8dVI0dIykZt3MZ4AmqE= bazil.org/fuse v0.0.0-20160811212531-371fbbdaa898 h1:SC+c6A1qTFstO9qmB86mPV2IpYme/2ZoEQ0hrP+wo+Q= cel.dev/expr v0.15.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= cel.dev/expr v0.16.0/go.mod h1:TRSuuV7DlVCE/uwv5QbAiW/v8l5O8C4eEPHeu7gf7Sg= @@ -1002,8 +1000,6 @@ github.com/Microsoft/go-winio v0.4.17-0.20210211115548-6eac466e5fa3/go.mod h1:JP github.com/Microsoft/go-winio v0.4.17-0.20210324224401-5516f17a5958/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.4.17/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7-0.20190325164909-8abdbb8205e4/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ= @@ -1024,6 +1020,7 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/PuerkitoBio/purell v1.0.0 h1:0GoNN3taZV6QI81IXgCbxMyEaJDXMSIjArYBCYzVVvs= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2 h1:JCHLVE3B+kJde7bIEo5N4J+ZbLhp0J1Fs+ulyRws4gE= @@ -1049,8 +1046,6 @@ github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbf github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= -github.com/alecthomas/kong v1.9.0 h1:Wgg0ll5Ys7xDnpgYBuBn/wPeLGAuK0NvYmEcisJgrIs= -github.com/alecthomas/kong v1.9.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= @@ -1212,6 +1207,8 @@ github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnx github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible h1:C29Ae4G5GtYyYMm1aztcyj/J5ckgJm2zwdDajFbx1NY= github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= @@ -1389,6 +1386,7 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbp github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c h1:Xo2rK1pzOm0jO6abTPIQwbAmqBIOj132otexc1mmzFc= github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ= github.com/d2g/dhcp4client v1.0.0 h1:suYBsYZIkSlUMEz4TAYCczKf62IA2UWC+O8+KtdOhCo= @@ -1456,6 +1454,7 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21 h1:YEetp8 github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633 h1:H2pdYOb3KQ1/YsqVWoWNLQO+fusocsw354rqGTZtAgw= github.com/emicklei/go-restful v2.9.5+incompatible h1:spTtZBk5DYEvbxMVutUuTyh1Ao2r4iyvLdACqsl/Ljk= @@ -2140,6 +2139,7 @@ github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4 h1:BN/Nyn2nWMo github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY= github.com/rubiojr/go-vhd v0.0.0-20160810183302-0bfd3b39853c h1:ht7N4d/B7Ezf58nvMNVF3OlvDlz9pp+WHVcRNS0nink= @@ -2473,6 +2473,7 @@ golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn5 golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20221106115401-f9659909a136/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20240531132922-fd00a4e0eefc/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= @@ -2485,6 +2486,7 @@ golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -2508,6 +2510,7 @@ golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= @@ -2610,6 +2613,7 @@ golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= @@ -2777,7 +2781,6 @@ gorm.io/driver/sqlserver v1.5.4/go.mod h1:+frZ/qYmuna11zHPlh5oc2O6ZA/lS88Keb0XSH gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.7-0.20240204074919-46816ad31dde/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= diff --git a/sandbox-sidecar/.gitignore b/sandbox-sidecar/.gitignore new file mode 100644 index 000000000..ed7bba3b2 --- /dev/null +++ b/sandbox-sidecar/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env + diff --git a/sandbox-sidecar/README.md b/sandbox-sidecar/README.md new file mode 100644 index 000000000..1ffb5a104 --- /dev/null +++ b/sandbox-sidecar/README.md @@ -0,0 +1,104 @@ +# Sandbox Sidecar + +This package hosts a lightweight Node.js/TypeScript service that exposes the +`/api/v1/sandboxes/runs` API consumed by OpenTaco. It is responsible for: + +1. Accepting Terraform run payloads from the Go backend (archives, state, metadata). +2. Spinning up an execution environment (E2B or a local fallback) to run + `terraform init/plan/apply`. +3. Streaming logs, plan metadata, and updated state back to the main service. + +## Getting Started + +```bash +cd sandbox-sidecar +npm install +npm run dev # hot-reloads with tsx +# or build + run +npm run build +npm start +``` + +The service listens on `PORT` (default `9100`). + +## Configuration + +| Variable | Description | +| --- | --- | +| `PORT` | HTTP port for the sidecar (default `9100`). | +| `SANDBOX_RUNNER` | `local` or `e2b`. Defaults to `local`. | +| `E2B_API_KEY` | Required for `SANDBOX_RUNNER=e2b`. | +| `E2B_DEFAULT_TEMPLATE_ID` | E2B template ID (use base template like `rki5dems9wqfm4r03t7g`). Required for E2B. | +| `E2B_BAREBONES_TEMPLATE_ID` | Same as DEFAULT for now - both use runtime installation. Required for E2B. | +| `LOCAL_TERRAFORM_BIN` | Optional path to the `terraform` binary (defaults to `terraform` in `$PATH`). | + +### Terraform Version Selection + +The sidecar installs Terraform at runtime for any requested version: + +- **Any version** (including 1.5.5 default): Installs Terraform on-demand (~1-2 seconds) +- Supports any Terraform version available from HashiCorp releases +- No pre-built templates needed - simple and reliable + +Users can specify the Terraform version when creating a unit in the UI, or it defaults to 1.5.5. + +### Local Runner + +The bundled local runner is intended for development. It unpacks the provided +archive, writes the optional state payload, and shells out to a Terraform binary +installed on the same host. All stdout/stderr is captured and streamed back to +the requester. + +### E2B Runner + +An opinionated `E2BSandboxRunner` is included as a scaffold. Hook it up to the +official SDK by wiring the `runPlan`/`runApply` helpers with the appropriate E2B API +calls and file upload primitives (see `src/runners/e2bRunner.ts` for the TODOs). +Once implemented, switch `SANDBOX_RUNNER=e2b` and provide `E2B_API_KEY` plus a +template/blueprint identifier. + +## API Surface + +### `POST /api/v1/sandboxes/runs` + +Accepts the payload emitted by the Go backend (`operation`, `run_id`, base64 +archives, etc.) and responds with the created job ID: + +```json +{ "id": "sbx_run_123" } +``` + +### `GET /api/v1/sandboxes/runs/:id` + +Returns the tracked job status: + +```json +{ + "id": "sbx_run_123", + "operation": "plan", + "status": "succeeded", + "logs": "...", + "result": { + "has_changes": false, + "plan_json": "", + "resource_additions": 0, + "resource_changes": 0, + "resource_destructions": 0 + } +} +``` + +`status` transitions through `pending → running → (succeeded|failed)`. On +failure, `error` contains the reason string. A `failed` response never includes +`result`. + +## Development Notes + +- This package intentionally keeps job state in-memory. Use a persistent store + (Redis, Postgres) before running multiple replicas. +- The local runner shell-outs to `terraform`. Sandbox machines therefore need + Terraform installed and accessible in `$PATH`. +- The E2B runner is wired as an interchangeable strategy: extend it or add + additional runners (Kubernetes, Nomad, etc.) as needed without touching + the Go control plane. + diff --git a/sandbox-sidecar/package-lock.json b/sandbox-sidecar/package-lock.json new file mode 100644 index 000000000..cbfc4beaa --- /dev/null +++ b/sandbox-sidecar/package-lock.json @@ -0,0 +1,3018 @@ +{ + "name": "@diggerhq/sandbox-sidecar", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@diggerhq/sandbox-sidecar", + "version": "0.1.0", + "dependencies": { + "@e2b/code-interpreter": "^1.0.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "e2b": "^2.7.0", + "express": "^4.19.2", + "nanoid": "^5.0.7", + "pino": "^9.4.0", + "tar": "^6.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.8.1", + "pino-pretty": "^11.2.2", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "tsx": "^4.16.3", + "typescript": "^5.6.3" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz", + "integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.0.0-rc.3.tgz", + "integrity": "sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.0.0-rc.3.tgz", + "integrity": "sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.2.0", + "@connectrpc/connect": "2.0.0-rc.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@e2b/code-interpreter": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-1.5.1.tgz", + "integrity": "sha512-mkyKjAW2KN5Yt0R1I+1lbH3lo+W/g/1+C2lnwlitXk5wqi/g94SEO41XKdmDf5WWpKG3mnxWDR5d6S/lyjmMEw==", + "license": "MIT", + "dependencies": { + "e2b": "^1.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@e2b/code-interpreter/node_modules/e2b": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-1.13.2.tgz", + "integrity": "sha512-m8acE/MzMAJo1A57DakR2X1Sl5Mt1tcQO2aJfygNaQHLXby/4xsjF0UeJUB70jF7xntiR41pAMbZEHnkzrT9tw==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.6.2", + "@connectrpc/connect": "2.0.0-rc.3", + "@connectrpc/connect-web": "2.0.0-rc.3", + "compare-versions": "^6.1.0", + "openapi-fetch": "^0.9.7", + "platform": "^1.3.6" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@e2b/code-interpreter/node_modules/openapi-fetch": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.9.8.tgz", + "integrity": "sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.8" + } + }, + "node_modules/@e2b/code-interpreter/node_modules/openapi-typescript-helpers": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.8.tgz", + "integrity": "sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dockerfile-ast": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/dockerfile-ast/-/dockerfile-ast-0.7.1.tgz", + "integrity": "sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==", + "license": "MIT", + "dependencies": { + "vscode-languageserver-textdocument": "^1.0.8", + "vscode-languageserver-types": "^3.17.3" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/e2b": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.7.0.tgz", + "integrity": "sha512-pbCbkkdkkY+yIhhtdSE7lM/vhIROtHNI0hNpj8lBphDILNH2qmmjhxU7/wam8/xWRbiWbfuQaOsv100lD32nag==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.6.2", + "@connectrpc/connect": "2.0.0-rc.3", + "@connectrpc/connect-web": "2.0.0-rc.3", + "chalk": "^5.3.0", + "compare-versions": "^6.1.0", + "dockerfile-ast": "^0.7.1", + "glob": "^11.0.3", + "openapi-fetch": "^0.14.1", + "platform": "^1.3.6", + "tar": "^7.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/e2b/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/e2b/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/e2b/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/e2b/node_modules/tar": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz", + "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/e2b/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", + "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-11.3.0.tgz", + "integrity": "sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==", + "license": "MIT" + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/sandbox-sidecar/package.json b/sandbox-sidecar/package.json new file mode 100644 index 000000000..517de0ffa --- /dev/null +++ b/sandbox-sidecar/package.json @@ -0,0 +1,32 @@ +{ + "name": "@diggerhq/sandbox-sidecar", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@e2b/code-interpreter": "^1.0.2", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "e2b": "^2.7.0", + "express": "^4.19.2", + "nanoid": "^5.0.7", + "pino": "^9.4.0", + "tar": "^6.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/node": "^22.8.1", + "pino-pretty": "^11.2.2", + "ts-node": "^10.9.2", + "tslib": "^2.8.1", + "tsx": "^4.16.3", + "typescript": "^5.6.3" + } +} diff --git a/sandbox-sidecar/src/config.ts b/sandbox-sidecar/src/config.ts new file mode 100644 index 000000000..3555a264c --- /dev/null +++ b/sandbox-sidecar/src/config.ts @@ -0,0 +1,48 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +export type RunnerType = "local" | "e2b"; + +export interface AppConfig { + port: number; + runner: RunnerType; + local: { + terraformBinary: string; + }; + e2b: { + apiKey?: string; + defaultTemplateId?: string; // Pre-built template with TF 1.5.5 + bareBonesTemplateId?: string; // Base template for custom versions + }; +} + +const parsePort = (value: string | undefined, fallback: number) => { + if (!value) { + return fallback; + } + const parsed = Number(value); + if (Number.isNaN(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +}; + +export function loadConfig(): AppConfig { + const runnerEnv = (process.env.SANDBOX_RUNNER || "local").toLowerCase(); + const runner: RunnerType = runnerEnv === "e2b" ? "e2b" : "local"; + + return { + port: parsePort(process.env.PORT, 9100), + runner, + local: { + terraformBinary: process.env.LOCAL_TERRAFORM_BIN || "terraform", + }, + e2b: { + apiKey: process.env.E2B_API_KEY, + defaultTemplateId: process.env.E2B_DEFAULT_TEMPLATE_ID, // Pre-built with TF 1.5.5 + bareBonesTemplateId: process.env.E2B_BAREBONES_TEMPLATE_ID, // Base for custom versions + }, + }; +} + diff --git a/sandbox-sidecar/src/index.ts b/sandbox-sidecar/src/index.ts new file mode 100644 index 000000000..ee7a9ccf9 --- /dev/null +++ b/sandbox-sidecar/src/index.ts @@ -0,0 +1,44 @@ +import express from "express"; +import cors from "cors"; +import { loadConfig } from "./config.js"; +import { logger } from "./logger.js"; +import { JobStore } from "./jobs/jobStore.js"; +import { createRunner } from "./runners/index.js"; +import { JobRunner } from "./jobs/jobRunner.js"; +import { createRunRouter } from "./routes/runRoutes.js"; + +const config = loadConfig(); +const app = express(); + +app.use(cors()); +app.use(express.json({ limit: "20mb" })); + +app.get("/healthz", (_req, res) => res.json({ status: "ok" })); + +const store = new JobStore(); +const runner = createRunner(config); +const jobRunner = new JobRunner(store, runner); + +app.use(createRunRouter(store, jobRunner)); + +app.use( + ( + err: Error & { status?: number }, + _req: express.Request, + res: express.Response, + _next: express.NextFunction, + ) => { + logger.error({ err }, "unhandled error"); + res + .status(err.status ?? 500) + .json({ error: err.name || "error", message: err.message }); + }, +); + +app.listen(config.port, () => { + logger.info( + { port: config.port, runner: runner.name }, + "Sandbox sidecar listening", + ); +}); + diff --git a/sandbox-sidecar/src/jobs/jobRunner.ts b/sandbox-sidecar/src/jobs/jobRunner.ts new file mode 100644 index 000000000..6197f9b6a --- /dev/null +++ b/sandbox-sidecar/src/jobs/jobRunner.ts @@ -0,0 +1,39 @@ +import { SandboxRunner } from "../runners/types.js"; +import { JobStore } from "./jobStore.js"; +import { SandboxRunRecord } from "./jobTypes.js"; +import { logger } from "../logger.js"; + +export class JobRunner { + constructor( + private readonly store: JobStore, + private readonly runner: SandboxRunner, + ) {} + + schedule(job: SandboxRunRecord) { + setImmediate(() => this.execute(job.id)); + } + + private async execute(jobId: string) { + const job = this.store.get(jobId); + if (!job) { + return; + } + + this.store.updateStatus(job.id, "running"); + logger.info({ job: job.id, runner: this.runner.name }, "sandbox job started"); + + try { + const result = await this.runner.run(job); + this.store.updateStatus(job.id, "succeeded", result.logs); + this.store.setResult(job.id, result.result); + logger.info({ job: job.id }, "sandbox job succeeded"); + } catch (err) { + const message = + err instanceof Error ? err.message : "sandbox execution failed"; + logger.error({ job: job.id, err }, "sandbox job failed"); + this.store.updateStatus(job.id, "failed", job.logs, message); + this.store.setResult(job.id, undefined); + } + } +} + diff --git a/sandbox-sidecar/src/jobs/jobStore.ts b/sandbox-sidecar/src/jobs/jobStore.ts new file mode 100644 index 000000000..810146389 --- /dev/null +++ b/sandbox-sidecar/src/jobs/jobStore.ts @@ -0,0 +1,49 @@ +import { nanoid } from "nanoid"; +import { + JobStatus, + SandboxRunPayload, + SandboxRunRecord, + SandboxRunResult, +} from "./jobTypes.js"; + +export class JobStore { + private jobs = new Map(); + + create(payload: SandboxRunPayload): SandboxRunRecord { + const id = `sbx_run_${nanoid(10)}`; + const now = new Date(); + const job: SandboxRunRecord = { + id, + payload, + status: "pending", + logs: "", + createdAt: now, + updatedAt: now, + }; + this.jobs.set(id, job); + return job; + } + + get(id: string): SandboxRunRecord | undefined { + return this.jobs.get(id); + } + + updateStatus(id: string, status: JobStatus, logs?: string, error?: string) { + const job = this.jobs.get(id); + if (!job) return; + job.status = status; + if (typeof logs === "string") { + job.logs = logs; + } + job.error = error; + job.updatedAt = new Date(); + } + + setResult(id: string, result: SandboxRunResult | undefined) { + const job = this.jobs.get(id); + if (!job) return; + job.result = result; + job.updatedAt = new Date(); + } +} + diff --git a/sandbox-sidecar/src/jobs/jobTypes.ts b/sandbox-sidecar/src/jobs/jobTypes.ts new file mode 100644 index 000000000..d0859209e --- /dev/null +++ b/sandbox-sidecar/src/jobs/jobTypes.ts @@ -0,0 +1,39 @@ +import { SandboxOperation } from "../types/runTypes.js"; + +export type JobStatus = "pending" | "running" | "succeeded" | "failed"; + +export interface SandboxRunPayload { + operation: SandboxOperation; + runId: string; + planId?: string; + orgId: string; + unitId: string; + configurationVersionId: string; + isDestroy: boolean; + terraformVersion?: string; + workingDirectory?: string; + configArchive: string; + state?: string; + metadata?: Record; +} + +export interface SandboxRunResult { + hasChanges?: boolean; + resourceAdditions?: number; + resourceChanges?: number; + resourceDestructions?: number; + planJSON?: string; // base64 encoded terraform show -json result + state?: string; // base64 encoded terraform.tfstate +} + +export interface SandboxRunRecord { + id: string; + status: JobStatus; + logs: string; + error?: string; + payload: SandboxRunPayload; + result?: SandboxRunResult; + createdAt: Date; + updatedAt: Date; +} + diff --git a/sandbox-sidecar/src/logger.ts b/sandbox-sidecar/src/logger.ts new file mode 100644 index 000000000..e7a97c753 --- /dev/null +++ b/sandbox-sidecar/src/logger.ts @@ -0,0 +1,16 @@ +import pino from "pino"; + +export const logger = pino({ + level: process.env.LOG_LEVEL || "info", + transport: + process.env.NODE_ENV === "production" + ? undefined + : { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "SYS:standard", + }, + }, +}); + diff --git a/sandbox-sidecar/src/routes/runRoutes.ts b/sandbox-sidecar/src/routes/runRoutes.ts new file mode 100644 index 000000000..4d9d2876b --- /dev/null +++ b/sandbox-sidecar/src/routes/runRoutes.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; +import { + runRequestSchema, + ApiRunResponse, + ApiRunStatusResponse, +} from "../types/runTypes.js"; +import { JobStore } from "../jobs/jobStore.js"; +import { JobRunner } from "../jobs/jobRunner.js"; +import { SandboxRunPayload } from "../jobs/jobTypes.js"; + +export function createRunRouter( + store: JobStore, + runner: JobRunner, +): Router { + const router = Router(); + + router.post("/api/v1/sandboxes/runs", (req, res, next) => { + try { + const parsed = runRequestSchema.parse(req.body); + const payload: SandboxRunPayload = { + operation: parsed.operation, + runId: parsed.run_id, + planId: parsed.plan_id, + orgId: parsed.org_id, + unitId: parsed.unit_id, + configurationVersionId: parsed.configuration_version_id, + isDestroy: parsed.is_destroy, + terraformVersion: parsed.terraform_version, + workingDirectory: parsed.working_directory, + configArchive: parsed.config_archive, + state: parsed.state, + metadata: parsed.metadata, + }; + + const job = store.create(payload); + runner.schedule(job); + + const response: ApiRunResponse = { id: job.id }; + res.status(202).json(response); + } catch (error) { + next(error); + } + }); + + router.get("/api/v1/sandboxes/runs/:id", (req, res) => { + const job = store.get(req.params.id); + if (!job) { + return res.status(404).json({ + error: "not_found", + message: `Run ${req.params.id} does not exist`, + }); + } + + const response: ApiRunStatusResponse = { + id: job.id, + operation: job.payload.operation, + status: job.status, + logs: job.logs, + error: job.error, + metadata: job.payload.metadata ?? {}, + result: job.result + ? { + has_changes: job.result.hasChanges, + resource_additions: job.result.resourceAdditions, + resource_changes: job.result.resourceChanges, + resource_destructions: job.result.resourceDestructions, + plan_json: job.result.planJSON, + state: job.result.state, + } + : undefined, + created_at: job.createdAt.toISOString(), + updated_at: job.updatedAt.toISOString(), + }; + + res.json(response); + }); + + return router; +} + diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts new file mode 100644 index 000000000..88d454dd6 --- /dev/null +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -0,0 +1,301 @@ +import { Sandbox } from "@e2b/code-interpreter"; +import { SandboxRunner, RunnerOutput } from "./types.js"; +import { SandboxRunRecord, SandboxRunResult } from "../jobs/jobTypes.js"; +import { logger } from "../logger.js"; + +export interface E2BRunnerOptions { + apiKey?: string; + defaultTemplateId?: string; // Pre-built with TF 1.5.5 + bareBonesTemplateId?: string; // Base for custom versions +} + +/** + * E2B runner that executes Terraform commands inside an E2B sandbox. + * Uses the official @e2b/sdk to create sandboxes, upload files, and run commands. + */ +export class E2BSandboxRunner implements SandboxRunner { + readonly name = "e2b"; + + constructor(private readonly options: E2BRunnerOptions) { + if (!options.apiKey) { + throw new Error("E2B_API_KEY is required when SANDBOX_RUNNER=e2b"); + } + if (!options.defaultTemplateId) { + throw new Error("E2B_DEFAULT_TEMPLATE_ID is required when SANDBOX_RUNNER=e2b"); + } + if (!options.bareBonesTemplateId) { + throw new Error("E2B_BAREBONES_TEMPLATE_ID is required when SANDBOX_RUNNER=e2b"); + } + } + + async run(job: SandboxRunRecord): Promise { + if (job.payload.operation === "plan") { + return this.runPlan(job); + } + return this.runApply(job); + } + + private async runPlan(job: SandboxRunRecord): Promise { + const requestedVersion = job.payload.terraformVersion || "1.5.5"; + const sandbox = await this.createSandbox(requestedVersion); + try { + // Install Terraform if not already present + await this.ensureTerraform(sandbox); + + const workDir = await this.setupWorkspace(sandbox, job); + const logs: string[] = []; + + // Run terraform init + await this.runTerraformCommand( + sandbox, + workDir, + ["init", "-input=false", "-no-color"], + logs, + ); + + // Run terraform plan + const planArgs = ["plan", "-input=false", "-no-color", "-out=tfplan.binary"]; + if (job.payload.isDestroy) { + planArgs.splice(1, 0, "-destroy"); + } + await this.runTerraformCommand(sandbox, workDir, planArgs, logs); + + // Get plan JSON + const showResult = await this.runTerraformCommand( + sandbox, + workDir, + ["show", "-json", "tfplan.binary"], + logs, + ); + + const planJSON = showResult.stdout; + const summary = this.summarizePlan(planJSON); + const result: SandboxRunResult = { + hasChanges: summary.hasChanges, + resourceAdditions: summary.additions, + resourceChanges: summary.changes, + resourceDestructions: summary.destroys, + planJSON: Buffer.from(planJSON, "utf8").toString("base64"), + }; + + return { logs: logs.join("\n"), result }; + } finally { + await sandbox.kill(); + } + } + + private async runApply(job: SandboxRunRecord): Promise { + const requestedVersion = job.payload.terraformVersion || "1.5.5"; + const sandbox = await this.createSandbox(requestedVersion); + try { + // Install Terraform if not already present + await this.ensureTerraform(sandbox); + + const workDir = await this.setupWorkspace(sandbox, job); + const logs: string[] = []; + + // Run terraform init + await this.runTerraformCommand( + sandbox, + workDir, + ["init", "-input=false", "-no-color"], + logs, + ); + + // Run terraform apply/destroy + const applyCommand = job.payload.isDestroy ? "destroy" : "apply"; + await this.runTerraformCommand( + sandbox, + workDir, + [applyCommand, "-auto-approve", "-input=false", "-no-color"], + logs, + ); + + // Read the state file + const statePath = `${workDir}/terraform.tfstate`; + const stateContent = await sandbox.files.read(statePath); + const result: SandboxRunResult = { + state: Buffer.from(stateContent, "utf8").toString("base64"), + }; + + return { logs: logs.join("\n"), result }; + } finally { + await sandbox.kill(); + } + } + + private async createSandbox(requestedVersion?: string): Promise { + // Select template based on requested Terraform version + let templateId: string; + let needsInstall = false; + const version = requestedVersion || "1.5.5"; + + if (version === "1.5.5" && this.options.defaultTemplateId) { + // Use pre-built template with TF 1.5.5 + templateId = this.options.defaultTemplateId; + needsInstall = false; + logger.info({ templateId, version: "1.5.5" }, "using pre-built template with Terraform 1.5.5"); + } else if (this.options.bareBonesTemplateId) { + // Use bare-bones template for custom version + templateId = this.options.bareBonesTemplateId; + needsInstall = true; + logger.info({ templateId, version }, "using bare-bones template for custom Terraform version"); + } else { + throw new Error("E2B templates not configured. Set E2B_DEFAULT_TEMPLATE_ID and E2B_BAREBONES_TEMPLATE_ID"); + } + + logger.info({ templateId }, "creating E2B sandbox"); + const sandbox = await Sandbox.create(templateId, { + apiKey: this.options.apiKey, + }); + logger.info({ sandboxId: sandbox.sandboxId }, "E2B sandbox created"); + + // Store whether we need to install TF + (sandbox as any)._needsTerraformInstall = needsInstall; + (sandbox as any)._requestedTerraformVersion = version; + + return sandbox; + } + + private async ensureTerraform(sandbox: Sandbox): Promise { + // Always check if terraform is actually installed, even in pre-built templates + logger.info("checking for Terraform installation"); + const checkResult = await sandbox.commands.run("which terraform 2>/dev/null || echo 'not-found'"); + if (!checkResult.stdout.includes("not-found")) { + const versionCheck = await sandbox.commands.run("terraform version"); + logger.info({ + path: checkResult.stdout.trim(), + version: versionCheck.stdout.split('\n')[0] + }, "Terraform already installed"); + return; + } + + // If we expected it to be pre-installed but it's not, log a warning + if (!(sandbox as any)._needsTerraformInstall) { + logger.warn("Terraform not found in pre-built template, installing at runtime"); + } + + // Use requested version or default + const terraformVersion = (sandbox as any)._requestedTerraformVersion || "1.9.8"; + logger.info({ version: terraformVersion }, "installing Terraform in sandbox"); + + // Download and install Terraform binary directly (faster and simpler) + const installScript = ` + set -e + cd /tmp + wget -q https://releases.hashicorp.com/terraform/${terraformVersion}/terraform_${terraformVersion}_linux_amd64.zip + unzip -q terraform_${terraformVersion}_linux_amd64.zip + sudo mv terraform /usr/local/bin/ + sudo chmod +x /usr/local/bin/terraform + terraform version + `; + + const result = await sandbox.commands.run(installScript); + logger.info({ + version: result.stdout.trim() + }, "Terraform installation complete"); + } + + private async setupWorkspace( + sandbox: Sandbox, + job: SandboxRunRecord, + ): Promise { + // Use /home/user which is writable in E2B sandboxes + const workDir = "/home/user/workspace"; + await sandbox.commands.run(`mkdir -p ${workDir}`); + + // Write the config archive + const archivePath = `${workDir}/bundle.tar.gz`; + const archiveBuffer = Buffer.from(job.payload.configArchive, "base64"); + await sandbox.files.write(archivePath, archiveBuffer.buffer); + + // Extract the archive + await sandbox.commands.run(`cd ${workDir} && tar -xzf bundle.tar.gz`); + + // Determine the execution directory + const execDir = job.payload.workingDirectory + ? `${workDir}/${job.payload.workingDirectory}` + : workDir; + + // Write the state file if provided + if (job.payload.state) { + const statePath = `${execDir}/terraform.tfstate`; + const stateBuffer = Buffer.from(job.payload.state, "base64"); + await sandbox.files.write(statePath, stateBuffer.buffer); + } + + return execDir; + } + + private async runTerraformCommand( + sandbox: Sandbox, + cwd: string, + args: string[], + logBuffer?: string[], + ): Promise<{ stdout: string; stderr: string }> { + const cmdStr = `terraform ${args.join(" ")}`; + logger.info({ cmd: cmdStr, cwd }, "running terraform command in E2B sandbox"); + + const result = await sandbox.commands.run(cmdStr, { + cwd, + envs: { + TF_IN_AUTOMATION: "1", + }, + }); + + const stdout = result.stdout; + const stderr = result.stderr; + const exitCode = result.exitCode; + + const mergedLogs = `${stdout}\n${stderr}`.trim(); + if (logBuffer && mergedLogs.length > 0) { + logBuffer.push(mergedLogs); + } + + if (exitCode !== 0) { + throw new Error( + `terraform ${args[0]} exited with code ${exitCode}\n${mergedLogs}`, + ); + } + + return { stdout, stderr }; + } + + private summarizePlan(planJSON: string) { + try { + const parsed = JSON.parse(planJSON); + const changes = parsed?.resource_changes ?? []; + let additions = 0; + let updates = 0; + let destroys = 0; + + for (const change of changes) { + const actions: string[] = change?.change?.actions ?? []; + if (actions.includes("create")) additions += 1; + if (actions.includes("update")) updates += 1; + if (actions.includes("delete") || actions.includes("destroy")) + destroys += 1; + if (actions.includes("replace")) { + additions += 1; + destroys += 1; + } + } + + return { + hasChanges: additions + updates + destroys > 0, + additions, + changes: updates, + destroys, + }; + } catch (error) { + logger.warn({ error }, "failed to parse terraform plan JSON"); + return { + hasChanges: false, + additions: 0, + changes: 0, + destroys: 0, + }; + } + } +} + diff --git a/sandbox-sidecar/src/runners/index.ts b/sandbox-sidecar/src/runners/index.ts new file mode 100644 index 000000000..77d3c5e50 --- /dev/null +++ b/sandbox-sidecar/src/runners/index.ts @@ -0,0 +1,14 @@ +import { AppConfig } from "../config.js"; +import { SandboxRunner } from "./types.js"; +import { LocalTerraformRunner } from "./localRunner.js"; +import { E2BSandboxRunner } from "./e2bRunner.js"; + +export function createRunner(config: AppConfig): SandboxRunner { + if (config.runner === "e2b") { + return new E2BSandboxRunner(config.e2b); + } + return new LocalTerraformRunner({ + terraformBinary: config.local.terraformBinary, + }); +} + diff --git a/sandbox-sidecar/src/runners/localRunner.ts b/sandbox-sidecar/src/runners/localRunner.ts new file mode 100644 index 000000000..7efef035c --- /dev/null +++ b/sandbox-sidecar/src/runners/localRunner.ts @@ -0,0 +1,228 @@ +import os from "os"; +import path from "path"; +import { promises as fs } from "fs"; +import { spawn } from "child_process"; +import tar from "tar"; +import { SandboxRunner, RunnerOutput } from "./types.js"; +import { SandboxRunRecord, SandboxRunResult } from "../jobs/jobTypes.js"; +import { logger } from "../logger.js"; + +interface LocalRunnerOptions { + terraformBinary: string; +} + +interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export class LocalTerraformRunner implements SandboxRunner { + readonly name = "local"; + + constructor(private readonly options: LocalRunnerOptions) {} + + async run(job: SandboxRunRecord): Promise { + if (job.payload.operation === "plan") { + return this.runPlan(job); + } + return this.runApply(job); + } + + private async runPlan(job: SandboxRunRecord): Promise { + const workspace = await this.createWorkspace(job); + try { + const logs: string[] = []; + await this.runTerraformCommand( + workspace.execCwd, + ["init", "-input=false", "-no-color"], + logs, + ); + + const planArgs = ["plan", "-input=false", "-no-color", "-out=tfplan.binary"]; + if (job.payload.isDestroy) { + planArgs.splice(1, 0, "-destroy"); + } + await this.runTerraformCommand(workspace.execCwd, planArgs, logs); + + const show = await this.runTerraformCommand( + workspace.execCwd, + ["show", "-json", "tfplan.binary"], + ); + logs.push(show.stdout); + + const planJSON = show.stdout; + const summary = summarizePlan(planJSON); + const result: SandboxRunResult = { + hasChanges: summary.hasChanges, + resourceAdditions: summary.additions, + resourceChanges: summary.changes, + resourceDestructions: summary.destroys, + planJSON: Buffer.from(planJSON, "utf8").toString("base64"), + }; + + return { logs: logs.join("\n"), result }; + } finally { + await fs.rm(workspace.root, { recursive: true, force: true }); + } + } + + private async runApply(job: SandboxRunRecord): Promise { + const workspace = await this.createWorkspace(job); + try { + const logs: string[] = []; + await this.runTerraformCommand( + workspace.execCwd, + ["init", "-input=false", "-no-color"], + logs, + ); + + const applyCommand = job.payload.isDestroy ? "destroy" : "apply"; + await this.runTerraformCommand( + workspace.execCwd, + [applyCommand, "-auto-approve", "-input=false", "-no-color"], + logs, + ); + + const statePath = path.join(workspace.execCwd, "terraform.tfstate"); + const stateBuffer = await fs.readFile(statePath); + const result: SandboxRunResult = { + state: stateBuffer.toString("base64"), + }; + + return { logs: logs.join("\n"), result }; + } finally { + await fs.rm(workspace.root, { recursive: true, force: true }); + } + } + + private async createWorkspace(job: SandboxRunRecord) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "taco-sbx-")); + const archivePath = path.join(root, "bundle.tar.gz"); + await fs.writeFile( + archivePath, + Buffer.from(job.payload.configArchive, "base64"), + ); + await tar.x({ + file: archivePath, + cwd: root, + }); + + const workingDirectory = job.payload.workingDirectory + ? path.join(root, job.payload.workingDirectory) + : root; + const execCwd = path.resolve(workingDirectory); + const exists = await pathExists(execCwd); + if (!exists) { + throw new Error( + `Working directory ${job.payload.workingDirectory} not found in archive`, + ); + } + + if (job.payload.state) { + const statePath = path.join(execCwd, "terraform.tfstate"); + await fs.writeFile(statePath, Buffer.from(job.payload.state, "base64")); + } + + return { root, execCwd }; + } + + private async runTerraformCommand( + cwd: string, + args: string[], + logBuffer?: string[], + ): Promise { + const result = await runCommand(this.options.terraformBinary, args, cwd); + const mergedLogs = `${result.stdout}\n${result.stderr}`.trim(); + if (logBuffer && mergedLogs.length > 0) { + logBuffer.push(mergedLogs); + } + if (result.code !== 0) { + throw new Error( + `terraform ${args[0]} exited with code ${result.code}\n${mergedLogs}`, + ); + } + return result; + } +} + +async function runCommand( + command: string, + args: string[], + cwd: string, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { + ...process.env, + TF_IN_AUTOMATION: "1", + }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + resolve({ code: code ?? 0, stdout, stderr }); + }); + }); +} + +function summarizePlan(planJSON: string) { + try { + const parsed = JSON.parse(planJSON); + const changes = parsed?.resource_changes ?? []; + let additions = 0; + let updates = 0; + let destroys = 0; + + for (const change of changes) { + const actions: string[] = change?.change?.actions ?? []; + if (actions.includes("create")) additions += 1; + if (actions.includes("update")) updates += 1; + if (actions.includes("delete") || actions.includes("destroy")) + destroys += 1; + if (actions.includes("replace")) { + additions += 1; + destroys += 1; + } + } + + return { + hasChanges: additions + updates + destroys > 0, + additions, + changes: updates, + destroys, + }; + } catch (error) { + logger.warn({ error }, "failed to parse terraform plan JSON"); + return { + hasChanges: false, + additions: 0, + changes: 0, + destroys: 0, + }; + } +} + +async function pathExists(target: string) { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} + diff --git a/sandbox-sidecar/src/runners/types.ts b/sandbox-sidecar/src/runners/types.ts new file mode 100644 index 000000000..a9d2d1f69 --- /dev/null +++ b/sandbox-sidecar/src/runners/types.ts @@ -0,0 +1,12 @@ +import { SandboxRunRecord, SandboxRunResult } from "../jobs/jobTypes.js"; + +export interface RunnerOutput { + logs: string; + result?: SandboxRunResult; +} + +export interface SandboxRunner { + readonly name: string; + run(job: SandboxRunRecord): Promise; +} + diff --git a/sandbox-sidecar/src/types/runTypes.ts b/sandbox-sidecar/src/types/runTypes.ts new file mode 100644 index 000000000..79139bef2 --- /dev/null +++ b/sandbox-sidecar/src/types/runTypes.ts @@ -0,0 +1,45 @@ +import { z } from "zod"; + +export const sandboxOperationSchema = z.enum(["plan", "apply"]); +export type SandboxOperation = z.infer; + +export const runRequestSchema = z.object({ + operation: sandboxOperationSchema, + run_id: z.string().min(1), + plan_id: z.string().optional(), + org_id: z.string().min(1), + unit_id: z.string().min(1), + configuration_version_id: z.string().min(1), + is_destroy: z.boolean(), + terraform_version: z.string().optional(), + working_directory: z.string().optional(), + config_archive: z.string().min(1), + state: z.string().optional(), + metadata: z.record(z.string()).optional(), +}); + +export type RunRequestSchema = z.infer; + +export interface ApiRunResponse { + id: string; +} + +export interface ApiRunStatusResponse { + id: string; + operation: SandboxOperation; + status: string; + logs: string; + error?: string; + metadata: Record; + result?: { + has_changes?: boolean; + resource_additions?: number; + resource_changes?: number; + resource_destructions?: number; + plan_json?: string; + state?: string; + }; + created_at: string; + updated_at: string; +} + diff --git a/sandbox-sidecar/templates/1.5.5.ts b/sandbox-sidecar/templates/1.5.5.ts new file mode 100644 index 000000000..16318ef82 --- /dev/null +++ b/sandbox-sidecar/templates/1.5.5.ts @@ -0,0 +1,39 @@ +// template.ts +import { Template } from "e2b"; + +const dockerfileContent = ` +FROM ubuntu:22.04 + +# Set Terraform version +ARG TF_VERSION=1.5.5 + +# Avoid interactive prompts +ENV DEBIAN_FRONTEND=noninteractive + +# Install dependencies +RUN apt-get update && \\ + apt-get install -y --no-install-recommends \\ + curl \\ + unzip \\ + ca-certificates \\ + git \\ + bash && \\ + rm -rf /var/lib/apt/lists/* + +# Download and install Terraform +RUN curl -fsSL https://releases.hashicorp.com/terraform/\${TF_VERSION}/terraform_\${TF_VERSION}_linux_amd64.zip \\ + -o terraform.zip && \\ + unzip terraform.zip && \\ + mv terraform /usr/local/bin/ && \\ + chmod +x /usr/local/bin/terraform && \\ + rm terraform.zip + +# Verify installation +RUN terraform version + +# Default working directory +WORKDIR /workspace +`; + +export const template = Template() + .fromDockerfile(dockerfileContent) diff --git a/sandbox-sidecar/templates/Dockerfile b/sandbox-sidecar/templates/Dockerfile new file mode 100644 index 000000000..d2e51f2fe --- /dev/null +++ b/sandbox-sidecar/templates/Dockerfile @@ -0,0 +1,31 @@ +FROM ubuntu:22.04 + +# Force rebuild - change this number to invalidate cache +ENV CACHE_BUST=20251118v3 + +# Install dependencies +RUN apt-get update && \ + apt-get install -y curl unzip wget ca-certificates && \ + rm -rf /var/lib/apt/lists/* + +# Install Terraform 1.5.5 +RUN cd /tmp && \ + curl -fsSL https://releases.hashicorp.com/terraform/1.5.5/terraform_1.5.5_linux_amd64.zip -o terraform.zip && \ + unzip terraform.zip && \ + mv terraform /usr/local/bin/terraform && \ + chmod +x /usr/local/bin/terraform && \ + rm terraform.zip && \ + echo "Terraform binary installed" + +# Verify installation - this will fail the build if terraform is not installed +RUN terraform version && \ + ls -la /usr/local/bin/terraform && \ + which terraform + +# Create user directory +RUN mkdir -p /home/user && chown -R 1000:1000 /home/user + +# Set default user (E2B requirement) +USER 1000 +WORKDIR /home/user + diff --git a/sandbox-sidecar/templates/build.ts b/sandbox-sidecar/templates/build.ts new file mode 100644 index 000000000..0f2d0f79f --- /dev/null +++ b/sandbox-sidecar/templates/build.ts @@ -0,0 +1,21 @@ +// build.ts +import { Template, defaultBuildLogger } from "e2b"; +import { template } from "./test-template.ts"; + +async function main() { + const buildInfo = await Template.build(template, { + alias: "terraform-prebuilt-new", // template name / alias + cpuCount: 4, + memoryMB: 2048, + onBuildLogs: defaultBuildLogger(), + }); + + console.log("Template built:"); + console.log("Template ID:", buildInfo.templateId); + console.log("Build ID:", buildInfo.buildId); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sandbox-sidecar/templates/debug-template.ts b/sandbox-sidecar/templates/debug-template.ts new file mode 100644 index 000000000..dee397d8c --- /dev/null +++ b/sandbox-sidecar/templates/debug-template.ts @@ -0,0 +1,81 @@ +// Debug script to understand what's in the template +import { Sandbox } from "@e2b/code-interpreter"; + +async function debugTemplate() { + console.log("=== E2B Template Debug ===\n"); + // Use the template ID directly to bypass alias caching + const templateId = "vnjk0omiwu39qpbcsyf5"; // Template ID + console.log(`Creating sandbox from template ID ${templateId}...`); + + const sandbox = await Sandbox.create({ + apiKey: process.env.E2B_API_KEY!, + template: templateId, + }); + + console.log("āœ… Sandbox created:", sandbox.sandboxId); + console.log("\n--- Checking filesystem ---"); + + // Check if /usr/local/bin exists + const binCheck = await sandbox.commands.run("ls -la /usr/local/bin/ 2>&1 || echo 'Directory does not exist'"); + console.log("/usr/local/bin/ contents:"); + console.log(binCheck.stdout || binCheck.stderr); + + // Check PATH + const pathCheck = await sandbox.commands.run("echo $PATH"); + console.log("\nPATH:"); + console.log(pathCheck.stdout); + + // Check if /usr/local/bin is in PATH + const pathHasLocal = pathCheck.stdout.includes("/usr/local/bin"); + console.log("/usr/local/bin in PATH:", pathHasLocal); + + // Try to find terraform anywhere in the filesystem + console.log("\n--- Searching entire filesystem for terraform ---"); + const findTf = await sandbox.commands.run("find / -name terraform -type f 2>/dev/null | head -20"); + console.log("Found terraform at:"); + console.log(findTf.stdout || "(none)"); + + // Also search for any files in /usr/local/bin + const localBinFiles = await sandbox.commands.run("find /usr/local/bin -type f 2>/dev/null"); + console.log("\nAll files in /usr/local/bin:"); + console.log(localBinFiles.stdout || "(none)"); + + // Check which user we are + const whoami = await sandbox.commands.run("whoami"); + console.log("\nCurrent user:"); + console.log(whoami.stdout); + + // Test if we can install something else (jq) to see if it's terraform-specific + console.log("\n--- Testing installation of another package (jq) ---"); + const installJq = await sandbox.commands.run("sudo apt-get update -qq && sudo apt-get install -y jq 2>&1 | tail -5"); + console.log("jq installation output:"); + console.log(installJq.stdout); + + const jqCheck = await sandbox.commands.run("which jq && jq --version"); + console.log("\njq check:"); + console.log("Exit code:", jqCheck.exitCode); + console.log("Output:", jqCheck.stdout || jqCheck.stderr); + + // Try running terraform + console.log("\n--- Attempting to run terraform ---"); + const tfResult = await sandbox.commands.run("terraform version 2>&1 || echo 'Command failed'"); + console.log("Exit code:", tfResult.exitCode); + console.log("Output:", tfResult.stdout || tfResult.stderr); + + // Check if curl/unzip are available (should be from our build) + console.log("\n--- Checking installed packages ---"); + const curlCheck = await sandbox.commands.run("which curl"); + console.log("curl:", curlCheck.stdout.trim() || "NOT FOUND"); + + const unzipCheck = await sandbox.commands.run("which unzip"); + console.log("unzip:", unzipCheck.stdout.trim() || "NOT FOUND"); + + const wgetCheck = await sandbox.commands.run("which wget"); + console.log("wget:", wgetCheck.stdout.trim() || "NOT FOUND"); + + await sandbox.close(); + console.log("\nāœ… Debug complete"); +} + +debugTemplate().catch(console.error); + diff --git a/sandbox-sidecar/templates/test-template.ts b/sandbox-sidecar/templates/test-template.ts new file mode 100644 index 000000000..a06f7722e --- /dev/null +++ b/sandbox-sidecar/templates/test-template.ts @@ -0,0 +1,19 @@ +import { Template } from "e2b"; + + +const terraform15 = Template() + .fromUbuntuImage("22.04") + .setUser("root") + .runCmd("apt-get update && apt-get install -y wget unzip") + .runCmd(` + cd /tmp && \ + wget -O terraform.zip https://releases.hashicorp.com/terraform/1.5.7/terraform_1.5.7_linux_amd64.zip && \ + unzip terraform.zip && \ + mv terraform /usr/local/bin/terraform && \ + chmod +x /usr/local/bin/terraform && \ + rm terraform.zip + `) + .setUser("user") + + +export const template = terraform15; \ No newline at end of file diff --git a/sandbox-sidecar/tsconfig.json b/sandbox-sidecar/tsconfig.json new file mode 100644 index 000000000..358e56566 --- /dev/null +++ b/sandbox-sidecar/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "Node", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} + diff --git a/taco/cmd/statesman/main.go b/taco/cmd/statesman/main.go index cbdd2eb81..360ddc7a7 100644 --- a/taco/cmd/statesman/main.go +++ b/taco/cmd/statesman/main.go @@ -19,6 +19,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/queryfactory" "github.com/diggerhq/digger/opentaco/internal/rbac" "github.com/diggerhq/digger/opentaco/internal/repositories" + "github.com/diggerhq/digger/opentaco/internal/sandbox" "github.com/diggerhq/digger/opentaco/internal/storage" "github.com/kelseyhightower/envconfig" "github.com/labstack/echo/v4" @@ -61,7 +62,6 @@ func main() { slog.Info("Query backend initialized", "backend", queryCfg.Backend) - // Initialize storage var blobStore storage.UnitStore switch *storageType { @@ -89,7 +89,6 @@ func main() { slog.Info("Using in-memory storage") } - // sync units to query index existingUnits, err := queryStore.ListUnits(context.Background(), "") if err != nil { @@ -193,6 +192,23 @@ func main() { } analytics.SendEssential("service_startup") + // Initialize sandbox provider (optional) + slog.Info("šŸ” Initializing sandbox provider from environment...") + sandboxProvider, err := sandbox.NewFromEnv() + if err != nil { + slog.Error("āŒ Failed to initialize sandbox provider", "error", err) + os.Exit(1) + } + if sandboxProvider != nil { + slog.Info("āœ… Sandbox provider configured and ready", + "provider", sandboxProvider.Name(), + "env_OPENTACO_SANDBOX_PROVIDER", os.Getenv("OPENTACO_SANDBOX_PROVIDER"), + "env_OPENTACO_E2B_SIDECAR_URL", os.Getenv("OPENTACO_E2B_SIDECAR_URL")) + } else { + slog.Info("ā„¹ļø Sandbox provider disabled or not configured - remote execution will not be available", + "env_OPENTACO_SANDBOX_PROVIDER", os.Getenv("OPENTACO_SANDBOX_PROVIDER")) + } + // Create Echo instance e := echo.New() e.HideBanner = true @@ -208,7 +224,6 @@ func main() { e.Use(echomiddleware.Secure()) e.Use(echomiddleware.CORS()) - // Create a signer for JWTs (this may need to be configured from env vars) signer, err := auth.NewSignerFromEnv() if err != nil { @@ -225,6 +240,7 @@ func main() { RBACManager: rbacManager, // RBAC management Signer: signer, // JWT signing AuthEnabled: !*authDisable, // Auth flag + Sandbox: sandboxProvider, }) // Start server @@ -259,4 +275,3 @@ func main() { analytics.SendEssential("server_shutdown_complete") slog.Info("Server shutdown complete") } - diff --git a/taco/internal/api/internal.go b/taco/internal/api/internal.go index 475c49372..c61330179 100644 --- a/taco/internal/api/internal.go +++ b/taco/internal/api/internal.go @@ -17,7 +17,6 @@ import ( "github.com/labstack/echo/v4" ) - func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { webhookSecret := os.Getenv("OPENTACO_ENABLE_INTERNAL_ENDPOINTS") if webhookSecret == "" { @@ -118,6 +117,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { internal.POST("/units", unitHandler.CreateUnit) internal.GET("/units", unitHandler.ListUnits) internal.GET("/units/:id", unitHandler.GetUnit) + internal.PATCH("/units/:id", unitHandler.UpdateUnit) internal.DELETE("/units/:id", unitHandler.DeleteUnit) internal.GET("/units/:id/download", unitHandler.DownloadUnit) internal.POST("/units/:id/upload", unitHandler.UploadUnit) @@ -145,6 +145,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { var runRepo domain.TFERunRepository var planRepo domain.TFEPlanRepository var configVerRepo domain.TFEConfigurationVersionRepository + var remoteRunActivityRepo domain.RemoteRunActivityRepository if deps.QueryStore != nil { if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { @@ -153,6 +154,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { runRepo = repositories.NewTFERunRepository(db) planRepo = repositories.NewTFEPlanRepository(db) configVerRepo = repositories.NewTFEConfigurationVersionRepository(db) + remoteRunActivityRepo = repositories.NewRemoteRunActivityRepository(db) log.Println("TFE repositories initialized successfully (internal routes)") } } @@ -169,6 +171,8 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { runRepo, planRepo, configVerRepo, + deps.Sandbox, + remoteRunActivityRepo, ) // TFE group with webhook auth (for UI pass-through) @@ -207,7 +211,6 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { tfeInternal.GET("/applies/:id", tfeHandler.GetApply) tfeInternal.GET("/applies/:id/logs", tfeHandler.GetApplyLogs) - log.Println("TFE API endpoints registered at /internal/tfe/api/v2 with webhook auth") // ==================================================================================== @@ -252,6 +255,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { log.Printf("Internal routes registered at /internal/api/* with webhook authentication") } + // wrapWithWebhookRBAC wraps a handler with RBAC permission checking func wrapWithWebhookRBAC(manager *rbac.RBACManager, action rbac.Action, resource string) func(echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { @@ -306,4 +310,3 @@ func wrapWithWebhookRBAC(manager *rbac.RBACManager, action rbac.Action, resource } } } - diff --git a/taco/internal/api/routes.go b/taco/internal/api/routes.go index 2a6887956..a2245194c 100644 --- a/taco/internal/api/routes.go +++ b/taco/internal/api/routes.go @@ -20,6 +20,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/rbac" "github.com/diggerhq/digger/opentaco/internal/repositories" "github.com/diggerhq/digger/opentaco/internal/s3compat" + "github.com/diggerhq/digger/opentaco/internal/sandbox" "github.com/diggerhq/digger/opentaco/internal/storage" "github.com/diggerhq/digger/opentaco/internal/sts" unithandlers "github.com/diggerhq/digger/opentaco/internal/unit" @@ -36,6 +37,7 @@ type Dependencies struct { RBACManager *rbac.RBACManager // RBAC management (RBAC routes only) Signer *authpkg.Signer // JWT signing (auth, middleware) AuthEnabled bool // Whether auth is enabled + Sandbox sandbox.Sandbox // Optional sandbox provider for remote runs } // RegisterRoutes registers all API routes with interface-scoped dependencies. @@ -167,6 +169,7 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { // ListUnits does its own RBAC filtering internally, no middleware needed v1.GET("/units", unitHandler.ListUnits) v1.GET("/units/:id", middleware.JWTOnlyRBACMiddleware(deps.RBACManager, deps.Signer, rbac.ActionUnitRead, "{id}")(unitHandler.GetUnit)) + v1.PATCH("/units/:id", middleware.JWTOnlyRBACMiddleware(deps.RBACManager, deps.Signer, rbac.ActionUnitWrite, "{id}")(unitHandler.UpdateUnit)) v1.DELETE("/units/:id", middleware.JWTOnlyRBACMiddleware(deps.RBACManager, deps.Signer, rbac.ActionUnitDelete, "{id}")(unitHandler.DeleteUnit)) v1.GET("/units/:id/download", middleware.JWTOnlyRBACMiddleware(deps.RBACManager, deps.Signer, rbac.ActionUnitRead, "{id}")(unitHandler.DownloadUnit)) v1.POST("/units/:id/upload", middleware.JWTOnlyRBACMiddleware(deps.RBACManager, deps.Signer, rbac.ActionUnitWrite, "{id}")(unitHandler.UploadUnit)) @@ -182,6 +185,7 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { v1.POST("/units", unitHandler.CreateUnit) v1.GET("/units", unitHandler.ListUnits) v1.GET("/units/:id", unitHandler.GetUnit) + v1.PATCH("/units/:id", unitHandler.UpdateUnit) v1.DELETE("/units/:id", unitHandler.DeleteUnit) v1.GET("/units/:id/download", unitHandler.DownloadUnit) v1.POST("/units/:id/upload", unitHandler.UploadUnit) @@ -253,6 +257,7 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { var runRepo domain.TFERunRepository var planRepo domain.TFEPlanRepository var configVerRepo domain.TFEConfigurationVersionRepository + var remoteRunActivityRepo domain.RemoteRunActivityRepository if deps.QueryStore != nil { if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { @@ -261,6 +266,7 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { runRepo = repositories.NewTFERunRepository(db) planRepo = repositories.NewTFEPlanRepository(db) configVerRepo = repositories.NewTFEConfigurationVersionRepository(db) + remoteRunActivityRepo = repositories.NewRemoteRunActivityRepository(db) log.Println("TFE repositories initialized successfully") } } @@ -275,6 +281,8 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { runRepo, planRepo, configVerRepo, + deps.Sandbox, + remoteRunActivityRepo, ) // Create protected TFE group - opaque tokens only diff --git a/taco/internal/domain/interfaces.go b/taco/internal/domain/interfaces.go index 72c9231b1..45ee24e9c 100644 --- a/taco/internal/domain/interfaces.go +++ b/taco/internal/domain/interfaces.go @@ -22,10 +22,10 @@ type StateOperations interface { Get(ctx context.Context, id string) (*storage.UnitMetadata, error) Download(ctx context.Context, id string) ([]byte, error) GetLock(ctx context.Context, id string) (*storage.LockInfo, error) - + // Write operations Upload(ctx context.Context, id string, data []byte, lockID string) error - + // Lock operations Lock(ctx context.Context, id string, info *storage.LockInfo) error Unlock(ctx context.Context, id string, lockID string) error @@ -40,12 +40,12 @@ type StateOperations interface { // All operations are org-scoped in the new architecture. type UnitManagement interface { StateOperations - + // Admin operations (org-scoped) Create(ctx context.Context, orgID string, name string) (*storage.UnitMetadata, error) List(ctx context.Context, orgID string, prefix string) ([]*storage.UnitMetadata, error) Delete(ctx context.Context, id string) error // Uses UUID - + // Version operations (UUID-based) ListVersions(ctx context.Context, id string) ([]*storage.VersionInfo, error) RestoreVersion(ctx context.Context, id string, versionTimestamp time.Time, lockID string) error @@ -65,22 +65,22 @@ type TFEOperations interface { type TFERunRepository interface { // Create a new run CreateRun(ctx context.Context, run *TFERun) error - + // Get run by ID GetRun(ctx context.Context, runID string) (*TFERun, error) - + // List runs for a unit (workspace) ListRunsForUnit(ctx context.Context, unitID string, limit int) ([]*TFERun, error) - + // Update run status UpdateRunStatus(ctx context.Context, runID string, status string) error - + // Update run with plan ID UpdateRunPlanID(ctx context.Context, runID string, planID string) error - + // Update run status and can_apply together UpdateRunStatusAndCanApply(ctx context.Context, runID string, status string, canApply bool) error - + // Update run with error message (when execution fails) UpdateRunError(ctx context.Context, runID string, errorMessage string) error } @@ -89,13 +89,13 @@ type TFERunRepository interface { type TFEPlanRepository interface { // Create a new plan CreatePlan(ctx context.Context, plan *TFEPlan) error - + // Get plan by ID GetPlan(ctx context.Context, planID string) (*TFEPlan, error) - + // Update plan status and results UpdatePlan(ctx context.Context, planID string, updates *TFEPlanUpdate) error - + // Get plan by run ID GetPlanByRunID(ctx context.Context, runID string) (*TFEPlan, error) } @@ -104,17 +104,24 @@ type TFEPlanRepository interface { type TFEConfigurationVersionRepository interface { // Create a new configuration version CreateConfigurationVersion(ctx context.Context, cv *TFEConfigurationVersion) error - + // Get configuration version by ID GetConfigurationVersion(ctx context.Context, cvID string) (*TFEConfigurationVersion, error) - + // Update configuration version status (and optionally the archive blob ID) UpdateConfigurationVersionStatus(ctx context.Context, cvID string, status string, uploadedAt *time.Time, archiveBlobID *string) error - + // List configuration versions for a unit (workspace) ListConfigurationVersionsForUnit(ctx context.Context, unitID string, limit int) ([]*TFEConfigurationVersion, error) } +// RemoteRunActivityRepository records compute usage for remote plan/apply executions +type RemoteRunActivityRepository interface { + CreateActivity(ctx context.Context, activity *RemoteRunActivity) (string, error) + MarkRunning(ctx context.Context, activityID string, startedAt time.Time, sandboxProvider string) error + MarkCompleted(ctx context.Context, activityID string, status string, completedAt time.Time, duration time.Duration, sandboxJobID *string, errorMessage *string) error +} + // ============================================ // Full Repository Interface // ============================================ @@ -188,12 +195,12 @@ func NormalizeUnitID(id string) string { s := strings.TrimSpace(id) s = strings.ToLower(s) // Normalize to lowercase for case-insensitivity s = strings.Trim(s, "/") - + // Collapse multiple slashes for strings.Contains(s, "//") { s = strings.ReplaceAll(s, "//", "/") } - + return s } @@ -290,3 +297,22 @@ type TFEConfigurationVersion struct { CreatedBy string } +// RemoteRunActivity tracks remote sandbox executions for billing/auditing +type RemoteRunActivity struct { + ID string + RunID string + OrgID string + UnitID string + Operation string + Status string + TriggeredBy string + TriggeredSource string + SandboxProvider string + SandboxJobID *string + StartedAt *time.Time + CompletedAt *time.Time + DurationMS *int64 + ErrorMessage *string + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/taco/internal/query/types/models.go b/taco/internal/query/types/models.go index 4a82761e7..0d7468e8d 100644 --- a/taco/internal/query/types/models.go +++ b/taco/internal/query/types/models.go @@ -1,18 +1,18 @@ package types import ( - "strings" - "time" "github.com/google/uuid" "gorm.io/gorm" + "strings" + "time" ) type Role struct { - ID string `gorm:"type:varchar(36);primaryKey"` - OrgID string `gorm:"type:varchar(36);index;index:idx_roles_org_id_name;uniqueIndex:unique_org_role_name"` // Foreign key to organizations.id (UUID) - Name string `gorm:"type:varchar(255);not null;index;index:idx_roles_org_id_name;uniqueIndex:unique_org_role_name"` // Composite index for (org_id, name) queries + ID string `gorm:"type:varchar(36);primaryKey"` + OrgID string `gorm:"type:varchar(36);index;index:idx_roles_org_id_name;uniqueIndex:unique_org_role_name"` // Foreign key to organizations.id (UUID) + Name string `gorm:"type:varchar(255);not null;index;index:idx_roles_org_id_name;uniqueIndex:unique_org_role_name"` // Composite index for (org_id, name) queries Description string - Permissions []Permission `gorm:"many2many:role_permissions;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"` + Permissions []Permission `gorm:"many2many:role_permissions;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"` CreatedAt time.Time CreatedBy string } @@ -28,7 +28,7 @@ func (Role) TableName() string { return "roles" } type Permission struct { ID string `gorm:"type:varchar(36);primaryKey"` - OrgID string `gorm:"type:varchar(36);index;index:idx_permissions_org_id_name;uniqueIndex:unique_org_permission_name"` // Foreign key to organizations.id (UUID) + OrgID string `gorm:"type:varchar(36);index;index:idx_permissions_org_id_name;uniqueIndex:unique_org_permission_name"` // Foreign key to organizations.id (UUID) Name string `gorm:"type:varchar(255);not null;index;index:idx_permissions_org_id_name;uniqueIndex:unique_org_permission_name"` // Composite index for (org_id, name) queries Description string Rules []Rule `gorm:"constraint:OnDelete:CASCADE"` @@ -46,12 +46,12 @@ func (p *Permission) BeforeCreate(tx *gorm.DB) error { func (Permission) TableName() string { return "permissions" } type Rule struct { - ID string `gorm:"type:varchar(36);primaryKey"` - PermissionID string `gorm:"type:varchar(36);index;not null"` - Effect string `gorm:"size:8;not null;default:allow"` - WildcardAction bool `gorm:"not null;default:false"` - WildcardResource bool `gorm:"not null;default:false"` - ResourcePatterns string `gorm:"type:text;"` + ID string `gorm:"type:varchar(36);primaryKey"` + PermissionID string `gorm:"type:varchar(36);index;not null"` + Effect string `gorm:"size:8;not null;default:allow"` + WildcardAction bool `gorm:"not null;default:false"` + WildcardResource bool `gorm:"not null;default:false"` + ResourcePatterns string `gorm:"type:text;"` Actions []RuleAction `gorm:"constraint:OnDelete:CASCADE"` UnitTargets []RuleUnit `gorm:"constraint:OnDelete:CASCADE"` TagTargets []RuleUnitTag `gorm:"constraint:OnDelete:CASCADE"` @@ -69,6 +69,7 @@ type RuleAction struct { RuleID string `gorm:"type:varchar(36);index;not null"` Action string `gorm:"size:128;not null;index"` } + func (RuleAction) TableName() string { return "rule_actions" } func (ra *RuleAction) BeforeCreate(tx *gorm.DB) error { @@ -83,6 +84,7 @@ type RuleUnit struct { RuleID string `gorm:"type:varchar(36);index;not null"` UnitID string `gorm:"type:varchar(36);index;not null"` } + func (RuleUnit) TableName() string { return "rule_units" } func (ru *RuleUnit) BeforeCreate(tx *gorm.DB) error { @@ -97,6 +99,7 @@ type RuleUnitTag struct { RuleID string `gorm:"type:varchar(36);index;not null"` TagID string `gorm:"type:varchar(36);index;not null"` } + func (RuleUnitTag) TableName() string { return "rule_unit_tags" } func (rut *RuleUnitTag) BeforeCreate(tx *gorm.DB) error { @@ -107,11 +110,11 @@ func (rut *RuleUnitTag) BeforeCreate(tx *gorm.DB) error { } type Organization struct { - ID string `gorm:"type:varchar(36);primaryKey"` - Name string `gorm:"type:varchar(255);not null;index"` // Non-unique - multiple orgs can have same name (e.g., "Personal") - DisplayName string `gorm:"type:varchar(255);not null"` // Friendly name (e.g., "Acme Corp") - shown in UI + ID string `gorm:"type:varchar(36);primaryKey"` + Name string `gorm:"type:varchar(255);not null;index"` // Non-unique - multiple orgs can have same name (e.g., "Personal") + DisplayName string `gorm:"type:varchar(255);not null"` // Friendly name (e.g., "Acme Corp") - shown in UI ExternalOrgID *string `gorm:"type:varchar(500);uniqueIndex"` // External org identifier (optional, nullable) - THIS is unique - CreatedBy string `gorm:"type:varchar(255);not null"` + CreatedBy string `gorm:"type:varchar(255);not null"` CreatedAt time.Time UpdatedAt time.Time } @@ -141,17 +144,17 @@ func (u *User) BeforeCreate(tx *gorm.DB) error { } type Unit struct { - ID string `gorm:"type:varchar(36);primaryKey"` - OrgID string `gorm:"type:varchar(36);index;index:idx_units_org_id_name;uniqueIndex:idx_units_org_name"` // Foreign key to organizations.id (UUID) - Name string `gorm:"type:varchar(255);not null;index;index:idx_units_org_id_name;uniqueIndex:idx_units_org_name"` // Composite index for (org_id, name) queries - Size int64 `gorm:"default:0"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` - Locked bool `gorm:"default:false"` - LockID string `gorm:"default:''"` - LockWho string `gorm:"default:''"` + ID string `gorm:"type:varchar(36);primaryKey"` + OrgID string `gorm:"type:varchar(36);index;index:idx_units_org_id_name;uniqueIndex:idx_units_org_name"` // Foreign key to organizations.id (UUID) + Name string `gorm:"type:varchar(255);not null;index;index:idx_units_org_id_name;uniqueIndex:idx_units_org_name"` // Composite index for (org_id, name) queries + Size int64 `gorm:"default:0"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + Locked bool `gorm:"default:false"` + LockID string `gorm:"default:''"` + LockWho string `gorm:"default:''"` LockCreated *time.Time - Tags []Tag `gorm:"many2many:unit_tags;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"` - + Tags []Tag `gorm:"many2many:unit_tags;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"` + // TFE workspace settings (nullable for non-TFE usage) TFEAutoApply *bool `gorm:"default:null"` TFETerraformVersion *string `gorm:"type:varchar(50);default:null"` @@ -170,7 +173,7 @@ func (Unit) TableName() string { return "units" } type Tag struct { ID string `gorm:"type:varchar(36);primaryKey"` - OrgID string `gorm:"type:varchar(36);index;index:idx_tags_org_id_name;uniqueIndex:unique_org_tag_name"` // Foreign key to organizations.id (UUID) + OrgID string `gorm:"type:varchar(36);index;index:idx_tags_org_id_name;uniqueIndex:unique_org_tag_name"` // Foreign key to organizations.id (UUID) Name string `gorm:"type:varchar(255);not null;index;index:idx_tags_org_id_name;uniqueIndex:unique_org_tag_name"` // Composite index for (org_id, name) queries } @@ -187,6 +190,7 @@ type UnitTag struct { UnitID string `gorm:"type:varchar(36);primaryKey;index"` TagID string `gorm:"type:varchar(36);primaryKey;index"` } + func (UnitTag) TableName() string { return "unit_tags" } type UserRole struct { @@ -194,21 +198,23 @@ type UserRole struct { RoleID string `gorm:"type:varchar(36);primaryKey;index"` OrgID string `gorm:"type:varchar(36);primaryKey;index"` } + func (UserRole) TableName() string { return "user_roles" } type RolePermission struct { RoleID string `gorm:"type:varchar(36);primaryKey;index"` PermissionID string `gorm:"type:varchar(36);primaryKey;index"` } + func (RolePermission) TableName() string { return "role_permissions" } type Token struct { - ID string `gorm:"type:varchar(36);primaryKey"` - UserID string `gorm:"type:varchar(255);index;not null"` // Flexible for external user IDs - OrgID string `gorm:"type:varchar(255);index;not null"` // Flexible for external org IDs - Token string `gorm:"type:varchar(255);uniqueIndex;not null"` - Name string `gorm:"type:varchar(255)"` - Status string `gorm:"type:varchar(20);default:active"` + ID string `gorm:"type:varchar(36);primaryKey"` + UserID string `gorm:"type:varchar(255);index;not null"` // Flexible for external user IDs + OrgID string `gorm:"type:varchar(255);index;not null"` // Flexible for external org IDs + Token string `gorm:"type:varchar(255);uniqueIndex;not null"` + Name string `gorm:"type:varchar(255)"` + Status string `gorm:"type:varchar(20);default:active"` CreatedAt time.Time UpdatedAt time.Time LastUsedAt *time.Time @@ -237,27 +243,27 @@ type TFERun struct { IsDestroy bool `gorm:"default:false"` Message string `gorm:"type:text"` PlanOnly bool `gorm:"default:true"` - AutoApply bool `gorm:"default:false"` // Whether to auto-trigger apply after successful plan + AutoApply bool `gorm:"default:false"` // Whether to auto-trigger apply after successful plan Source string `gorm:"type:varchar(50);default:'cli'"` // 'cli', 'api', 'ui', 'vcs' - + // Actions (stored as fields) IsCancelable bool `gorm:"default:true"` CanApply bool `gorm:"default:false"` - + // Relationships (foreign keys) ConfigurationVersionID string `gorm:"type:varchar(36);not null;index"` PlanID *string `gorm:"type:varchar(36);index"` // Nullable until plan is created ApplyID *string `gorm:"type:varchar(36);index"` // Nullable if plan-only - + // Blob storage references ApplyLogBlobID *string `gorm:"type:varchar(255)"` // Blob ID for apply logs - + // Error tracking ErrorMessage *string `gorm:"type:text"` // Stores error message if run fails - + // Metadata CreatedBy string `gorm:"type:varchar(255)"` - + // Associations Unit *Unit `gorm:"foreignKey:UnitID"` Plan *TFEPlan `gorm:"foreignKey:PlanID"` @@ -288,18 +294,18 @@ type TFEPlan struct { ResourceChanges int `gorm:"default:0"` ResourceDestructions int `gorm:"default:0"` HasChanges bool `gorm:"default:false"` - + // Log storage - reference to blob storage LogBlobID *string `gorm:"type:varchar(255)"` // Reference to blob storage key LogReadURL *string `gorm:"type:text"` // Signed URL for log access (temporary) - + // Plan output/data stored in blob storage or as JSON PlanOutputBlobID *string `gorm:"type:varchar(255)"` // Reference to blob storage for large plans PlanOutputJSON *string `gorm:"type:longtext"` // Inline JSON for smaller plans - + // Metadata CreatedBy string `gorm:"type:varchar(255)"` - + // Associations Run *TFERun `gorm:"foreignKey:RunID"` } @@ -323,29 +329,29 @@ type TFEConfigurationVersion struct { UpdatedAt time.Time `gorm:"autoUpdateTime"` // Configuration version attributes - Status string `gorm:"type:varchar(50);not null;default:'pending'"` - Source string `gorm:"type:varchar(50);default:'cli'"` // 'cli', 'api', 'vcs', 'terraform' - Speculative bool `gorm:"default:false"` // false = normal apply, true = plan-only - AutoQueueRuns bool `gorm:"default:false"` - Provisional bool `gorm:"default:false"` - + Status string `gorm:"type:varchar(50);not null;default:'pending'"` + Source string `gorm:"type:varchar(50);default:'cli'"` // 'cli', 'api', 'vcs', 'terraform' + Speculative bool `gorm:"default:false"` // false = normal apply, true = plan-only + AutoQueueRuns bool `gorm:"default:false"` + Provisional bool `gorm:"default:false"` + // Error handling Error *string `gorm:"type:text"` ErrorMessage *string `gorm:"type:text"` - + // Upload handling - UploadURL *string `gorm:"type:text"` // Signed upload URL (temporary) - UploadedAt *time.Time // When upload completed + UploadURL *string `gorm:"type:text"` // Signed upload URL (temporary) + UploadedAt *time.Time // When upload completed ArchiveBlobID *string `gorm:"type:varchar(255)"` // Reference to stored archive in blob storage - + // Status timestamps stored as JSON StatusTimestamps string `gorm:"type:json;default:'{}'"` // JSON map of status -> timestamp - + // Metadata CreatedBy string `gorm:"type:varchar(255)"` - + // Associations - Unit *Unit `gorm:"foreignKey:UnitID"` + Unit *Unit `gorm:"foreignKey:UnitID"` Runs []TFERun `gorm:"foreignKey:ConfigurationVersionID"` } @@ -362,6 +368,35 @@ func (cv *TFEConfigurationVersion) BeforeCreate(tx *gorm.DB) error { func (TFEConfigurationVersion) TableName() string { return "tfe_configuration_versions" } +// RemoteRunActivityModel captures sandbox execution stats for billing +type RemoteRunActivity struct { + ID string `gorm:"type:varchar(36);primaryKey"` + RunID string `gorm:"type:varchar(36);index;not null"` + OrgID string `gorm:"type:varchar(36);index;not null"` + UnitID string `gorm:"type:varchar(36);index;not null"` + Operation string `gorm:"type:varchar(16);not null"` + Status string `gorm:"type:varchar(32);not null;default:'pending'"` + TriggeredBy string `gorm:"type:varchar(255)"` + TriggeredSource string `gorm:"type:varchar(50)"` + SandboxProvider string `gorm:"type:varchar(50)"` + SandboxJobID *string `gorm:"type:varchar(100)"` + StartedAt *time.Time + CompletedAt *time.Time + DurationMs *int64 `gorm:"type:bigint"` + ErrorMessage *string `gorm:"type:text"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} + +func (rra *RemoteRunActivity) BeforeCreate(tx *gorm.DB) error { + if rra.ID == "" { + rra.ID = uuid.New().String() + } + return nil +} + +func (RemoteRunActivity) TableName() string { return "remote_run_activity" } + var DefaultModels = []any{ &Organization{}, &User{}, @@ -380,4 +415,5 @@ var DefaultModels = []any{ &TFERun{}, &TFEPlan{}, &TFEConfigurationVersion{}, -} \ No newline at end of file + &RemoteRunActivity{}, +} diff --git a/taco/internal/repositories/remote_run_activity_repository.go b/taco/internal/repositories/remote_run_activity_repository.go new file mode 100644 index 000000000..a5f29b656 --- /dev/null +++ b/taco/internal/repositories/remote_run_activity_repository.go @@ -0,0 +1,106 @@ +package repositories + +import ( + "context" + "fmt" + "time" + + "github.com/diggerhq/digger/opentaco/internal/domain" + "github.com/diggerhq/digger/opentaco/internal/query/types" + "gorm.io/gorm" +) + +type RemoteRunActivityRepository struct { + db *gorm.DB +} + +func NewRemoteRunActivityRepository(db *gorm.DB) *RemoteRunActivityRepository { + return &RemoteRunActivityRepository{db: db} +} + +func (r *RemoteRunActivityRepository) CreateActivity(ctx context.Context, activity *domain.RemoteRunActivity) (string, error) { + record := &types.RemoteRunActivity{ + ID: activity.ID, + RunID: activity.RunID, + OrgID: activity.OrgID, + UnitID: activity.UnitID, + Operation: activity.Operation, + Status: activity.Status, + TriggeredBy: activity.TriggeredBy, + TriggeredSource: activity.TriggeredSource, + SandboxProvider: activity.SandboxProvider, + SandboxJobID: activity.SandboxJobID, + StartedAt: activity.StartedAt, + CompletedAt: activity.CompletedAt, + DurationMs: activity.DurationMS, + ErrorMessage: activity.ErrorMessage, + } + + if record.Status == "" { + record.Status = "pending" + } + + if err := r.db.WithContext(ctx).Create(record).Error; err != nil { + return "", fmt.Errorf("failed to create remote run activity: %w", err) + } + + return record.ID, nil +} + +func (r *RemoteRunActivityRepository) MarkRunning(ctx context.Context, activityID string, startedAt time.Time, sandboxProvider string) error { + result := r.db.WithContext(ctx).Model(&types.RemoteRunActivity{}). + Where("id = ?", activityID). + Updates(map[string]interface{}{ + "status": "running", + "started_at": startedAt, + "sandbox_provider": sandboxProvider, + }) + + if result.Error != nil { + return fmt.Errorf("failed to mark remote run activity as running: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("remote run activity not found: %s", activityID) + } + return nil +} + +func (r *RemoteRunActivityRepository) MarkCompleted( + ctx context.Context, + activityID string, + status string, + completedAt time.Time, + duration time.Duration, + sandboxJobID *string, + errorMessage *string, +) error { + updates := map[string]interface{}{ + "status": status, + "completed_at": completedAt, + } + + if duration > 0 { + updates["duration_ms"] = duration.Milliseconds() + } else { + updates["duration_ms"] = nil + } + + if sandboxJobID != nil { + updates["sandbox_job_id"] = *sandboxJobID + } + if errorMessage != nil { + updates["error_message"] = *errorMessage + } + + result := r.db.WithContext(ctx).Model(&types.RemoteRunActivity{}). + Where("id = ?", activityID). + Updates(updates) + + if result.Error != nil { + return fmt.Errorf("failed to mark remote run activity as completed: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("remote run activity not found: %s", activityID) + } + return nil +} diff --git a/taco/internal/sandbox/config.go b/taco/internal/sandbox/config.go new file mode 100644 index 000000000..34ed9b541 --- /dev/null +++ b/taco/internal/sandbox/config.go @@ -0,0 +1,89 @@ +package sandbox + +import ( + "fmt" + "os" + "strings" + "time" +) + +const ( + // ProviderE2B enables the E2B-powered sandbox sidecar. + ProviderE2B = "e2b" +) + +// E2BConfig contains the settings needed to talk to the sidecar service that speaks to E2B. +type E2BConfig struct { + BaseURL string + APIKey string + PollInterval time.Duration + PollTimeout time.Duration + HTTPTimeout time.Duration +} + +// NewFromEnv returns the sandbox provider configured via environment variables. +// Returns (nil, nil) when no sandbox provider is configured. +func NewFromEnv() (Sandbox, error) { + provider := strings.ToLower(strings.TrimSpace(os.Getenv("OPENTACO_SANDBOX_PROVIDER"))) + if provider == "" || provider == "none" || provider == "disabled" { + return nil, nil + } + + switch provider { + case ProviderE2B: + cfg, err := loadE2BConfigFromEnv() + if err != nil { + return nil, err + } + return NewE2BSandbox(cfg) + default: + return nil, fmt.Errorf("unsupported sandbox provider %q", provider) + } +} + +func loadE2BConfigFromEnv() (E2BConfig, error) { + baseURL := strings.TrimSpace(os.Getenv("OPENTACO_E2B_SIDECAR_URL")) + if baseURL == "" { + return E2BConfig{}, fmt.Errorf("OPENTACO_E2B_SIDECAR_URL is required when using the E2B sandbox provider") + } + baseURL = strings.TrimRight(baseURL, "/") + + apiKey := strings.TrimSpace(os.Getenv("OPENTACO_E2B_API_KEY")) + if apiKey == "" { + return E2BConfig{}, fmt.Errorf("OPENTACO_E2B_API_KEY is required when using the E2B sandbox provider") + } + + pollInterval, err := parseDurationWithDefault(os.Getenv("OPENTACO_E2B_POLL_INTERVAL"), 5*time.Second) + if err != nil { + return E2BConfig{}, fmt.Errorf("invalid OPENTACO_E2B_POLL_INTERVAL: %w", err) + } + + pollTimeout, err := parseDurationWithDefault(os.Getenv("OPENTACO_E2B_POLL_TIMEOUT"), 30*time.Minute) + if err != nil { + return E2BConfig{}, fmt.Errorf("invalid OPENTACO_E2B_POLL_TIMEOUT: %w", err) + } + + httpTimeout, err := parseDurationWithDefault(os.Getenv("OPENTACO_E2B_HTTP_TIMEOUT"), 60*time.Second) + if err != nil { + return E2BConfig{}, fmt.Errorf("invalid OPENTACO_E2B_HTTP_TIMEOUT: %w", err) + } + + return E2BConfig{ + BaseURL: baseURL, + APIKey: apiKey, + PollInterval: pollInterval, + PollTimeout: pollTimeout, + HTTPTimeout: httpTimeout, + }, nil +} + +func parseDurationWithDefault(value string, def time.Duration) (time.Duration, error) { + if strings.TrimSpace(value) == "" { + return def, nil + } + parsed, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + return parsed, nil +} diff --git a/taco/internal/sandbox/e2b.go b/taco/internal/sandbox/e2b.go new file mode 100644 index 000000000..4ca13efe4 --- /dev/null +++ b/taco/internal/sandbox/e2b.go @@ -0,0 +1,316 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + e2bRunsPath = "/api/v1/sandboxes/runs" +) + +// e2bSandbox speaks to the Python/TypeScript sidecar that manages E2B sandboxes. +type e2bSandbox struct { + cfg E2BConfig + httpClient *http.Client +} + +// NewE2BSandbox constructs a sandbox implementation that delegates execution to an external sidecar. +func NewE2BSandbox(cfg E2BConfig) (Sandbox, error) { + if cfg.BaseURL == "" { + return nil, fmt.Errorf("E2B sandbox requires a base URL") + } + if cfg.APIKey == "" { + return nil, fmt.Errorf("E2B sandbox requires an API key") + } + if cfg.PollInterval <= 0 { + cfg.PollInterval = 5 * time.Second + } + if cfg.PollTimeout <= 0 { + cfg.PollTimeout = 30 * time.Minute + } + if cfg.HTTPTimeout <= 0 { + cfg.HTTPTimeout = 60 * time.Second + } + + return &e2bSandbox{ + cfg: cfg, + httpClient: &http.Client{ + Timeout: cfg.HTTPTimeout, + }, + }, nil +} + +func (s *e2bSandbox) Name() string { + return ProviderE2B +} + +func (s *e2bSandbox) ExecutePlan(ctx context.Context, req *PlanRequest) (*PlanResult, error) { + if req == nil { + return nil, fmt.Errorf("plan request cannot be nil") + } + + jobID, err := s.startRun(ctx, e2bRunRequest{ + Operation: "plan", + RunID: req.RunID, + PlanID: req.PlanID, + OrgID: req.OrgID, + UnitID: req.UnitID, + ConfigurationVersionID: req.ConfigurationVersionID, + IsDestroy: req.IsDestroy, + TerraformVersion: req.TerraformVersion, + WorkingDirectory: req.WorkingDirectory, + ConfigArchive: base64.StdEncoding.EncodeToString(req.ConfigArchive), + State: encodeOptional(req.State), + Metadata: req.Metadata, + }) + if err != nil { + return nil, err + } + + status, err := s.waitForCompletion(ctx, jobID) + if err != nil { + return nil, err + } + + if status.Result == nil { + return nil, fmt.Errorf("sandbox run %s completed without a result payload", jobID) + } + + var planJSON []byte + if status.Result.PlanJSON != "" { + decoded, err := base64.StdEncoding.DecodeString(status.Result.PlanJSON) + if err != nil { + return nil, fmt.Errorf("failed to decode plan JSON from sandbox: %w", err) + } + planJSON = decoded + } + + return &PlanResult{ + Logs: status.Logs, + HasChanges: boolValue(status.Result.HasChanges), + ResourceAdditions: intValue(status.Result.ResourceAdditions), + ResourceChanges: intValue(status.Result.ResourceChanges), + ResourceDestructions: intValue(status.Result.ResourceDestructions), + PlanJSON: planJSON, + RuntimeRunID: status.ID, + }, nil +} + +func (s *e2bSandbox) ExecuteApply(ctx context.Context, req *ApplyRequest) (*ApplyResult, error) { + if req == nil { + return nil, fmt.Errorf("apply request cannot be nil") + } + + jobID, err := s.startRun(ctx, e2bRunRequest{ + Operation: "apply", + RunID: req.RunID, + PlanID: req.PlanID, + OrgID: req.OrgID, + UnitID: req.UnitID, + ConfigurationVersionID: req.ConfigurationVersionID, + IsDestroy: req.IsDestroy, + TerraformVersion: req.TerraformVersion, + WorkingDirectory: req.WorkingDirectory, + ConfigArchive: base64.StdEncoding.EncodeToString(req.ConfigArchive), + State: encodeOptional(req.State), + Metadata: req.Metadata, + }) + if err != nil { + return nil, err + } + + status, err := s.waitForCompletion(ctx, jobID) + if err != nil { + return nil, err + } + + if status.Result == nil || status.Result.State == "" { + return nil, fmt.Errorf("sandbox run %s completed without returning updated state", jobID) + } + + stateBytes, err := base64.StdEncoding.DecodeString(status.Result.State) + if err != nil { + return nil, fmt.Errorf("failed to decode sandbox state payload: %w", err) + } + + return &ApplyResult{ + Logs: status.Logs, + State: stateBytes, + RuntimeRunID: status.ID, + }, nil +} + +func (s *e2bSandbox) startRun(ctx context.Context, payload e2bRunRequest) (string, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal sandbox payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint(e2bRunsPath), bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("failed to build sandbox request: %w", err) + } + s.decorateHeaders(req) + + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to start sandbox run: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + msg, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("sandbox returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + + var startResp e2bRunStartResponse + if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { + return "", fmt.Errorf("failed to decode sandbox start response: %w", err) + } + if startResp.ID == "" { + return "", fmt.Errorf("sandbox did not return a run identifier") + } + return startResp.ID, nil +} + +func (s *e2bSandbox) waitForCompletion(ctx context.Context, runID string) (*e2bRunStatusResponse, error) { + ctx, cancel := context.WithTimeout(ctx, s.cfg.PollTimeout) + defer cancel() + + ticker := time.NewTicker(s.cfg.PollInterval) + defer ticker.Stop() + + var lastErr error + + for { + status, err := s.fetchStatus(ctx, runID) + if err == nil { + switch strings.ToLower(status.Status) { + case "succeeded", "completed", "done": + return status, nil + case "failed", "errored": + if status.ErrorMessage != "" { + return nil, fmt.Errorf("sandbox run %s failed: %s", runID, status.ErrorMessage) + } + return nil, fmt.Errorf("sandbox run %s failed without an error message", runID) + } + } else { + lastErr = err + } + + select { + case <-ctx.Done(): + if lastErr != nil { + return nil, fmt.Errorf("timed out waiting for sandbox run %s (last error: %v): %w", runID, lastErr, ctx.Err()) + } + return nil, fmt.Errorf("timed out waiting for sandbox run %s: %w", runID, ctx.Err()) + case <-ticker.C: + } + } +} + +func (s *e2bSandbox) fetchStatus(ctx context.Context, runID string) (*e2bRunStatusResponse, error) { + url := s.endpoint(fmt.Sprintf("%s/%s", e2bRunsPath, runID)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to build sandbox status request: %w", err) + } + s.decorateHeaders(req) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to query sandbox status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + msg, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("sandbox status returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + + var status e2bRunStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("failed to decode sandbox status response: %w", err) + } + return &status, nil +} + +func (s *e2bSandbox) decorateHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.cfg.APIKey) + req.Header.Set("Accept", "application/json") +} + +func (s *e2bSandbox) endpoint(path string) string { + if strings.HasPrefix(path, "/") { + return s.cfg.BaseURL + path + } + return s.cfg.BaseURL + "/" + path +} + +func encodeOptional(data []byte) string { + if len(data) == 0 { + return "" + } + return base64.StdEncoding.EncodeToString(data) +} + +func boolValue(v *bool) bool { + if v == nil { + return false + } + return *v +} + +func intValue(v *int) int { + if v == nil { + return 0 + } + return *v +} + +type e2bRunRequest struct { + Operation string `json:"operation"` + RunID string `json:"run_id"` + PlanID string `json:"plan_id,omitempty"` + OrgID string `json:"org_id"` + UnitID string `json:"unit_id"` + ConfigurationVersionID string `json:"configuration_version_id"` + IsDestroy bool `json:"is_destroy"` + TerraformVersion string `json:"terraform_version,omitempty"` + WorkingDirectory string `json:"working_directory,omitempty"` + ConfigArchive string `json:"config_archive"` + State string `json:"state,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type e2bRunStartResponse struct { + ID string `json:"id"` +} + +type e2bRunStatusResponse struct { + ID string `json:"id"` + Operation string `json:"operation"` + Status string `json:"status"` + Logs string `json:"logs"` + Result *e2bRunStatusResult `json:"result,omitempty"` + ErrorMessage string `json:"error,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type e2bRunStatusResult struct { + HasChanges *bool `json:"has_changes,omitempty"` + ResourceAdditions *int `json:"resource_additions,omitempty"` + ResourceChanges *int `json:"resource_changes,omitempty"` + ResourceDestructions *int `json:"resource_destructions,omitempty"` + PlanJSON string `json:"plan_json,omitempty"` + State string `json:"state,omitempty"` +} diff --git a/taco/internal/sandbox/types.go b/taco/internal/sandbox/types.go new file mode 100644 index 000000000..7942adaca --- /dev/null +++ b/taco/internal/sandbox/types.go @@ -0,0 +1,58 @@ +package sandbox + +import "context" + +// PlanRequest bundles the inputs needed to execute a Terraform plan inside a sandbox. +type PlanRequest struct { + RunID string + PlanID string + OrgID string + UnitID string + ConfigurationVersionID string + IsDestroy bool + TerraformVersion string + WorkingDirectory string + ConfigArchive []byte + State []byte + Metadata map[string]string +} + +// PlanResult captures the outcome of a sandboxed plan execution. +type PlanResult struct { + Logs string + HasChanges bool + ResourceAdditions int + ResourceChanges int + ResourceDestructions int + PlanJSON []byte + RuntimeRunID string +} + +// ApplyRequest bundles the inputs needed to execute a Terraform apply inside a sandbox. +type ApplyRequest struct { + RunID string + PlanID string + OrgID string + UnitID string + ConfigurationVersionID string + IsDestroy bool + TerraformVersion string + WorkingDirectory string + ConfigArchive []byte + State []byte + Metadata map[string]string +} + +// ApplyResult captures the outcome of a sandboxed apply. +type ApplyResult struct { + Logs string + State []byte + RuntimeRunID string +} + +// Sandbox defines the behavior any sandbox provider must implement. +type Sandbox interface { + Name() string + ExecutePlan(ctx context.Context, req *PlanRequest) (*PlanResult, error) + ExecuteApply(ctx context.Context, req *ApplyRequest) (*ApplyResult, error) +} diff --git a/taco/internal/tfe/apply_executor.go b/taco/internal/tfe/apply_executor.go index e844f5cde..91ae016a5 100644 --- a/taco/internal/tfe/apply_executor.go +++ b/taco/internal/tfe/apply_executor.go @@ -8,11 +8,13 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "time" - "github.com/hashicorp/terraform-exec/tfexec" "github.com/diggerhq/digger/opentaco/internal/domain" + "github.com/diggerhq/digger/opentaco/internal/sandbox" "github.com/diggerhq/digger/opentaco/internal/storage" + "github.com/hashicorp/terraform-exec/tfexec" ) // ApplyExecutor handles real Terraform apply execution @@ -22,6 +24,8 @@ type ApplyExecutor struct { configVerRepo domain.TFEConfigurationVersionRepository blobStore storage.UnitStore unitRepo domain.UnitRepository + sandbox sandbox.Sandbox + activityRepo domain.RemoteRunActivityRepository } // NewApplyExecutor creates a new apply executor @@ -31,6 +35,8 @@ func NewApplyExecutor( configVerRepo domain.TFEConfigurationVersionRepository, blobStore storage.UnitStore, unitRepo domain.UnitRepository, + sandboxProvider sandbox.Sandbox, + activityRepo domain.RemoteRunActivityRepository, ) *ApplyExecutor { return &ApplyExecutor{ runRepo: runRepo, @@ -38,6 +44,8 @@ func NewApplyExecutor( configVerRepo: configVerRepo, blobStore: blobStore, unitRepo: unitRepo, + sandbox: sandboxProvider, + activityRepo: activityRepo, } } @@ -47,7 +55,7 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { slog.String("operation", "execute_apply"), slog.String("run_id", runID), ) - + logger.Info("starting apply execution") // Get run @@ -57,6 +65,36 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { return fmt.Errorf("failed to get run: %w", err) } + unitMeta, err := e.unitRepo.Get(ctx, run.UnitID) + if err != nil { + logger.Error("failed to load unit metadata", slog.String("error", err.Error())) + return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to load workspace metadata: %v", err)) + } + + useSandbox := requiresSandbox(unitMeta) + var applyActivityID string + var applyActivityStart time.Time + var applySandboxResult *sandbox.ApplyResult + if useSandbox && e.activityRepo != nil { + activity := &domain.RemoteRunActivity{ + RunID: run.ID, + OrgID: run.OrgID, + UnitID: run.UnitID, + Operation: "apply", + Status: "pending", + TriggeredBy: run.CreatedBy, + TriggeredSource: run.Source, + } + if id, err := e.activityRepo.CreateActivity(ctx, activity); err != nil { + logger.Warn("failed to create remote apply activity", slog.String("error", err.Error())) + } else { + applyActivityID = id + } + } + if useSandbox && e.sandbox == nil { + return e.handleApplyError(ctx, run.ID, logger, "Workspace execution mode is remote, but no sandbox provider is configured") + } + // Check if run can be applied // Allow apply from "planned" (waiting for confirmation) or "apply_queued" status if run.Status != "planned" && run.Status != "apply_queued" { @@ -72,18 +110,18 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { Version: "1.0.0", Created: time.Now(), } - - logger.Info("acquiring unit lock", + + logger.Info("acquiring unit lock", slog.String("unit_id", run.UnitID), slog.String("lock_id", lockInfo.ID)) - + if err := e.unitRepo.Lock(ctx, run.UnitID, lockInfo); err != nil { if err == storage.ErrLockConflict { // Unit is locked by another operation currentLock, _ := e.unitRepo.GetLock(ctx, run.UnitID) - errMsg := fmt.Sprintf("Unit is locked by another operation (locked by: %s). Please wait and try again.", + errMsg := fmt.Sprintf("Unit is locked by another operation (locked by: %s). Please wait and try again.", currentLock.Who) - logger.Warn("lock conflict - unit already locked", + logger.Warn("lock conflict - unit already locked", slog.String("unit_id", run.UnitID), slog.String("locked_by", currentLock.Who), slog.String("lock_id", currentLock.ID)) @@ -92,14 +130,14 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { logger.Error("failed to acquire lock", slog.String("error", err.Error())) return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to acquire lock: %v", err)) } - + logger.Info("unit lock acquired successfully") - + // Ensure lock is released when we're done (success or failure) defer func() { logger.Info("releasing unit lock", slog.String("unit_id", run.UnitID)) if unlockErr := e.unitRepo.Unlock(ctx, run.UnitID, lockInfo.ID); unlockErr != nil { - logger.Error("failed to release lock", + logger.Error("failed to release lock", slog.String("error", unlockErr.Error()), slog.String("unit_id", run.UnitID), slog.String("lock_id", lockInfo.ID)) @@ -113,7 +151,7 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { logger.Error("failed to update run status", slog.String("error", err.Error())) return fmt.Errorf("failed to update run status: %w", err) } - + logger.Info("updated run status to applying") // Get configuration version @@ -143,28 +181,77 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to remove backend configuration: %v", err)) } + var workspaceArchive []byte + if useSandbox { + workspaceArchive, err = createWorkspaceArchive(workDir) + if err != nil { + return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to package workspace for sandbox apply: %v", err)) + } + logger.Info("packaged workspace for sandbox apply", slog.Int("bytes", len(workspaceArchive))) + } + // Download current state for this unit (must exist before apply) // Construct org-scoped state ID: / stateID := fmt.Sprintf("%s/%s", run.OrgID, run.UnitID) stateData, err := e.blobStore.Download(ctx, stateID) if err != nil { - logger.Warn("failed to download state, continuing anyway", + logger.Warn("failed to download state, continuing anyway", slog.String("state_id", stateID), slog.String("error", err.Error())) // Continue anyway - might be a fresh deployment + } else if useSandbox { + logger.Info("downloaded existing state for sandbox apply", + slog.String("state_id", stateID), + slog.Int("bytes", len(stateData))) } else { // Write state to terraform.tfstate in the working directory statePath := filepath.Join(workDir, "terraform.tfstate") if err := os.WriteFile(statePath, stateData, 0644); err != nil { return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to write state file: %v", err)) } - logger.Info("downloaded and wrote existing state", + logger.Info("downloaded and wrote existing state", slog.String("state_id", stateID), slog.Int("bytes", len(stateData))) } - // Run terraform apply - logs, err := e.runTerraformApply(ctx, workDir, run.IsDestroy) + // Run terraform apply (locally or via sandbox) + var ( + logs string + applyErr error + updatedState []byte + ) + + if useSandbox { + if applyActivityID != "" && e.activityRepo != nil { + applyActivityStart = time.Now() + if err := e.activityRepo.MarkRunning(ctx, applyActivityID, applyActivityStart, e.sandbox.Name()); err != nil { + logger.Warn("failed to mark remote apply running", slog.String("error", err.Error())) + } + } + + result, execErr := e.executeApplyInSandbox(ctx, run, unitMeta, workspaceArchive, stateData) + applySandboxResult = result + applyErr = execErr + if result != nil { + logs = result.Logs + updatedState = result.State + } + if logs == "" { + logs = "remote sandbox did not return apply logs" + } + } else { + localLogs, execErr := e.runTerraformApply(ctx, workDir, run.IsDestroy) + logs = localLogs + applyErr = execErr + if execErr == nil { + statePath := filepath.Join(workDir, "terraform.tfstate") + if data, readErr := os.ReadFile(statePath); readErr != nil { + logger.Warn("failed to read updated state file", slog.String("error", readErr.Error())) + } else { + updatedState = data + } + } + } // Store apply logs in blob storage (use UploadBlob - no lock checks needed for logs) applyLogBlobID := fmt.Sprintf("runs/%s/apply-logs.txt", run.ID) @@ -174,41 +261,35 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { // Update run status runStatus := "applied" - if err != nil { + if applyErr != nil { runStatus = "errored" - logs = logs + "\n\nError: " + err.Error() - // Store error logs even on failure + logs = logs + "\n\nError: " + applyErr.Error() _ = e.blobStore.UploadBlob(ctx, applyLogBlobID, []byte(logs)) - // Store error in run for user visibility - if updateErr := e.runRepo.UpdateRunError(ctx, run.ID, err.Error()); updateErr != nil { + if updateErr := e.runRepo.UpdateRunError(ctx, run.ID, applyErr.Error()); updateErr != nil { logger.Error("failed to update run error", slog.String("error", updateErr.Error())) } } else { - // Upload the updated state back to storage after successful apply - // Construct org-scoped state ID: / stateID := fmt.Sprintf("%s/%s", run.OrgID, run.UnitID) - statePath := filepath.Join(workDir, "terraform.tfstate") - newStateData, readErr := os.ReadFile(statePath) - if readErr != nil { - logger.Warn("failed to read updated state file", slog.String("error", readErr.Error())) + if len(updatedState) == 0 { + logger.Warn("no updated state returned after apply; state upload skipped", + slog.String("state_id", stateID)) } else { - // Upload state with lock ID to unlock it after upload - if uploadErr := e.blobStore.Upload(ctx, stateID, newStateData, lockInfo.ID); uploadErr != nil { - logger.Error("failed to upload updated state", + if uploadErr := e.blobStore.Upload(ctx, stateID, updatedState, lockInfo.ID); uploadErr != nil { + logger.Error("failed to upload updated state", slog.String("state_id", stateID), slog.String("error", uploadErr.Error())) - // This is critical - mark as errored runStatus = "errored" errMsg := fmt.Sprintf("Failed to upload state: %v", uploadErr) logs = logs + "\n\nCritical Error: " + errMsg + "\n" - // Store error in database + _ = e.blobStore.UploadBlob(ctx, applyLogBlobID, []byte(logs)) if updateErr := e.runRepo.UpdateRunError(ctx, run.ID, errMsg); updateErr != nil { logger.Error("failed to update run error", slog.String("error", updateErr.Error())) } + applyErr = fmt.Errorf(errMsg) } else { - logger.Info("successfully uploaded updated state", + logger.Info("successfully uploaded updated state", slog.String("state_id", stateID), - slog.Int("bytes", len(newStateData))) + slog.Int("bytes", len(updatedState))) } } } @@ -220,8 +301,27 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { logger.Info("apply execution completed", slog.String("status", runStatus)) - if err != nil { - return fmt.Errorf("apply failed: %w", err) + if applyErr != nil { + return fmt.Errorf("apply failed: %w", applyErr) + } + + if useSandbox && applyActivityID != "" && e.activityRepo != nil && !applyActivityStart.IsZero() { + completedAt := time.Now() + status := "succeeded" + var errMsg *string + if applyErr != nil { + status = "failed" + msg := applyErr.Error() + errMsg = &msg + } + var sandboxJobID *string + if applySandboxResult != nil && applySandboxResult.RuntimeRunID != "" { + id := applySandboxResult.RuntimeRunID + sandboxJobID = &id + } + if err := e.activityRepo.MarkCompleted(ctx, applyActivityID, status, completedAt, completedAt.Sub(applyActivityStart), sandboxJobID, errMsg); err != nil { + logger.Warn("failed to mark remote apply completion", slog.String("error", err.Error())) + } } return nil @@ -262,7 +362,7 @@ func (e *ApplyExecutor) runTerraformApply(ctx context.Context, workDir string, i // Run terraform apply logger.Info("running terraform apply", slog.Bool("is_destroy", isDestroy)) - + if isDestroy { err = tf.Destroy(ctx) } else { @@ -293,3 +393,36 @@ func (e *ApplyExecutor) handleApplyError(ctx context.Context, runID string, logg return fmt.Errorf("apply execution failed: %s", errorMsg) } +func (e *ApplyExecutor) executeApplyInSandbox(ctx context.Context, run *domain.TFERun, unit *storage.UnitMetadata, archive []byte, stateData []byte) (*sandbox.ApplyResult, error) { + if e.sandbox == nil { + return nil, fmt.Errorf("sandbox provider not configured") + } + if len(archive) == 0 { + return nil, fmt.Errorf("sandbox apply requires configuration archive") + } + + metadata := map[string]string{ + "auto_apply": strconv.FormatBool(run.AutoApply), + } + + planID := "" + if run.PlanID != nil { + planID = *run.PlanID + metadata["plan_id"] = planID + } + + req := &sandbox.ApplyRequest{ + RunID: run.ID, + PlanID: planID, + OrgID: run.OrgID, + UnitID: run.UnitID, + ConfigurationVersionID: run.ConfigurationVersionID, + IsDestroy: run.IsDestroy, + TerraformVersion: terraformVersionForUnit(unit), + WorkingDirectory: workingDirectoryForUnit(unit), + ConfigArchive: archive, + State: stateData, + Metadata: metadata, + } + return e.sandbox.ExecuteApply(ctx, req) +} diff --git a/taco/internal/tfe/plan_executor.go b/taco/internal/tfe/plan_executor.go index d193c10ba..3936b7146 100644 --- a/taco/internal/tfe/plan_executor.go +++ b/taco/internal/tfe/plan_executor.go @@ -11,12 +11,14 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" - "github.com/hashicorp/terraform-exec/tfexec" "github.com/diggerhq/digger/opentaco/internal/domain" + "github.com/diggerhq/digger/opentaco/internal/sandbox" "github.com/diggerhq/digger/opentaco/internal/storage" + "github.com/hashicorp/terraform-exec/tfexec" ) // PlanExecutor handles real Terraform plan execution @@ -26,6 +28,8 @@ type PlanExecutor struct { configVerRepo domain.TFEConfigurationVersionRepository blobStore storage.UnitStore unitRepo domain.UnitRepository + sandbox sandbox.Sandbox + activityRepo domain.RemoteRunActivityRepository } // NewPlanExecutor creates a new plan executor @@ -35,6 +39,8 @@ func NewPlanExecutor( configVerRepo domain.TFEConfigurationVersionRepository, blobStore storage.UnitStore, unitRepo domain.UnitRepository, + sandboxProvider sandbox.Sandbox, + activityRepo domain.RemoteRunActivityRepository, ) *PlanExecutor { return &PlanExecutor{ runRepo: runRepo, @@ -42,6 +48,8 @@ func NewPlanExecutor( configVerRepo: configVerRepo, blobStore: blobStore, unitRepo: unitRepo, + sandbox: sandboxProvider, + activityRepo: activityRepo, } } @@ -64,6 +72,67 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { slog.String("config_version_id", run.ConfigurationVersionID), slog.String("unit_id", run.UnitID)) + unitMeta, err := e.unitRepo.Get(ctx, run.UnitID) + if err != nil { + logger.Error("failed to load unit metadata", slog.String("error", err.Error())) + return e.handlePlanError(ctx, run.ID, run.PlanID, logger, fmt.Sprintf("Failed to load workspace metadata: %v", err)) + } + + logger.Info("šŸ” PLAN EXECUTOR: Checking execution path", + slog.String("unit_id", run.UnitID), + slog.String("unit_name", unitMeta.Name), + slog.String("execution_mode", func() string { + if unitMeta.TFEExecutionMode != nil { + return *unitMeta.TFEExecutionMode + } + return "not set" + }()), + slog.Bool("sandbox_available", e.sandbox != nil), + slog.String("sandbox_provider", func() string { + if e.sandbox != nil { + return e.sandbox.Name() + } + return "none" + }())) + + useSandbox := requiresSandbox(unitMeta) + var planActivityID string + var planActivityStart time.Time + var planSandboxResult *sandbox.PlanResult + + if useSandbox { + logger.Info("āœ… PLAN EXECUTOR: Remote execution path selected", + slog.String("unit_id", run.UnitID), + slog.Bool("activity_repo_available", e.activityRepo != nil)) + } else { + logger.Info("ā„¹ļø PLAN EXECUTOR: Local execution path selected", + slog.String("unit_id", run.UnitID)) + } + + if useSandbox && e.activityRepo != nil { + activity := &domain.RemoteRunActivity{ + RunID: run.ID, + OrgID: run.OrgID, + UnitID: run.UnitID, + Operation: "plan", + Status: "pending", + TriggeredBy: run.CreatedBy, + TriggeredSource: run.Source, + } + + if id, err := e.activityRepo.CreateActivity(ctx, activity); err != nil { + logger.Warn("āš ļø failed to create remote run activity record", slog.String("error", err.Error())) + } else { + planActivityID = id + logger.Info("šŸ“ Created remote run activity record", slog.String("activity_id", planActivityID)) + } + } + + if useSandbox && e.sandbox == nil { + logger.Error("āŒ FATAL: Workspace requires remote execution but no sandbox provider configured") + return e.handlePlanError(ctx, run.ID, run.PlanID, logger, "Workspace execution mode is remote, but no sandbox provider is configured") + } + // Acquire lock before starting terraform operations // This prevents concurrent plans/applies on the same unit lockInfo := &storage.LockInfo{ @@ -159,11 +228,25 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { return e.handlePlanError(ctx, run.ID, run.PlanID, logger, fmt.Sprintf("Failed to create backend override: %v", err)) } + var workspaceArchive []byte + if useSandbox { + workspaceArchive, err = createWorkspaceArchive(workDir) + if err != nil { + return e.handlePlanError(ctx, run.ID, run.PlanID, logger, fmt.Sprintf("Failed to package workspace for sandbox execution: %v", err)) + } + logger.Info("packaged workspace for sandbox execution", slog.Int("bytes", len(workspaceArchive))) + } + // Download current state for this unit (if it exists) // Construct org-scoped state ID: / stateID := fmt.Sprintf("%s/%s", run.OrgID, run.UnitID) stateData, err := e.blobStore.Download(ctx, stateID) if err == nil { + if useSandbox { + logger.Info("downloaded existing state for sandbox execution", + slog.String("state_id", stateID), + slog.Int("bytes", len(stateData))) + } else { // Write state to terraform.tfstate in the working directory statePath := filepath.Join(workDir, "terraform.tfstate") if err := os.WriteFile(statePath, stateData, 0644); err != nil { @@ -172,14 +255,108 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { logger.Info("downloaded and wrote existing state", slog.String("state_id", stateID), slog.Int("bytes", len(stateData))) + } } } else { logger.Info("no existing state found, starting fresh", slog.String("state_id", stateID)) } - // Run terraform plan - _, logs, hasChanges, adds, changes, destroys, err := e.runTerraformPlan(ctx, workDir, run.IsDestroy) + var ( + logs string + hasChanges bool + adds int + changes int + destroys int + planJSON []byte + planErr error + ) + + if useSandbox { + logger.Info("šŸš€ EXECUTING PLAN IN SANDBOX", + slog.String("run_id", run.ID), + slog.String("unit_id", run.UnitID), + slog.String("sandbox_provider", e.sandbox.Name()), + slog.Int("workspace_archive_bytes", len(workspaceArchive)), + slog.Int("state_bytes", len(stateData))) + + if planActivityID != "" && e.activityRepo != nil { + planActivityStart = time.Now() + if err := e.activityRepo.MarkRunning(ctx, planActivityID, planActivityStart, e.sandbox.Name()); err != nil { + logger.Warn("āš ļø failed to mark remote plan running", slog.String("error", err.Error())) + } else { + logger.Info("šŸ“ Marked activity as running", slog.String("activity_id", planActivityID)) + } + } + + result, execErr := e.executePlanInSandbox(ctx, run, unitMeta, workspaceArchive, stateData) + planSandboxResult = result + planErr = execErr + + if execErr != nil { + logger.Error("āŒ SANDBOX PLAN FAILED", + slog.String("run_id", run.ID), + slog.String("error", execErr.Error())) + } else { + logger.Info("āœ… SANDBOX PLAN SUCCEEDED", + slog.String("run_id", run.ID), + slog.Bool("has_changes", result != nil && result.HasChanges)) + } + + if result != nil { + logs = result.Logs + hasChanges = result.HasChanges + adds = result.ResourceAdditions + changes = result.ResourceChanges + destroys = result.ResourceDestructions + planJSON = result.PlanJSON + } + if logs == "" { + logs = "remote sandbox did not return plan logs" + } + } else { + logger.Info("šŸ  EXECUTING PLAN LOCALLY", + slog.String("run_id", run.ID), + slog.String("unit_id", run.UnitID), + slog.String("work_dir", workDir)) + + _, planLogs, planHasChanges, planAdds, planChanges, planDestroys, execErr := e.runTerraformPlan(ctx, workDir, run.IsDestroy) + logs = planLogs + hasChanges = planHasChanges + adds = planAdds + changes = planChanges + destroys = planDestroys + planErr = execErr + + if execErr != nil { + logger.Error("āŒ LOCAL PLAN FAILED", + slog.String("run_id", run.ID), + slog.String("error", execErr.Error())) + } else { + logger.Info("āœ… LOCAL PLAN SUCCEEDED", + slog.String("run_id", run.ID), + slog.Bool("has_changes", planHasChanges)) + } + } + + if useSandbox && planActivityID != "" && e.activityRepo != nil && !planActivityStart.IsZero() { + completedAt := time.Now() + status := "succeeded" + var errMsg *string + if planErr != nil { + status = "failed" + msg := planErr.Error() + errMsg = &msg + } + var sandboxJobID *string + if planSandboxResult != nil && planSandboxResult.RuntimeRunID != "" { + id := planSandboxResult.RuntimeRunID + sandboxJobID = &id + } + if err := e.activityRepo.MarkCompleted(ctx, planActivityID, status, completedAt, completedAt.Sub(planActivityStart), sandboxJobID, errMsg); err != nil { + logger.Warn("failed to mark remote plan completion", slog.String("error", err.Error())) + } + } // Store logs in blob storage (use UploadBlob - no lock checks needed for logs) logBlobID := fmt.Sprintf("plans/%s/logs.txt", *run.PlanID) @@ -192,11 +369,11 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { // Update plan with results planStatus := "finished" - if err != nil { + if planErr != nil { planStatus = "errored" - logs = logs + "\n\nError: " + err.Error() + logs = logs + "\n\nError: " + planErr.Error() // Store error in run for user visibility - if updateErr := e.runRepo.UpdateRunError(ctx, run.ID, err.Error()); updateErr != nil { + if updateErr := e.runRepo.UpdateRunError(ctx, run.ID, planErr.Error()); updateErr != nil { logger.Error("failed to update run error", slog.String("error", updateErr.Error())) } } @@ -210,7 +387,10 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { LogBlobID: &logBlobID, LogReadURL: &logReadURL, } - + if len(planJSON) > 0 { + jsonStr := string(planJSON) + planUpdates.PlanOutputJSON = &jsonStr + } if err := e.planRepo.UpdatePlan(ctx, *run.PlanID, planUpdates); err != nil { return fmt.Errorf("failed to update plan: %w", err) @@ -219,9 +399,9 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { // Update run status and can_apply // Use "planned" status (not "planned_and_finished") - this is what Terraform CLI expects runStatus := "planned" - canApply := (err == nil) // Can apply if plan succeeded (regardless of whether there are changes) + canApply := (planErr == nil) // Can apply if plan succeeded (regardless of whether there are changes) - if err != nil { + if planErr != nil { runStatus = "errored" } @@ -245,9 +425,9 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { // Only auto-trigger apply if AutoApply flag is true (i.e., terraform apply -auto-approve) logger.Debug("auto-apply check", slog.Bool("auto_apply", run.AutoApply), - slog.Bool("plan_succeeded", err == nil)) + slog.Bool("plan_succeeded", planErr == nil)) - if run.AutoApply && err == nil { + if run.AutoApply && planErr == nil { logger.Info("triggering auto-apply") // Queue the apply by updating the run status @@ -278,7 +458,7 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { slog.String("run_id", run.ID), ) applyLogger.Info("starting async apply execution") - applyExecutor := NewApplyExecutor(e.runRepo, e.planRepo, e.configVerRepo, e.blobStore, e.unitRepo) + applyExecutor := NewApplyExecutor(e.runRepo, e.planRepo, e.configVerRepo, e.blobStore, e.unitRepo, e.sandbox, e.activityRepo) if err := applyExecutor.ExecuteApply(applyCtx, run.ID); err != nil { applyLogger.Error("apply execution failed", slog.String("error", err.Error())) } else { @@ -579,3 +759,103 @@ func createBackendOverride(workDir string) error { slog.Info("successfully removed cloud/backend configuration from terraform files") return nil } + +func (e *PlanExecutor) executePlanInSandbox(ctx context.Context, run *domain.TFERun, unit *storage.UnitMetadata, archive []byte, stateData []byte) (*sandbox.PlanResult, error) { + if e.sandbox == nil { + return nil, fmt.Errorf("sandbox provider not configured") + } + if len(archive) == 0 { + return nil, fmt.Errorf("sandbox plan requires configuration archive") + } + + planID := "" + if run.PlanID != nil { + planID = *run.PlanID + } + + metadata := map[string]string{ + "auto_apply": strconv.FormatBool(run.AutoApply), + } + if planID != "" { + metadata["plan_id"] = planID + } + + req := &sandbox.PlanRequest{ + RunID: run.ID, + PlanID: planID, + OrgID: run.OrgID, + UnitID: run.UnitID, + ConfigurationVersionID: run.ConfigurationVersionID, + IsDestroy: run.IsDestroy, + TerraformVersion: terraformVersionForUnit(unit), + WorkingDirectory: workingDirectoryForUnit(unit), + ConfigArchive: archive, + State: stateData, + Metadata: metadata, + } + return e.sandbox.ExecutePlan(ctx, req) +} + +func createWorkspaceArchive(workDir string) ([]byte, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip .terraform directories to avoid uploading cache/modules + if info.IsDir() && strings.HasPrefix(info.Name(), ".terraform") { + return filepath.SkipDir + } + + relPath, err := filepath.Rel(workDir, path) + if err != nil { + return err + } + if relPath == "." { + return nil + } + relPath = filepath.ToSlash(relPath) + + header, err := tar.FileInfoHeader(info, path) + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + if _, err := io.Copy(tw, file); err != nil { + file.Close() + return err + } + file.Close() + } + return nil + }) + if err != nil { + tw.Close() + gz.Close() + return nil, err + } + + if err := tw.Close(); err != nil { + gz.Close() + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/taco/internal/tfe/runs.go b/taco/internal/tfe/runs.go index 7c894f42e..89e4a10d1 100644 --- a/taco/internal/tfe/runs.go +++ b/taco/internal/tfe/runs.go @@ -338,23 +338,45 @@ func (h *TfeHandler) CreateRun(c echo.Context) error { }) } - // Security: Check if remote runs are enabled via environment variable + // Security: Remote runs require an active sandbox provider. executionMode := "local" // default if unit.TFEExecutionMode != nil && *unit.TFEExecutionMode != "" { executionMode = *unit.TFEExecutionMode } - if executionMode == "remote" && os.Getenv("REMOTE_RUNS_ENABLED") != "true" { - logger.Warn("remote run creation blocked - feature not enabled", + + logger.Info("šŸ” DECISION POINT: Checking execution mode", + slog.String("unit_id", unitID), + slog.String("unit_name", unit.Name), + slog.String("execution_mode", executionMode), + slog.Bool("sandbox_configured", h.sandbox != nil), + slog.String("sandbox_provider", func() string { + if h.sandbox != nil { + return h.sandbox.Name() + } + return "none" + }())) + + if executionMode == "remote" && h.sandbox == nil { + logger.Warn("āŒ BLOCKED: remote run creation - sandbox provider not configured", slog.String("unit_id", unitID), slog.String("execution_mode", executionMode)) return c.JSON(http.StatusForbidden, map[string]interface{}{ "errors": []map[string]string{{ "status": "403", "title": "forbidden", - "detail": "Remote runs feature is not enabled on this server", + "detail": "Remote execution mode requires configuring OPENTACO_SANDBOX_PROVIDER", }}, }) } + + if executionMode == "remote" { + logger.Info("āœ… APPROVED: Remote execution will be used", + slog.String("unit_id", unitID), + slog.String("sandbox_provider", h.sandbox.Name())) + } else { + logger.Info("ā„¹ļø Local execution mode - sandbox will not be used", + slog.String("unit_id", unitID)) + } // Get the configuration version to check if it's speculative configVer, err := h.configVerRepo.GetConfigurationVersion(ctx, cvID) @@ -467,7 +489,7 @@ func (h *TfeHandler) CreateRun(c echo.Context) error { ) planLogger.Info("starting async plan execution") // Create plan executor - executor := NewPlanExecutor(h.runRepo, h.planRepo, h.configVerRepo, h.blobStore, h.unitRepo) + executor := NewPlanExecutor(h.runRepo, h.planRepo, h.configVerRepo, h.blobStore, h.unitRepo, h.sandbox, h.runActivityRepo) // Execute the plan (this will run terraform plan) if err := executor.ExecutePlan(planCtx, run.ID); err != nil { @@ -604,7 +626,7 @@ func (h *TfeHandler) ApplyRun(c echo.Context) error { ) applyLogger.Info("starting async apply execution") // Create apply executor - executor := NewApplyExecutor(h.runRepo, h.planRepo, h.configVerRepo, h.blobStore, h.unitRepo) + executor := NewApplyExecutor(h.runRepo, h.planRepo, h.configVerRepo, h.blobStore, h.unitRepo, h.sandbox, h.runActivityRepo) // Execute the apply (this will run terraform apply) if err := executor.ExecuteApply(applyCtx, runID); err != nil { diff --git a/taco/internal/tfe/sandbox_helpers.go b/taco/internal/tfe/sandbox_helpers.go new file mode 100644 index 000000000..02dcfccf1 --- /dev/null +++ b/taco/internal/tfe/sandbox_helpers.go @@ -0,0 +1,43 @@ +package tfe + +import ( + "log/slog" + "strings" + + "github.com/diggerhq/digger/opentaco/internal/storage" +) + +func requiresSandbox(unit *storage.UnitMetadata) bool { + if unit == nil || unit.TFEExecutionMode == nil { + slog.Info("šŸ” requiresSandbox: unit has no execution mode set, defaulting to local", + slog.String("unit_id", func() string { + if unit != nil { + return unit.ID + } + return "nil" + }())) + return false + } + mode := strings.TrimSpace(strings.ToLower(*unit.TFEExecutionMode)) + result := mode == "remote" + slog.Info("šŸ” requiresSandbox: execution mode check", + slog.String("unit_id", unit.ID), + slog.String("unit_name", unit.Name), + slog.String("execution_mode", mode), + slog.Bool("requires_sandbox", result)) + return result +} + +func terraformVersionForUnit(unit *storage.UnitMetadata) string { + if unit == nil || unit.TFETerraformVersion == nil { + return "" + } + return strings.TrimSpace(*unit.TFETerraformVersion) +} + +func workingDirectoryForUnit(unit *storage.UnitMetadata) string { + if unit == nil || unit.TFEWorkingDirectory == nil { + return "" + } + return strings.TrimSpace(*unit.TFEWorkingDirectory) +} diff --git a/taco/internal/tfe/tfe.go b/taco/internal/tfe/tfe.go index 6d17abcce..4bf612ea5 100644 --- a/taco/internal/tfe/tfe.go +++ b/taco/internal/tfe/tfe.go @@ -4,6 +4,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/auth" "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/diggerhq/digger/opentaco/internal/rbac" + "github.com/diggerhq/digger/opentaco/internal/sandbox" "github.com/diggerhq/digger/opentaco/internal/storage" ) @@ -11,18 +12,20 @@ import ( // Uses TFEOperations interface (6 methods) - cannot create, list, delete, or manage versions. type TfeHandler struct { authHandler *auth.Handler - stateStore domain.TFEOperations // RBAC-wrapped for authenticated operations - directStateStore domain.TFEOperations // Direct access for pre-authorized operations (signed URLs) + stateStore domain.TFEOperations // RBAC-wrapped for authenticated operations + directStateStore domain.TFEOperations // Direct access for pre-authorized operations (signed URLs) rbacManager *rbac.RBACManager apiTokens *auth.APITokenManager identifierResolver domain.IdentifierResolver // For resolving org external IDs // TFE repositories for runs, plans, and configuration versions - runRepo domain.TFERunRepository - planRepo domain.TFEPlanRepository - configVerRepo domain.TFEConfigurationVersionRepository - blobStore storage.UnitStore - unitRepo domain.UnitRepository // Direct access for locking during plan/apply + runRepo domain.TFERunRepository + planRepo domain.TFEPlanRepository + configVerRepo domain.TFEConfigurationVersionRepository + blobStore storage.UnitStore + unitRepo domain.UnitRepository // Direct access for locking during plan/apply + sandbox sandbox.Sandbox + runActivityRepo domain.RemoteRunActivityRepository } // NewTFETokenHandler creates a new TFE handler. @@ -38,6 +41,8 @@ func NewTFETokenHandler( runRepo domain.TFERunRepository, planRepo domain.TFEPlanRepository, configVerRepo domain.TFEConfigurationVersionRepository, + sandboxProvider sandbox.Sandbox, + runActivityRepo domain.RemoteRunActivityRepository, ) *TfeHandler { return &TfeHandler{ authHandler: authHandler, @@ -50,6 +55,8 @@ func NewTFETokenHandler( planRepo: planRepo, configVerRepo: configVerRepo, blobStore: blobStore, - unitRepo: unwrappedRepo, // Use unwrapped repo for direct lock access + unitRepo: unwrappedRepo, // Use unwrapped repo for direct lock access + sandbox: sandboxProvider, + runActivityRepo: runActivityRepo, } } diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index 070ca14c4..df91667b5 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -191,6 +191,90 @@ func (h *Handler) CreateUnit(c echo.Context) error { return c.JSON(http.StatusCreated, CreateUnitResponse{ID: metadata.ID, Created: metadata.Updated}) } +type UpdateUnitRequest struct { + TFEAutoApply *bool `json:"tfe_auto_apply"` + TFEExecutionMode *string `json:"tfe_execution_mode"` + TFETerraformVersion *string `json:"tfe_terraform_version"` + TFEWorkingDirectory *string `json:"tfe_working_directory"` +} + +func (h *Handler) UpdateUnit(c echo.Context) error { + logger := logging.FromContext(c) + ctx := c.Request().Context() + identifier := c.Param("id") + + // Get org UUID from domain context + orgCtx, ok := domain.OrgFromContext(ctx) + if !ok { + logger.Error("Organization context missing", "operation", "update_unit") + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Organization context missing"}) + } + + // Resolve unit identifier to UUID + unitID, err := h.resolveUnitIdentifier(ctx, identifier) + if err != nil { + logger.Error("Failed to resolve unit identifier", + "operation", "update_unit", + "identifier", identifier, + "error", err) + return c.JSON(http.StatusNotFound, map[string]string{"error": "Unit not found"}) + } + + logger.Info("Updating unit", + "operation", "update_unit", + "unit_id", unitID, + "org_id", orgCtx.OrgID) + + // Verify unit exists and user has access + _, err = h.store.Get(ctx, unitID) + if err != nil { + logger.Error("Failed to get unit", + "operation", "update_unit", + "unit_id", unitID, + "error", err) + if err.Error() == "unauthorized" || err.Error() == "forbidden" { + return c.JSON(http.StatusForbidden, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusNotFound, map[string]string{"error": "Unit not found"}) + } + + // Parse request body + var req UpdateUnitRequest + if err := c.Bind(&req); err != nil { + logger.Error("Failed to parse request body", + "operation", "update_unit", + "unit_id", unitID, + "error", err) + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + // Update TFE settings if any are provided + if req.TFEAutoApply != nil || req.TFEExecutionMode != nil || req.TFETerraformVersion != nil || req.TFEWorkingDirectory != nil { + if h.queryStore != nil { + if err := h.queryStore.UpdateUnitTFESettings(ctx, unitID, req.TFEAutoApply, req.TFEExecutionMode, req.TFETerraformVersion, req.TFEWorkingDirectory); err != nil { + logger.Error("Failed to update TFE settings for unit", + "operation", "update_unit", + "unit_id", unitID, + "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to update unit settings", + "detail": err.Error(), + }) + } + } + } + + logger.Info("Unit updated successfully", + "operation", "update_unit", + "unit_id", unitID, + "org_id", orgCtx.OrgID) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "id": unitID, + "message": "Unit updated successfully", + }) +} + func (h *Handler) ListUnits(c echo.Context) error { logger := logging.FromContext(c) ctx := c.Request().Context() diff --git a/taco/migrations/mysql/20251117000000_create_remote_run_activity.sql b/taco/migrations/mysql/20251117000000_create_remote_run_activity.sql new file mode 100644 index 000000000..4765dfcea --- /dev/null +++ b/taco/migrations/mysql/20251117000000_create_remote_run_activity.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS `remote_run_activity` ( + `id` varchar(36) NOT NULL PRIMARY KEY, + `run_id` varchar(36) NOT NULL, + `org_id` varchar(36) NOT NULL, + `unit_id` varchar(36) NOT NULL, + `operation` varchar(16) NOT NULL, + `status` varchar(32) NOT NULL DEFAULT 'pending', + `triggered_by` varchar(255) DEFAULT NULL, + `triggered_source` varchar(50) DEFAULT NULL, + `sandbox_provider` varchar(50) DEFAULT NULL, + `sandbox_job_id` varchar(100) DEFAULT NULL, + `started_at` datetime DEFAULT NULL, + `completed_at` datetime DEFAULT NULL, + `duration_ms` bigint DEFAULT NULL, + `error_message` text, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_remote_runs_run_id` (`run_id`), + INDEX `idx_remote_runs_unit_id` (`unit_id`), + INDEX `idx_remote_runs_created_at` (`created_at` DESC), + INDEX `idx_remote_runs_operation` (`operation`), + CONSTRAINT `fk_remote_runs_tfe_runs` FOREIGN KEY (`run_id`) REFERENCES `tfe_runs` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_remote_runs_units` FOREIGN KEY (`unit_id`) REFERENCES `units` (`id`) ON DELETE CASCADE +) CHARSET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + diff --git a/taco/migrations/postgres/20251117000000_create_remote_run_activity.sql b/taco/migrations/postgres/20251117000000_create_remote_run_activity.sql new file mode 100644 index 000000000..adb90fd6e --- /dev/null +++ b/taco/migrations/postgres/20251117000000_create_remote_run_activity.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS public.remote_run_activity ( + id uuid PRIMARY KEY, + run_id varchar(36) NOT NULL REFERENCES public.tfe_runs(id) ON DELETE CASCADE, + org_id varchar(36) NOT NULL, + unit_id varchar(36) NOT NULL REFERENCES public.units(id) ON DELETE CASCADE, + operation varchar(16) NOT NULL, + status varchar(32) NOT NULL DEFAULT 'pending', + triggered_by varchar(255), + triggered_source varchar(50), + sandbox_provider varchar(50), + sandbox_job_id varchar(100), + started_at timestamptz, + completed_at timestamptz, + duration_ms bigint, + error_message text, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_remote_runs_run_id ON public.remote_run_activity (run_id); +CREATE INDEX IF NOT EXISTS idx_remote_runs_unit_id ON public.remote_run_activity (unit_id); +CREATE INDEX IF NOT EXISTS idx_remote_runs_created_at ON public.remote_run_activity (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_remote_runs_operation ON public.remote_run_activity (operation); + +CREATE OR REPLACE FUNCTION public.remote_run_activity_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS remote_run_activity_set_updated_at ON public.remote_run_activity; +CREATE TRIGGER remote_run_activity_set_updated_at +BEFORE UPDATE ON public.remote_run_activity +FOR EACH ROW EXECUTE PROCEDURE public.remote_run_activity_set_updated_at(); + diff --git a/taco/migrations/sqlite/20251117000000_create_remote_run_activity.sql b/taco/migrations/sqlite/20251117000000_create_remote_run_activity.sql new file mode 100644 index 000000000..198f3022d --- /dev/null +++ b/taco/migrations/sqlite/20251117000000_create_remote_run_activity.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS remote_run_activity ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + org_id TEXT NOT NULL, + unit_id TEXT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + triggered_by TEXT, + triggered_source TEXT, + sandbox_provider TEXT, + sandbox_job_id TEXT, + started_at DATETIME, + completed_at DATETIME, + duration_ms INTEGER, + error_message TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (run_id) REFERENCES tfe_runs(id) ON DELETE CASCADE, + FOREIGN KEY (unit_id) REFERENCES units(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_remote_runs_run_id ON remote_run_activity (run_id); +CREATE INDEX IF NOT EXISTS idx_remote_runs_unit_id ON remote_run_activity (unit_id); +CREATE INDEX IF NOT EXISTS idx_remote_runs_created_at ON remote_run_activity (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_remote_runs_operation ON remote_run_activity (operation); + diff --git a/ui/src/components/UnitCreateForm.tsx b/ui/src/components/UnitCreateForm.tsx index 301c9cc70..f8f35c0de 100644 --- a/ui/src/components/UnitCreateForm.tsx +++ b/ui/src/components/UnitCreateForm.tsx @@ -30,11 +30,12 @@ export default function UnitCreateForm({ }: UnitCreateFormProps) { const [unitName, setUnitName] = React.useState('') const [unitType, setUnitType] = React.useState<'local' | 'remote'>('local') + const [terraformVersion, setTerraformVersion] = React.useState('1.5.5') const [isCreating, setIsCreating] = React.useState(false) const [error, setError] = React.useState(null) - // Check if remote runs feature is enabled via environment variable - const remoteRunsEnabled = import.meta.env.VITE_REMOTE_RUNS_ENABLED === 'true' + // Remote runs beta is now always enabled + const remoteRunsEnabled = true const handleCreate = async () => { if (!unitName.trim()) return @@ -59,8 +60,10 @@ export default function UnitCreateForm({ email, name: unitName.trim(), // Enable TFE remote execution for remote type - tfeAutoApply: finalUnitType === 'remote', + // Auto-apply defaults to false - user must explicitly approve applies + tfeAutoApply: false, tfeExecutionMode: finalUnitType === 'remote' ? 'remote' : 'local', + tfeTerraformVersion: finalUnitType === 'remote' ? terraformVersion : undefined, }, }) // analytics: track unit creation @@ -165,6 +168,25 @@ export default function UnitCreateForm({ + {unitType === 'remote' && ( +
+ +
+ setTerraformVersion(e.target.value)} + placeholder="1.5.5" + className="font-mono" + /> +

+ Default: 1.5.5 (pre-built template, fast startup). + Custom versions will be installed at runtime (slower first run). +

+
+
+ )} + {error &&

{error}

} {showBringOwnState ? (
From b44ace719910092a9f9283bbde990d6f9afd109a Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 15:45:08 -0800 Subject: [PATCH 02/13] add tfe engine, resolve api issue --- sandbox-sidecar/README.md | 20 ++- sandbox-sidecar/src/jobs/jobTypes.ts | 1 + sandbox-sidecar/src/runners/e2bRunner.ts | 118 +++++++++------ sandbox-sidecar/src/templateRegistry.ts | 49 ++++++ sandbox-sidecar/src/types/runTypes.ts | 1 + sandbox-sidecar/templates/build-all.ts | 43 ++++++ sandbox-sidecar/templates/manifest.ts | 26 ++++ .../templates/terraform-template.ts | 25 ++++ sandbox-sidecar/templates/tofu-template.ts | 20 +++ taco/internal/domain/interfaces.go | 7 + taco/internal/query/common/sql_store.go | 5 +- taco/internal/query/interface.go | 2 +- taco/internal/query/types/models.go | 1 + taco/internal/rbac/querystore.go | 6 +- .../repositories/authorizing_repository.go | 26 ++++ taco/internal/repositories/unit_repository.go | 1 + taco/internal/sandbox/types.go | 6 +- taco/internal/storage/interface.go | 1 + taco/internal/tfe/apply_executor.go | 1 + taco/internal/tfe/plan_executor.go | 1 + taco/internal/tfe/sandbox_helpers.go | 11 ++ taco/internal/unit/handler.go | 34 ++++- .../mysql/20251118000000_add_tfe_engine.sql | 7 + .../20251118000000_add_tfe_engine.sql | 9 ++ .../sqlite/20251118000000_add_tfe_engine.sql | 7 + ui/src/api/statesman_serverFunctions.ts | 2 + ui/src/api/statesman_units.ts | 39 +++++ ui/src/components/UnitCreateForm.tsx | 81 ++++++++-- .../_dashboard/dashboard/units.$unitId.tsx | 141 +++++++++++++++++- 29 files changed, 611 insertions(+), 80 deletions(-) create mode 100644 sandbox-sidecar/src/templateRegistry.ts create mode 100644 sandbox-sidecar/templates/build-all.ts create mode 100644 sandbox-sidecar/templates/manifest.ts create mode 100644 sandbox-sidecar/templates/terraform-template.ts create mode 100644 sandbox-sidecar/templates/tofu-template.ts create mode 100644 taco/migrations/mysql/20251118000000_add_tfe_engine.sql create mode 100644 taco/migrations/postgres/20251118000000_add_tfe_engine.sql create mode 100644 taco/migrations/sqlite/20251118000000_add_tfe_engine.sql diff --git a/sandbox-sidecar/README.md b/sandbox-sidecar/README.md index 1ffb5a104..4dfe9a3a1 100644 --- a/sandbox-sidecar/README.md +++ b/sandbox-sidecar/README.md @@ -28,19 +28,23 @@ The service listens on `PORT` (default `9100`). | `PORT` | HTTP port for the sidecar (default `9100`). | | `SANDBOX_RUNNER` | `local` or `e2b`. Defaults to `local`. | | `E2B_API_KEY` | Required for `SANDBOX_RUNNER=e2b`. | -| `E2B_DEFAULT_TEMPLATE_ID` | E2B template ID (use base template like `rki5dems9wqfm4r03t7g`). Required for E2B. | -| `E2B_BAREBONES_TEMPLATE_ID` | Same as DEFAULT for now - both use runtime installation. Required for E2B. | +| `E2B_BAREBONES_TEMPLATE_ID` | Optional fallback template ID for runtime installation (defaults to `rki5dems9wqfm4r03t7g`). | | `LOCAL_TERRAFORM_BIN` | Optional path to the `terraform` binary (defaults to `terraform` in `$PATH`). | -### Terraform Version Selection +### Terraform/OpenTofu Version Selection -The sidecar installs Terraform at runtime for any requested version: +The sidecar automatically selects the best execution environment: -- **Any version** (including 1.5.5 default): Installs Terraform on-demand (~1-2 seconds) -- Supports any Terraform version available from HashiCorp releases -- No pre-built templates needed - simple and reliable +1. **Pre-built templates** (instant startup): If a template exists for the requested version in `src/templateRegistry.ts`, it's used automatically +2. **Runtime installation** (~1-2 seconds): For versions not in the registry, Terraform/OpenTofu is installed on-demand -Users can specify the Terraform version when creating a unit in the UI, or it defaults to 1.5.5. +**Pre-built versions** (see `templates/manifest.ts`): +- Terraform: 1.0.11, 1.3.9, 1.5.5, 1.8.5 +- OpenTofu: 1.6.0, 1.10.0 + +**Building templates**: Run `cd templates && npm run build` to build all templates defined in `manifest.ts`. + +Users specify the version when creating a unit in the UI (defaults to 1.5.5). ### Local Runner diff --git a/sandbox-sidecar/src/jobs/jobTypes.ts b/sandbox-sidecar/src/jobs/jobTypes.ts index d0859209e..4516b77bd 100644 --- a/sandbox-sidecar/src/jobs/jobTypes.ts +++ b/sandbox-sidecar/src/jobs/jobTypes.ts @@ -11,6 +11,7 @@ export interface SandboxRunPayload { configurationVersionId: string; isDestroy: boolean; terraformVersion?: string; + engine?: "terraform" | "tofu"; workingDirectory?: string; configArchive: string; state?: string; diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index 88d454dd6..b421cf3dd 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -2,16 +2,17 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxRunner, RunnerOutput } from "./types.js"; import { SandboxRunRecord, SandboxRunResult } from "../jobs/jobTypes.js"; import { logger } from "../logger.js"; +import { findTemplate, getFallbackTemplateId } from "../templateRegistry.js"; export interface E2BRunnerOptions { apiKey?: string; - defaultTemplateId?: string; // Pre-built with TF 1.5.5 - bareBonesTemplateId?: string; // Base for custom versions + defaultTemplateId?: string; // Deprecated: kept for backward compatibility + bareBonesTemplateId?: string; // Optional fallback template for runtime installation } /** * E2B runner that executes Terraform commands inside an E2B sandbox. - * Uses the official @e2b/sdk to create sandboxes, upload files, and run commands. + * Automatically selects pre-built templates from the registry or falls back to runtime installation. */ export class E2BSandboxRunner implements SandboxRunner { readonly name = "e2b"; @@ -20,12 +21,6 @@ export class E2BSandboxRunner implements SandboxRunner { if (!options.apiKey) { throw new Error("E2B_API_KEY is required when SANDBOX_RUNNER=e2b"); } - if (!options.defaultTemplateId) { - throw new Error("E2B_DEFAULT_TEMPLATE_ID is required when SANDBOX_RUNNER=e2b"); - } - if (!options.bareBonesTemplateId) { - throw new Error("E2B_BAREBONES_TEMPLATE_ID is required when SANDBOX_RUNNER=e2b"); - } } async run(job: SandboxRunRecord): Promise { @@ -37,7 +32,8 @@ export class E2BSandboxRunner implements SandboxRunner { private async runPlan(job: SandboxRunRecord): Promise { const requestedVersion = job.payload.terraformVersion || "1.5.5"; - const sandbox = await this.createSandbox(requestedVersion); + const requestedEngine = job.payload.engine || "terraform"; + const sandbox = await this.createSandbox(requestedVersion, requestedEngine); try { // Install Terraform if not already present await this.ensureTerraform(sandbox); @@ -86,7 +82,8 @@ export class E2BSandboxRunner implements SandboxRunner { private async runApply(job: SandboxRunRecord): Promise { const requestedVersion = job.payload.terraformVersion || "1.5.5"; - const sandbox = await this.createSandbox(requestedVersion); + const requestedEngine = job.payload.engine || "terraform"; + const sandbox = await this.createSandbox(requestedVersion, requestedEngine); try { // Install Terraform if not already present await this.ensureTerraform(sandbox); @@ -124,24 +121,26 @@ export class E2BSandboxRunner implements SandboxRunner { } } - private async createSandbox(requestedVersion?: string): Promise { - // Select template based on requested Terraform version - let templateId: string; - let needsInstall = false; + private async createSandbox(requestedVersion?: string, requestedEngine?: string): Promise { const version = requestedVersion || "1.5.5"; + const engine = requestedEngine === "tofu" ? "tofu" : "terraform"; + + // Try to find a pre-built template for this version + const prebuiltAlias = findTemplate(engine, version); + + let templateId: string; + let needsInstall: boolean; - if (version === "1.5.5" && this.options.defaultTemplateId) { - // Use pre-built template with TF 1.5.5 - templateId = this.options.defaultTemplateId; + if (prebuiltAlias) { + // Use pre-built template with this version already installed + templateId = prebuiltAlias; needsInstall = false; - logger.info({ templateId, version: "1.5.5" }, "using pre-built template with Terraform 1.5.5"); - } else if (this.options.bareBonesTemplateId) { - // Use bare-bones template for custom version - templateId = this.options.bareBonesTemplateId; - needsInstall = true; - logger.info({ templateId, version }, "using bare-bones template for custom Terraform version"); + logger.info({ templateId, engine, version }, "using pre-built template"); } else { - throw new Error("E2B templates not configured. Set E2B_DEFAULT_TEMPLATE_ID and E2B_BAREBONES_TEMPLATE_ID"); + // Fall back to bare-bones template and install at runtime + templateId = getFallbackTemplateId(this.options.bareBonesTemplateId); + needsInstall = true; + logger.info({ templateId, engine, version }, "no pre-built template found, will install at runtime"); } logger.info({ templateId }, "creating E2B sandbox"); @@ -150,50 +149,71 @@ export class E2BSandboxRunner implements SandboxRunner { }); logger.info({ sandboxId: sandbox.sandboxId }, "E2B sandbox created"); - // Store whether we need to install TF + // Store metadata for installation (sandbox as any)._needsTerraformInstall = needsInstall; (sandbox as any)._requestedTerraformVersion = version; + (sandbox as any)._requestedEngine = engine; return sandbox; } private async ensureTerraform(sandbox: Sandbox): Promise { - // Always check if terraform is actually installed, even in pre-built templates - logger.info("checking for Terraform installation"); - const checkResult = await sandbox.commands.run("which terraform 2>/dev/null || echo 'not-found'"); + const engine = (sandbox as any)._requestedEngine || "terraform"; + const binaryName = engine === "tofu" ? "tofu" : "terraform"; + + // Always check if the binary is actually installed, even in pre-built templates + logger.info({ engine, binaryName }, "checking for IaC tool installation"); + const checkResult = await sandbox.commands.run(`which ${binaryName} 2>/dev/null || echo 'not-found'`); if (!checkResult.stdout.includes("not-found")) { - const versionCheck = await sandbox.commands.run("terraform version"); + const versionCheck = await sandbox.commands.run(`${binaryName} version`); logger.info({ + engine, path: checkResult.stdout.trim(), version: versionCheck.stdout.split('\n')[0] - }, "Terraform already installed"); + }, "IaC tool already installed"); return; } // If we expected it to be pre-installed but it's not, log a warning if (!(sandbox as any)._needsTerraformInstall) { - logger.warn("Terraform not found in pre-built template, installing at runtime"); + logger.warn({ engine }, "IaC tool not found in pre-built template, installing at runtime"); } // Use requested version or default - const terraformVersion = (sandbox as any)._requestedTerraformVersion || "1.9.8"; - logger.info({ version: terraformVersion }, "installing Terraform in sandbox"); + const version = (sandbox as any)._requestedTerraformVersion || (engine === "tofu" ? "1.10.0" : "1.9.8"); + logger.info({ engine, version }, "installing IaC tool in sandbox"); - // Download and install Terraform binary directly (faster and simpler) - const installScript = ` - set -e - cd /tmp - wget -q https://releases.hashicorp.com/terraform/${terraformVersion}/terraform_${terraformVersion}_linux_amd64.zip - unzip -q terraform_${terraformVersion}_linux_amd64.zip - sudo mv terraform /usr/local/bin/ - sudo chmod +x /usr/local/bin/terraform - terraform version - `; + let installScript: string; + + if (engine === "tofu") { + // Download and install OpenTofu binary + installScript = ` + set -e + cd /tmp + wget -q https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.tar.gz + tar -xzf tofu_${version}_linux_amd64.tar.gz + sudo mv tofu /usr/local/bin/ + sudo chmod +x /usr/local/bin/tofu + tofu version + `; + } else { + // Download and install Terraform binary + installScript = ` + set -e + cd /tmp + wget -q https://releases.hashicorp.com/terraform/${version}/terraform_${version}_linux_amd64.zip + unzip -q terraform_${version}_linux_amd64.zip + sudo mv terraform /usr/local/bin/ + sudo chmod +x /usr/local/bin/terraform + terraform version + `; + } const result = await sandbox.commands.run(installScript); logger.info({ + engine, version: result.stdout.trim() - }, "Terraform installation complete"); + }, "IaC tool installation complete"); } private async setupWorkspace( @@ -233,8 +253,10 @@ export class E2BSandboxRunner implements SandboxRunner { args: string[], logBuffer?: string[], ): Promise<{ stdout: string; stderr: string }> { - const cmdStr = `terraform ${args.join(" ")}`; - logger.info({ cmd: cmdStr, cwd }, "running terraform command in E2B sandbox"); + const engine = (sandbox as any)._requestedEngine || "terraform"; + const binaryName = engine === "tofu" ? "tofu" : "terraform"; + const cmdStr = `${binaryName} ${args.join(" ")}`; + logger.info({ cmd: cmdStr, cwd, engine }, "running IaC command in E2B sandbox"); const result = await sandbox.commands.run(cmdStr, { cwd, @@ -254,7 +276,7 @@ export class E2BSandboxRunner implements SandboxRunner { if (exitCode !== 0) { throw new Error( - `terraform ${args[0]} exited with code ${exitCode}\n${mergedLogs}`, + `${binaryName} ${args[0]} exited with code ${exitCode}\n${mergedLogs}`, ); } diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts new file mode 100644 index 000000000..4874550d7 --- /dev/null +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -0,0 +1,49 @@ +// Template registry - maps engine + version to E2B template aliases +// This should match what's built in templates/manifest.ts + +export interface TemplateInfo { + engine: "terraform" | "tofu"; + version: string; + alias: string; +} + +// Template version - bump this when the build recipe changes +const TEMPLATE_VERSION = "0.1.0"; + +// Generate alias matching the build system +function aliasFor(engine: string, version: string, tplVersion: string): string { + const engineVerSlug = version.replace(/\./g, "-"); + const tplVerSlug = tplVersion.replace(/\./g, "-"); + return `${engine}-${engineVerSlug}--tpl-${tplVerSlug}`; +} + +// Registry of pre-built templates +// Keep this in sync with templates/manifest.ts +export const TEMPLATE_REGISTRY: TemplateInfo[] = [ + { engine: "terraform", version: "1.0.11", alias: aliasFor("terraform", "1.0.11", TEMPLATE_VERSION) }, + { engine: "terraform", version: "1.3.9", alias: aliasFor("terraform", "1.3.9", TEMPLATE_VERSION) }, + { engine: "terraform", version: "1.5.5", alias: aliasFor("terraform", "1.5.5", TEMPLATE_VERSION) }, + { engine: "terraform", version: "1.8.5", alias: aliasFor("terraform", "1.8.5", TEMPLATE_VERSION) }, + { engine: "tofu", version: "1.6.0", alias: aliasFor("tofu", "1.6.0", TEMPLATE_VERSION) }, + { engine: "tofu", version: "1.10.0", alias: aliasFor("tofu", "1.10.0", TEMPLATE_VERSION) }, +]; + +/** + * Find a pre-built template for the given engine and version + * Returns the template alias if found, undefined otherwise + */ +export function findTemplate(engine: "terraform" | "tofu", version: string): string | undefined { + const match = TEMPLATE_REGISTRY.find( + t => t.engine === engine && t.version === version + ); + return match?.alias; +} + +/** + * Get the fallback template ID for runtime installation + * This should be a bare-bones template with just the base OS + */ +export function getFallbackTemplateId(fallbackId?: string): string { + return fallbackId || "rki5dems9wqfm4r03t7g"; // Default E2B base template +} + diff --git a/sandbox-sidecar/src/types/runTypes.ts b/sandbox-sidecar/src/types/runTypes.ts index 79139bef2..f7017f3cd 100644 --- a/sandbox-sidecar/src/types/runTypes.ts +++ b/sandbox-sidecar/src/types/runTypes.ts @@ -12,6 +12,7 @@ export const runRequestSchema = z.object({ configuration_version_id: z.string().min(1), is_destroy: z.boolean(), terraform_version: z.string().optional(), + engine: z.enum(["terraform", "tofu"]).optional(), working_directory: z.string().optional(), config_archive: z.string().min(1), state: z.string().optional(), diff --git a/sandbox-sidecar/templates/build-all.ts b/sandbox-sidecar/templates/build-all.ts new file mode 100644 index 000000000..1532b253f --- /dev/null +++ b/sandbox-sidecar/templates/build-all.ts @@ -0,0 +1,43 @@ +import "dotenv/config"; +import { Template, defaultBuildLogger } from "e2b"; +import { terraformTemplate } from "./terraform-template.ts"; +import { tofuTemplate } from "./tofu-template.ts"; +import { TEMPLATES, aliasFor, TemplateSpec } from "./manifest.ts"; + +function buildTemplateObject(spec: TemplateSpec) { + return spec.engine === "terraform" + ? terraformTemplate(spec.engineVersion) + : tofuTemplate(spec.engineVersion); +} + +async function main() { + const [, , maybeTplVersion] = process.argv; + + const specs = maybeTplVersion + ? TEMPLATES.filter(t => t.tplVersion === maybeTplVersion) + : TEMPLATES; + + if (specs.length === 0) { + console.error("No templates match that tplVersion."); + process.exit(1); + } + + for (const spec of specs) { + const alias = aliasFor(spec); + console.log(`\n=== Building ${alias} ===`); + + await Template.build(buildTemplateObject(spec), { + alias, + cpuCount: 1, + memoryMB: 1024, + onBuildLogs: defaultBuildLogger(), + }); + + console.log(`āœ… Built ${alias}`); + } +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/sandbox-sidecar/templates/manifest.ts b/sandbox-sidecar/templates/manifest.ts new file mode 100644 index 000000000..8b4f415e9 --- /dev/null +++ b/sandbox-sidecar/templates/manifest.ts @@ -0,0 +1,26 @@ +// templates/manifest.ts +export type Engine = "terraform" | "tofu"; + +export interface TemplateSpec { + engine: Engine; + engineVersion: string; + tplVersion: string; +} + +export const TEMPLATE_VERSION = "0.1.0"; // bump this when recipe changes + +export const TEMPLATES: TemplateSpec[] = [ + { engine: "terraform", engineVersion: "1.0.11", tplVersion: TEMPLATE_VERSION }, + { engine: "terraform", engineVersion: "1.3.9", tplVersion: TEMPLATE_VERSION }, + { engine: "terraform", engineVersion: "1.5.5", tplVersion: TEMPLATE_VERSION }, + { engine: "terraform", engineVersion: "1.8.5", tplVersion: TEMPLATE_VERSION }, + { engine: "tofu", engineVersion: "1.6.0", tplVersion: TEMPLATE_VERSION }, + { engine: "tofu", engineVersion: "1.10.0", tplVersion: TEMPLATE_VERSION }, +]; + + +export function aliasFor(spec: TemplateSpec) { + const engineVerSlug = spec.engineVersion.replace(/\./g, "-"); + const tplVerSlug = spec.tplVersion.replace(/\./g, "-"); + return `${spec.engine}-${engineVerSlug}--tpl-${tplVerSlug}`; + } \ No newline at end of file diff --git a/sandbox-sidecar/templates/terraform-template.ts b/sandbox-sidecar/templates/terraform-template.ts new file mode 100644 index 000000000..02f7a9b83 --- /dev/null +++ b/sandbox-sidecar/templates/terraform-template.ts @@ -0,0 +1,25 @@ +// templates/terraform-template.ts +import { Template } from "e2b"; + +export function terraformTemplate(version: string) { + // version like "1.5.7" + return Template() + .fromUbuntuImage("22.04") + + // root for system-level install + .setUser("root") + .runCmd("apt-get update && apt-get install -y wget unzip ca-certificates") + .runCmd(` + set -e + cd /tmp + echo "Installing Terraform ${version}..." + wget -O terraform.zip https://releases.hashicorp.com/terraform/${version}/terraform_${version}_linux_amd64.zip + unzip terraform.zip + mv terraform /usr/local/bin/terraform + chmod +x /usr/local/bin/terraform + rm terraform.zip + `) + + // back to normal user for sandbox runtime + .setUser("user"); +} diff --git a/sandbox-sidecar/templates/tofu-template.ts b/sandbox-sidecar/templates/tofu-template.ts new file mode 100644 index 000000000..a2e4175da --- /dev/null +++ b/sandbox-sidecar/templates/tofu-template.ts @@ -0,0 +1,20 @@ +// templates/tofu-template.ts +import { Template } from "e2b"; + +export function tofuTemplate(version: string) { + return Template() + .fromUbuntuImage("22.04") + .setUser("root") + .runCmd("apt-get update && apt-get install -y wget ca-certificates") + .runCmd(` + set -e + cd /tmp + echo "Installing OpenTofu ${version}..." + wget -O tofu.tar.gz https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.tar.gz + tar -xzf tofu.tar.gz + mv tofu /usr/local/bin/tofu + chmod +x /usr/local/bin/tofu + rm tofu.tar.gz + `) + .setUser("user"); +} diff --git a/taco/internal/domain/interfaces.go b/taco/internal/domain/interfaces.go index 45ee24e9c..ec5e56eeb 100644 --- a/taco/internal/domain/interfaces.go +++ b/taco/internal/domain/interfaces.go @@ -150,6 +150,13 @@ type Unit struct { Updated time.Time `json:"updated"` Locked bool `json:"locked"` LockInfo *Lock `json:"lock_info,omitempty"` + + // TFE workspace settings + TFEAutoApply *bool `json:"tfe_auto_apply,omitempty"` + TFETerraformVersion *string `json:"tfe_terraform_version,omitempty"` + TFEEngine *string `json:"tfe_engine,omitempty"` + TFEWorkingDirectory *string `json:"tfe_working_directory,omitempty"` + TFEExecutionMode *string `json:"tfe_execution_mode,omitempty"` } // Lock represents a Terraform state lock in API responses diff --git a/taco/internal/query/common/sql_store.go b/taco/internal/query/common/sql_store.go index 78be2b059..94e2ada96 100644 --- a/taco/internal/query/common/sql_store.go +++ b/taco/internal/query/common/sql_store.go @@ -251,7 +251,7 @@ func (s *SQLStore) SyncDeleteUnit(ctx context.Context, blobPath string) error { } // UpdateUnitTFESettings updates TFE-specific settings for a unit -func (s *SQLStore) UpdateUnitTFESettings(ctx context.Context, unitID string, autoApply *bool, executionMode *string, terraformVersion *string, workingDirectory *string) error { +func (s *SQLStore) UpdateUnitTFESettings(ctx context.Context, unitID string, autoApply *bool, executionMode *string, terraformVersion *string, engine *string, workingDirectory *string) error { updates := make(map[string]interface{}) if autoApply != nil { @@ -263,6 +263,9 @@ func (s *SQLStore) UpdateUnitTFESettings(ctx context.Context, unitID string, aut if terraformVersion != nil { updates["tfe_terraform_version"] = *terraformVersion } + if engine != nil { + updates["tfe_engine"] = *engine + } if workingDirectory != nil { updates["tfe_working_directory"] = *workingDirectory } diff --git a/taco/internal/query/interface.go b/taco/internal/query/interface.go index e5c4d2307..286516d7b 100644 --- a/taco/internal/query/interface.go +++ b/taco/internal/query/interface.go @@ -19,7 +19,7 @@ type UnitQuery interface { SyncUnitLock(ctx context.Context, unitName string, lockID, lockWho string, lockCreated time.Time) error SyncUnitUnlock(ctx context.Context, unitName string) error SyncDeleteUnit(ctx context.Context, unitName string) error - UpdateUnitTFESettings(ctx context.Context, unitID string, autoApply *bool, executionMode *string, terraformVersion *string, workingDirectory *string) error + UpdateUnitTFESettings(ctx context.Context, unitID string, autoApply *bool, executionMode *string, terraformVersion *string, engine *string, workingDirectory *string) error } type RBACQuery interface { diff --git a/taco/internal/query/types/models.go b/taco/internal/query/types/models.go index 0d7468e8d..1cb380c67 100644 --- a/taco/internal/query/types/models.go +++ b/taco/internal/query/types/models.go @@ -158,6 +158,7 @@ type Unit struct { // TFE workspace settings (nullable for non-TFE usage) TFEAutoApply *bool `gorm:"default:null"` TFETerraformVersion *string `gorm:"type:varchar(50);default:null"` + TFEEngine *string `gorm:"type:varchar(20);default:'terraform'"` // 'terraform' or 'tofu' TFEWorkingDirectory *string `gorm:"type:varchar(500);default:null"` TFEExecutionMode *string `gorm:"type:varchar(50);default:null"` // 'remote', 'local', 'agent' } diff --git a/taco/internal/rbac/querystore.go b/taco/internal/rbac/querystore.go index 811f733bc..eecd52127 100644 --- a/taco/internal/rbac/querystore.go +++ b/taco/internal/rbac/querystore.go @@ -294,7 +294,11 @@ func (s *queryRBACStore) GetUserAssignment(ctx context.Context, orgID, subject s var user types.User err := s.db.WithContext(ctx). Where("subject = ?", subject). - Preload("Roles", "org_id = ?", orgID). // Filter roles by org + Preload("Roles", func(db *gorm.DB) *gorm.DB { + // Join through user_roles to filter by org_id + return db.Joins("JOIN user_roles ON user_roles.role_id = roles.id"). + Where("user_roles.org_id = ?", orgID) + }). First(&user).Error if err != nil { diff --git a/taco/internal/repositories/authorizing_repository.go b/taco/internal/repositories/authorizing_repository.go index bea4ea400..a346ad1b0 100644 --- a/taco/internal/repositories/authorizing_repository.go +++ b/taco/internal/repositories/authorizing_repository.go @@ -38,17 +38,43 @@ func NewAuthorizingRepository(repo domain.UnitRepository, rbacMgr *rbac.RBACMana func (a *authorizingRepository) Get(ctx context.Context, id string) (*storage.UnitMetadata, error) { principal, ok := rbac.PrincipalFromContext(ctx) if !ok { + slog.Error("šŸ” RBAC: No principal in context", + "operation", "Get", + "unit_id", id) return nil, storage.ErrUnauthorized } + slog.Info("šŸ” RBAC: Checking permission", + "operation", "Get", + "unit_id", id, + "principal_subject", principal.Subject, + "principal_email", principal.Email, + "principal_roles", principal.Roles, + "principal_groups", principal.Groups) + allowed, err := a.rbac.Can(ctx, principal, rbac.ActionUnitRead, id) if err != nil { + slog.Error("šŸ” RBAC: Permission check error", + "operation", "Get", + "unit_id", id, + "principal", principal.Subject, + "error", err) return nil, err } if !allowed { + slog.Warn("šŸ” RBAC: Permission denied", + "operation", "Get", + "unit_id", id, + "principal", principal.Subject, + "action", rbac.ActionUnitRead) return nil, storage.ErrForbidden } + slog.Info("šŸ” RBAC: Permission granted, calling underlying repository", + "operation", "Get", + "unit_id", id, + "principal", principal.Subject) + return a.underlying.Get(ctx, id) } diff --git a/taco/internal/repositories/unit_repository.go b/taco/internal/repositories/unit_repository.go index dfeeb3e29..06438777c 100644 --- a/taco/internal/repositories/unit_repository.go +++ b/taco/internal/repositories/unit_repository.go @@ -152,6 +152,7 @@ func (r *UnitRepository) Get(ctx context.Context, uuid string) (*storage.UnitMet // Include TFE workspace settings TFEAutoApply: unit.TFEAutoApply, TFETerraformVersion: unit.TFETerraformVersion, + TFEEngine: unit.TFEEngine, TFEWorkingDirectory: unit.TFEWorkingDirectory, TFEExecutionMode: unit.TFEExecutionMode, } diff --git a/taco/internal/sandbox/types.go b/taco/internal/sandbox/types.go index 7942adaca..848d6d9dc 100644 --- a/taco/internal/sandbox/types.go +++ b/taco/internal/sandbox/types.go @@ -2,7 +2,7 @@ package sandbox import "context" -// PlanRequest bundles the inputs needed to execute a Terraform plan inside a sandbox. +// PlanRequest bundles the inputs needed to execute a Terraform/OpenTofu plan inside a sandbox. type PlanRequest struct { RunID string PlanID string @@ -11,6 +11,7 @@ type PlanRequest struct { ConfigurationVersionID string IsDestroy bool TerraformVersion string + Engine string // "terraform" or "tofu" WorkingDirectory string ConfigArchive []byte State []byte @@ -28,7 +29,7 @@ type PlanResult struct { RuntimeRunID string } -// ApplyRequest bundles the inputs needed to execute a Terraform apply inside a sandbox. +// ApplyRequest bundles the inputs needed to execute a Terraform/OpenTofu apply inside a sandbox. type ApplyRequest struct { RunID string PlanID string @@ -37,6 +38,7 @@ type ApplyRequest struct { ConfigurationVersionID string IsDestroy bool TerraformVersion string + Engine string // "terraform" or "tofu" WorkingDirectory string ConfigArchive []byte State []byte diff --git a/taco/internal/storage/interface.go b/taco/internal/storage/interface.go index 04975c42f..f7ffd5d14 100644 --- a/taco/internal/storage/interface.go +++ b/taco/internal/storage/interface.go @@ -27,6 +27,7 @@ type UnitMetadata struct { // TFE workspace settings (nullable for non-TFE usage) TFEAutoApply *bool `json:"tfe_auto_apply,omitempty"` TFETerraformVersion *string `json:"tfe_terraform_version,omitempty"` + TFEEngine *string `json:"tfe_engine,omitempty"` // 'terraform' or 'tofu' TFEWorkingDirectory *string `json:"tfe_working_directory,omitempty"` TFEExecutionMode *string `json:"tfe_execution_mode,omitempty"` // 'remote', 'local', 'agent' LockID string `json:"lock_id,omitempty"` diff --git a/taco/internal/tfe/apply_executor.go b/taco/internal/tfe/apply_executor.go index 91ae016a5..0785ff05e 100644 --- a/taco/internal/tfe/apply_executor.go +++ b/taco/internal/tfe/apply_executor.go @@ -419,6 +419,7 @@ func (e *ApplyExecutor) executeApplyInSandbox(ctx context.Context, run *domain.T ConfigurationVersionID: run.ConfigurationVersionID, IsDestroy: run.IsDestroy, TerraformVersion: terraformVersionForUnit(unit), + Engine: engineForUnit(unit), WorkingDirectory: workingDirectoryForUnit(unit), ConfigArchive: archive, State: stateData, diff --git a/taco/internal/tfe/plan_executor.go b/taco/internal/tfe/plan_executor.go index 3936b7146..f00cfaefd 100644 --- a/taco/internal/tfe/plan_executor.go +++ b/taco/internal/tfe/plan_executor.go @@ -788,6 +788,7 @@ func (e *PlanExecutor) executePlanInSandbox(ctx context.Context, run *domain.TFE ConfigurationVersionID: run.ConfigurationVersionID, IsDestroy: run.IsDestroy, TerraformVersion: terraformVersionForUnit(unit), + Engine: engineForUnit(unit), WorkingDirectory: workingDirectoryForUnit(unit), ConfigArchive: archive, State: stateData, diff --git a/taco/internal/tfe/sandbox_helpers.go b/taco/internal/tfe/sandbox_helpers.go index 02dcfccf1..7c56e390a 100644 --- a/taco/internal/tfe/sandbox_helpers.go +++ b/taco/internal/tfe/sandbox_helpers.go @@ -35,6 +35,17 @@ func terraformVersionForUnit(unit *storage.UnitMetadata) string { return strings.TrimSpace(*unit.TFETerraformVersion) } +func engineForUnit(unit *storage.UnitMetadata) string { + if unit == nil || unit.TFEEngine == nil { + return "terraform" // Default to terraform + } + engine := strings.TrimSpace(strings.ToLower(*unit.TFEEngine)) + if engine == "tofu" || engine == "opentofu" { + return "tofu" + } + return "terraform" +} + func workingDirectoryForUnit(unit *storage.UnitMetadata) string { if unit == nil || unit.TFEWorkingDirectory == nil { return "" diff --git a/taco/internal/unit/handler.go b/taco/internal/unit/handler.go index df91667b5..4a52ea159 100644 --- a/taco/internal/unit/handler.go +++ b/taco/internal/unit/handler.go @@ -84,6 +84,7 @@ type CreateUnitRequest struct { TFEAutoApply *bool `json:"tfe_auto_apply"` TFEExecutionMode *string `json:"tfe_execution_mode"` TFETerraformVersion *string `json:"tfe_terraform_version"` + TFEEngine *string `json:"tfe_engine"` TFEWorkingDirectory *string `json:"tfe_working_directory"` } @@ -164,7 +165,7 @@ func (h *Handler) CreateUnit(c echo.Context) error { // Update TFE fields if provided (after unit creation) if req.TFEAutoApply != nil || req.TFEExecutionMode != nil || req.TFETerraformVersion != nil || req.TFEWorkingDirectory != nil { if h.queryStore != nil { - if err := h.queryStore.UpdateUnitTFESettings(ctx, metadata.ID, req.TFEAutoApply, req.TFEExecutionMode, req.TFETerraformVersion, req.TFEWorkingDirectory); err != nil { + if err := h.queryStore.UpdateUnitTFESettings(ctx, metadata.ID, req.TFEAutoApply, req.TFEExecutionMode, req.TFETerraformVersion, req.TFEEngine, req.TFEWorkingDirectory); err != nil { logger.Warn("Failed to update TFE settings for unit", "operation", "create_unit", "unit_id", metadata.ID, @@ -195,6 +196,7 @@ type UpdateUnitRequest struct { TFEAutoApply *bool `json:"tfe_auto_apply"` TFEExecutionMode *string `json:"tfe_execution_mode"` TFETerraformVersion *string `json:"tfe_terraform_version"` + TFEEngine *string `json:"tfe_engine"` TFEWorkingDirectory *string `json:"tfe_working_directory"` } @@ -251,7 +253,7 @@ func (h *Handler) UpdateUnit(c echo.Context) error { // Update TFE settings if any are provided if req.TFEAutoApply != nil || req.TFEExecutionMode != nil || req.TFETerraformVersion != nil || req.TFEWorkingDirectory != nil { if h.queryStore != nil { - if err := h.queryStore.UpdateUnitTFESettings(ctx, unitID, req.TFEAutoApply, req.TFEExecutionMode, req.TFETerraformVersion, req.TFEWorkingDirectory); err != nil { + if err := h.queryStore.UpdateUnitTFESettings(ctx, unitID, req.TFEAutoApply, req.TFEExecutionMode, req.TFETerraformVersion, req.TFEEngine, req.TFEWorkingDirectory); err != nil { logger.Error("Failed to update TFE settings for unit", "operation", "update_unit", "unit_id", unitID, @@ -345,6 +347,13 @@ func (h *Handler) GetUnit(c echo.Context) error { logger := logging.FromContext(c) ctx := c.Request().Context() encodedID := c.Param("id") + + logger.Info("šŸ” GetUnit called", + "operation", "get_unit", + "encoded_id", encodedID, + "headers", c.Request().Header, + ) + id, err := h.resolveUnitIdentifier(ctx, encodedID) if err != nil { logger.Warn("Unit not found during resolution", @@ -358,6 +367,11 @@ func (h *Handler) GetUnit(c echo.Context) error { }) } + logger.Info("šŸ” Unit identifier resolved", + "operation", "get_unit", + "resolved_id", id, + ) + if err := domain.ValidateUnitID(id); err != nil { logger.Warn("Invalid unit ID", "operation", "get_unit", @@ -367,13 +381,20 @@ func (h *Handler) GetUnit(c echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - logger.Info("Getting unit", + logger.Info("šŸ” Getting unit from store", "operation", "get_unit", "unit_id", id, ) metadata, err := h.store.Get(ctx, id) if err != nil { + logger.Error("šŸ” Store.Get failed", + "operation", "get_unit", + "unit_id", id, + "error", err, + "error_type", fmt.Sprintf("%T", err), + "error_string", err.Error(), + ) if err.Error() == "forbidden" { logger.Warn("Forbidden access to unit", "operation", "get_unit", @@ -409,6 +430,13 @@ func (h *Handler) GetUnit(c echo.Context) error { Updated: metadata.Updated, Locked: metadata.Locked, LockInfo: convertLockInfo(metadata.LockInfo), + + // Include TFE workspace settings + TFEAutoApply: metadata.TFEAutoApply, + TFETerraformVersion: metadata.TFETerraformVersion, + TFEEngine: metadata.TFEEngine, + TFEWorkingDirectory: metadata.TFEWorkingDirectory, + TFEExecutionMode: metadata.TFEExecutionMode, }) } diff --git a/taco/migrations/mysql/20251118000000_add_tfe_engine.sql b/taco/migrations/mysql/20251118000000_add_tfe_engine.sql new file mode 100644 index 000000000..50f9b5fc6 --- /dev/null +++ b/taco/migrations/mysql/20251118000000_add_tfe_engine.sql @@ -0,0 +1,7 @@ +-- Add engine field to support both Terraform and OpenTofu +ALTER TABLE `units` +ADD COLUMN `tfe_engine` VARCHAR(20) DEFAULT 'terraform' COMMENT 'IaC engine: terraform or tofu'; + +-- Add index for engine queries +CREATE INDEX `idx_units_tfe_engine` ON `units` (`tfe_engine`); + diff --git a/taco/migrations/postgres/20251118000000_add_tfe_engine.sql b/taco/migrations/postgres/20251118000000_add_tfe_engine.sql new file mode 100644 index 000000000..319272cca --- /dev/null +++ b/taco/migrations/postgres/20251118000000_add_tfe_engine.sql @@ -0,0 +1,9 @@ +-- Add engine field to support both Terraform and OpenTofu +ALTER TABLE units +ADD COLUMN tfe_engine VARCHAR(20) DEFAULT 'terraform'; + +COMMENT ON COLUMN units.tfe_engine IS 'IaC engine: terraform or tofu'; + +-- Add index for engine queries +CREATE INDEX idx_units_tfe_engine ON units (tfe_engine); + diff --git a/taco/migrations/sqlite/20251118000000_add_tfe_engine.sql b/taco/migrations/sqlite/20251118000000_add_tfe_engine.sql new file mode 100644 index 000000000..cafe723a8 --- /dev/null +++ b/taco/migrations/sqlite/20251118000000_add_tfe_engine.sql @@ -0,0 +1,7 @@ +-- Add engine field to support both Terraform and OpenTofu +ALTER TABLE units +ADD COLUMN tfe_engine TEXT DEFAULT 'terraform'; + +-- Add index for engine queries +CREATE INDEX idx_units_tfe_engine ON units (tfe_engine); + diff --git a/ui/src/api/statesman_serverFunctions.ts b/ui/src/api/statesman_serverFunctions.ts index 76dd3adfc..64b5778dc 100644 --- a/ui/src/api/statesman_serverFunctions.ts +++ b/ui/src/api/statesman_serverFunctions.ts @@ -74,6 +74,7 @@ export const createUnitFn = createServerFn({method: 'POST'}) tfeAutoApply?: boolean, tfeExecutionMode?: string, tfeTerraformVersion?: string, + tfeEngine?: string, tfeWorkingDirectory?: string }) => data) .handler(async ({ data }) => { @@ -85,6 +86,7 @@ export const createUnitFn = createServerFn({method: 'POST'}) data.tfeAutoApply, data.tfeExecutionMode, data.tfeTerraformVersion, + data.tfeEngine, data.tfeWorkingDirectory ); return unit; diff --git a/ui/src/api/statesman_units.ts b/ui/src/api/statesman_units.ts index a37cb72d1..dc03a7eda 100644 --- a/ui/src/api/statesman_units.ts +++ b/ui/src/api/statesman_units.ts @@ -178,6 +178,7 @@ export async function createUnit( tfeAutoApply?: boolean, tfeExecutionMode?: string, tfeTerraformVersion?: string, + tfeEngine?: string, tfeWorkingDirectory?: string ) { const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units`, { @@ -195,6 +196,7 @@ export async function createUnit( tfe_auto_apply: tfeAutoApply, tfe_execution_mode: tfeExecutionMode, tfe_terraform_version: tfeTerraformVersion, + tfe_engine: tfeEngine, tfe_working_directory: tfeWorkingDirectory, }), }); @@ -206,6 +208,43 @@ export async function createUnit( return response.json(); } +export async function updateUnit( + orgId: string, + userId: string, + email: string, + unitId: string, + tfeAutoApply?: boolean, + tfeExecutionMode?: string, + tfeTerraformVersion?: string, + tfeEngine?: string, + tfeWorkingDirectory?: string +) { + const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.STATESMAN_BACKEND_WEBHOOK_SECRET}`, + 'X-Org-ID': orgId, + 'X-User-ID': userId, + 'X-Email': email, + 'X-Request-ID': generateRequestId(), + }, + body: JSON.stringify({ + tfe_auto_apply: tfeAutoApply, + tfe_execution_mode: tfeExecutionMode, + tfe_terraform_version: tfeTerraformVersion, + tfe_engine: tfeEngine, + tfe_working_directory: tfeWorkingDirectory, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to update unit: ${response.statusText}`); + } + + return response.json(); +} + export async function deleteUnit(orgId: string, userId: string, email: string, unitId: string) { const response = await fetch(`${process.env.STATESMAN_BACKEND_URL}/internal/api/units/${unitId}`, { method: 'DELETE', diff --git a/ui/src/components/UnitCreateForm.tsx b/ui/src/components/UnitCreateForm.tsx index f8f35c0de..8b5fd1150 100644 --- a/ui/src/components/UnitCreateForm.tsx +++ b/ui/src/components/UnitCreateForm.tsx @@ -30,6 +30,7 @@ export default function UnitCreateForm({ }: UnitCreateFormProps) { const [unitName, setUnitName] = React.useState('') const [unitType, setUnitType] = React.useState<'local' | 'remote'>('local') + const [engine, setEngine] = React.useState<'terraform' | 'tofu'>('terraform') const [terraformVersion, setTerraformVersion] = React.useState('1.5.5') const [isCreating, setIsCreating] = React.useState(false) const [error, setError] = React.useState(null) @@ -64,6 +65,7 @@ export default function UnitCreateForm({ tfeAutoApply: false, tfeExecutionMode: finalUnitType === 'remote' ? 'remote' : 'local', tfeTerraformVersion: finalUnitType === 'remote' ? terraformVersion : undefined, + tfeEngine: finalUnitType === 'remote' ? engine : undefined, }, }) // analytics: track unit creation @@ -169,22 +171,71 @@ export default function UnitCreateForm({
{unitType === 'remote' && ( -
- -
- setTerraformVersion(e.target.value)} - placeholder="1.5.5" - className="font-mono" - /> -

- Default: 1.5.5 (pre-built template, fast startup). - Custom versions will be installed at runtime (slower first run). -

+ <> +
+ +
+ { + setEngine(v as 'terraform' | 'tofu') + // Set default version based on engine + if (v === 'tofu') { + setTerraformVersion('1.10.0') + } else { + setTerraformVersion('1.5.5') + } + }} + className="flex gap-4" + > + + + +
-
+ +
+ +
+ setTerraformVersion(e.target.value)} + placeholder={engine === 'tofu' ? '1.10.0' : '1.5.5'} + className="font-mono" + /> +

+ {engine === 'terraform' ? ( + <>Pre-built versions: 1.0.11, 1.3.9, 1.5.5 (fast startup). Custom versions installed at runtime. + ) : ( + <>Pre-built versions: 1.6.0, 1.10.0 (fast startup). Custom versions installed at runtime. + )} +

+
+
+ )} {error &&

{error}

} diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx index 49cd624aa..3c5f9b776 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx @@ -22,10 +22,13 @@ import { TabsTrigger, } from "@/components/ui/tabs" import { Badge } from "@/components/ui/badge" -import { ArrowLeft, Lock, Unlock, MoreVertical, History, Trash2, Download, Upload, RefreshCcw, Copy, Check, ArrowUpRight } from 'lucide-react' +import { ArrowLeft, Lock, Unlock, MoreVertical, History, Trash2, Download, Upload, RefreshCcw, Copy, Check, ArrowUpRight, Save } from 'lucide-react' import { useState } from 'react' import { toast } from '@/hooks/use-toast' import { getUnitFn, getUnitVersionsFn, lockUnitFn, unlockUnitFn, getUnitStatusFn, deleteUnitFn, downloadLatestStateFn, restoreUnitStateVersionFn } from '@/api/statesman_serverFunctions' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { getPublicServerConfig } from '@/lib/env.server' import { DetailsSkeleton } from '@/components/LoadingSkeleton' @@ -136,6 +139,26 @@ function RouteComponent() { const { unitData, unitVersions, unitStatus, organisationId, organisationName, publicHostname, user } = data const unit = unitData const router = useRouter() + + // Debug: log unit data to console + console.log('Unit data:', { + id: unit.id, + name: unit.name, + tfe_execution_mode: unit.tfe_execution_mode, + tfe_engine: unit.tfe_engine, + tfe_terraform_version: unit.tfe_terraform_version + }) + + // Settings state - handle null/undefined with sensible defaults + const [engine, setEngine] = useState<'terraform' | 'tofu'>( + (unit.tfe_engine as 'terraform' | 'tofu') || 'terraform' + ) + const [version, setVersion] = useState( + unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' + ? unit.tfe_terraform_version + : '1.5.5' + ) + const [isSavingSettings, setIsSavingSettings] = useState(false) const handleUnlock = async () => { try { @@ -279,6 +302,41 @@ function RouteComponent() { } } + const handleUpdateSettings = async () => { + setIsSavingSettings(true) + try { + const { updateUnit } = await import('@/api/statesman_units') + await updateUnit( + organisationId || '', + user?.id || '', + user?.email || '', + unit.id, + undefined, // tfeAutoApply + undefined, // tfeExecutionMode + version, + engine, + undefined // tfeWorkingDirectory + ) + toast({ + title: 'Settings updated', + description: 'Unit settings were updated successfully.', + duration: 2000, + variant: "default" + }) + router.invalidate() + } catch (error) { + console.error('Failed to update settings', error) + toast({ + title: 'Failed to update settings', + description: 'Failed to update unit settings.', + duration: 5000, + variant: "destructive" + }) + } finally { + setIsSavingSettings(false) + } + } + return (
@@ -414,6 +472,87 @@ function RouteComponent() { + {(unit.tfe_execution_mode === 'remote' || unit.tfe_execution_mode === 'agent') && ( + + + Remote Execution Settings + Configure the IaC engine and version for remote runs + + +
+
+ +
+ { + setEngine(v as 'terraform' | 'tofu') + // Set default version based on engine + if (v === 'tofu') { + setVersion('1.10.0') + } else { + setVersion('1.5.5') + } + }} + className="flex gap-4" + > + + + +
+
+ +
+ +
+ setVersion(e.target.value)} + placeholder={engine === 'tofu' ? '1.10.0' : '1.5.5'} + className="font-mono" + /> +

+ {engine === 'terraform' ? ( + <>Pre-built versions: 1.0.11, 1.3.9, 1.5.5 (fast startup). Custom versions installed at runtime. + ) : ( + <>Pre-built versions: 1.6.0, 1.10.0 (fast startup). Custom versions installed at runtime. + )} +

+
+
+ + +
+
+
+ )} + Dangerous Operations From 87495797a1079b7c6bc01e46356fbb80e7c63ab5 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 16:32:48 -0800 Subject: [PATCH 03/13] add release --- .github/release-please-config.json | 5 ++ .github/workflows/sidecar-release.yml | 76 ++++++++++++++++++++ .github/workflows/update-helm-on-release.yml | 5 ++ .release-please-manifest.json | 3 +- helm-charts/secrets-example/sidecar.env | 8 +++ helm-charts/secrets-example/statesman.env | 5 ++ sandbox-sidecar/.dockerignore | 19 +++++ sandbox-sidecar/Dockerfile_sidecar | 49 +++++++++++++ sandbox-sidecar/Makefile | 49 +++++++++++++ sandbox-sidecar/README.md | 31 ++++++++ ui/src/vite-env.d.ts | 8 --- 11 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/sidecar-release.yml create mode 100644 helm-charts/secrets-example/sidecar.env create mode 100644 sandbox-sidecar/.dockerignore create mode 100644 sandbox-sidecar/Dockerfile_sidecar create mode 100644 sandbox-sidecar/Makefile diff --git a/.github/release-please-config.json b/.github/release-please-config.json index 03841008e..e0b4c6acb 100644 --- a/.github/release-please-config.json +++ b/.github/release-please-config.json @@ -23,6 +23,11 @@ "release-type": "go", "component": "taco/statesman", "changelog-path": ".changelog-ignore/CHANGELOG.md" + }, + "sandbox-sidecar": { + "release-type": "node", + "component": "sandbox-sidecar", + "changelog-path": "CHANGELOG.md" } } } diff --git a/.github/workflows/sidecar-release.yml b/.github/workflows/sidecar-release.yml new file mode 100644 index 000000000..9bcef0729 --- /dev/null +++ b/.github/workflows/sidecar-release.yml @@ -0,0 +1,76 @@ +name: Sidecar Release + +on: + push: + branches: + - main + - develop + paths: + - 'sandbox-sidecar/**' + - '.github/workflows/sidecar-release.yml' + pull_request: + paths: + - 'sandbox-sidecar/**' + release: + types: [published] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: diggerhq/sandbox-sidecar + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./sandbox-sidecar + file: ./sandbox-sidecar/Dockerfile_sidecar + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: Generate artifact attestation + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + diff --git a/.github/workflows/update-helm-on-release.yml b/.github/workflows/update-helm-on-release.yml index 2bdf76b34..4c8245259 100644 --- a/.github/workflows/update-helm-on-release.yml +++ b/.github/workflows/update-helm-on-release.yml @@ -8,6 +8,7 @@ on: - "UI Docker Release" - "taco-release" - "Projects Refresh Service Docker Release" + - "Sidecar Release" types: - completed @@ -52,6 +53,10 @@ jobs: # Projects refresh doesn't have a dedicated section yet echo "Projects refresh service detected, skipping helm update (no chart section)" exit 0 + elif [[ $TAG_NAME == sandbox-sidecar/v* ]]; then + SERVICE="sandbox-sidecar" + VERSION="${TAG_NAME##sandbox-sidecar/}" + YAML_PATH=".taco-sidecar.sidecar.image.tag" else echo "Not a recognized release tag, skipping" exit 0 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 660618308..0ef03ee74 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,5 @@ { "taco/cmd/taco": "0.1.10", - "taco/cmd/statesman": "0.1.10" + "taco/cmd/statesman": "0.1.10", + "sandbox-sidecar": "0.1.0" } \ No newline at end of file diff --git a/helm-charts/secrets-example/sidecar.env b/helm-charts/secrets-example/sidecar.env new file mode 100644 index 000000000..6153592bf --- /dev/null +++ b/helm-charts/secrets-example/sidecar.env @@ -0,0 +1,8 @@ +# Runner type +SANDBOX_RUNNER="e2b" + +# E2B authentication +E2B_API_KEY="your-e2b-api-key" + +# Bare-bones template for custom versions +E2B_BAREBONES_TEMPLATE_ID="your-template-id" \ No newline at end of file diff --git a/helm-charts/secrets-example/statesman.env b/helm-charts/secrets-example/statesman.env index ef56a1e96..ee8f5d4d8 100644 --- a/helm-charts/secrets-example/statesman.env +++ b/helm-charts/secrets-example/statesman.env @@ -51,3 +51,8 @@ OPENTACO_POSTGRES_DBNAME=taco OPENTACO_POSTGRES_SSLMODE=disable OPENTACO_QUERY_BACKEND=postgres +# Enable E2B sandbox provider +OPENTACO_SANDBOX_PROVIDER="e2b" + +# Sidecar URL +OPENTACO_E2B_SIDECAR_URL="http://localhost:9100" \ No newline at end of file diff --git a/sandbox-sidecar/.dockerignore b/sandbox-sidecar/.dockerignore new file mode 100644 index 000000000..df7ae1186 --- /dev/null +++ b/sandbox-sidecar/.dockerignore @@ -0,0 +1,19 @@ +node_modules +npm-debug.log +dist +.env +.env.* +*.log +.DS_Store +coverage +.vscode +.idea +*.swp +*.swo +*~ +.git +.gitignore +README.md +templates +examples + diff --git a/sandbox-sidecar/Dockerfile_sidecar b/sandbox-sidecar/Dockerfile_sidecar new file mode 100644 index 000000000..48bc0f6ae --- /dev/null +++ b/sandbox-sidecar/Dockerfile_sidecar @@ -0,0 +1,49 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY src ./src + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S sidecar && \ + adduser -S sidecar -u 1001 + +# Switch to non-root user +USER sidecar + +# Expose port +EXPOSE 9100 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:9100/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));" + +# Start the application +CMD ["node", "dist/index.js"] + diff --git a/sandbox-sidecar/Makefile b/sandbox-sidecar/Makefile new file mode 100644 index 000000000..ac93ad751 --- /dev/null +++ b/sandbox-sidecar/Makefile @@ -0,0 +1,49 @@ +.PHONY: build push run dev test clean + +# Variables +IMAGE_NAME ?= digger/sandbox-sidecar +VERSION ?= latest +REGISTRY ?= ghcr.io/diggerhq + +# Build the Docker image +build: + docker build -f Dockerfile_sidecar -t $(IMAGE_NAME):$(VERSION) . + +# Tag for registry +tag: + docker tag $(IMAGE_NAME):$(VERSION) $(REGISTRY)/$(IMAGE_NAME):$(VERSION) + +# Push to registry +push: tag + docker push $(REGISTRY)/$(IMAGE_NAME):$(VERSION) + +# Build and push +release: build push + +# Run container locally +run: + docker run -p 9100:9100 \ + --env-file .env \ + $(IMAGE_NAME):$(VERSION) + +# Development mode (with hot reload) +dev: + npm run dev + +# Run tests +test: + npm test + +# Clean up +clean: + rm -rf dist node_modules + docker rmi $(IMAGE_NAME):$(VERSION) 2>/dev/null || true + +# Install dependencies +install: + npm install + +# Build TypeScript +compile: + npm run build + diff --git a/sandbox-sidecar/README.md b/sandbox-sidecar/README.md index 4dfe9a3a1..20e82ac50 100644 --- a/sandbox-sidecar/README.md +++ b/sandbox-sidecar/README.md @@ -10,6 +10,8 @@ This package hosts a lightweight Node.js/TypeScript service that exposes the ## Getting Started +### Local Development + ```bash cd sandbox-sidecar npm install @@ -21,6 +23,35 @@ npm start The service listens on `PORT` (default `9100`). +### Docker + +```bash +# Build the image +docker build -f Dockerfile_sidecar -t sandbox-sidecar:latest . + +# Run the container +docker run -p 9100:9100 \ + -e SANDBOX_RUNNER=e2b \ + -e E2B_API_KEY=your-api-key \ + -e E2B_BAREBONES_TEMPLATE_ID=your-template-id \ + sandbox-sidecar:latest +``` + +### Using Pre-built Images + +```bash +# Pull from GitHub Container Registry +docker pull ghcr.io/diggerhq/sandbox-sidecar:latest + +# Run with environment file +docker run -p 9100:9100 \ + --env-file .env \ + ghcr.io/diggerhq/sandbox-sidecar:latest + +# Or with Kubernetes/Helm (see helm-charts repo) +helm install taco-sidecar diggerhq/taco-sidecar +``` + ## Configuration | Variable | Description | diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 5c4034424..11f02fe2a 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -1,9 +1 @@ /// - -interface ImportMetaEnv { - readonly VITE_REMOTE_RUNS_ENABLED?: string -} - -interface ImportMeta { - readonly env: ImportMetaEnv -} From 3237997e5bb87481eeb7bc4f620693d3f590d64e Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 16:37:41 -0800 Subject: [PATCH 04/13] remove out of range terraform --- sandbox-sidecar/src/templateRegistry.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts index 4874550d7..24beab6df 100644 --- a/sandbox-sidecar/src/templateRegistry.ts +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -23,7 +23,6 @@ export const TEMPLATE_REGISTRY: TemplateInfo[] = [ { engine: "terraform", version: "1.0.11", alias: aliasFor("terraform", "1.0.11", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.3.9", alias: aliasFor("terraform", "1.3.9", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.5.5", alias: aliasFor("terraform", "1.5.5", TEMPLATE_VERSION) }, - { engine: "terraform", version: "1.8.5", alias: aliasFor("terraform", "1.8.5", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.6.0", alias: aliasFor("tofu", "1.6.0", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.10.0", alias: aliasFor("tofu", "1.10.0", TEMPLATE_VERSION) }, ]; From 1dce7bbacf606527674b313cb4d4d0718e615647 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 16:38:52 -0800 Subject: [PATCH 05/13] remove out of range tf --- sandbox-sidecar/templates/manifest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/sandbox-sidecar/templates/manifest.ts b/sandbox-sidecar/templates/manifest.ts index 8b4f415e9..3b1baa4a2 100644 --- a/sandbox-sidecar/templates/manifest.ts +++ b/sandbox-sidecar/templates/manifest.ts @@ -13,7 +13,6 @@ export const TEMPLATES: TemplateSpec[] = [ { engine: "terraform", engineVersion: "1.0.11", tplVersion: TEMPLATE_VERSION }, { engine: "terraform", engineVersion: "1.3.9", tplVersion: TEMPLATE_VERSION }, { engine: "terraform", engineVersion: "1.5.5", tplVersion: TEMPLATE_VERSION }, - { engine: "terraform", engineVersion: "1.8.5", tplVersion: TEMPLATE_VERSION }, { engine: "tofu", engineVersion: "1.6.0", tplVersion: TEMPLATE_VERSION }, { engine: "tofu", engineVersion: "1.10.0", tplVersion: TEMPLATE_VERSION }, ]; From 8f972419d05d4e5e5f4498fad4b7893279d415b3 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 16:42:54 -0800 Subject: [PATCH 06/13] remove local runner --- sandbox-sidecar/README.md | 29 +-- sandbox-sidecar/src/config.ts | 21 +- sandbox-sidecar/src/runners/index.ts | 9 +- sandbox-sidecar/src/runners/localRunner.ts | 228 --------------------- 4 files changed, 19 insertions(+), 268 deletions(-) delete mode 100644 sandbox-sidecar/src/runners/localRunner.ts diff --git a/sandbox-sidecar/README.md b/sandbox-sidecar/README.md index 20e82ac50..4a32137b4 100644 --- a/sandbox-sidecar/README.md +++ b/sandbox-sidecar/README.md @@ -57,10 +57,9 @@ helm install taco-sidecar diggerhq/taco-sidecar | Variable | Description | | --- | --- | | `PORT` | HTTP port for the sidecar (default `9100`). | -| `SANDBOX_RUNNER` | `local` or `e2b`. Defaults to `local`. | -| `E2B_API_KEY` | Required for `SANDBOX_RUNNER=e2b`. | -| `E2B_BAREBONES_TEMPLATE_ID` | Optional fallback template ID for runtime installation (defaults to `rki5dems9wqfm4r03t7g`). | -| `LOCAL_TERRAFORM_BIN` | Optional path to the `terraform` binary (defaults to `terraform` in `$PATH`). | +| `SANDBOX_RUNNER` | Must be `e2b`. | +| `E2B_API_KEY` | Required. Your E2B API key. | +| `E2B_BAREBONES_TEMPLATE_ID` | Required. Fallback template ID for runtime installation. | ### Terraform/OpenTofu Version Selection @@ -77,20 +76,11 @@ The sidecar automatically selects the best execution environment: Users specify the version when creating a unit in the UI (defaults to 1.5.5). -### Local Runner - -The bundled local runner is intended for development. It unpacks the provided -archive, writes the optional state payload, and shells out to a Terraform binary -installed on the same host. All stdout/stderr is captured and streamed back to -the requester. - ### E2B Runner -An opinionated `E2BSandboxRunner` is included as a scaffold. Hook it up to the -official SDK by wiring the `runPlan`/`runApply` helpers with the appropriate E2B API -calls and file upload primitives (see `src/runners/e2bRunner.ts` for the TODOs). -Once implemented, switch `SANDBOX_RUNNER=e2b` and provide `E2B_API_KEY` plus a -template/blueprint identifier. +The sidecar uses E2B sandboxes for secure, isolated Terraform/OpenTofu execution. +Each run creates an ephemeral sandbox, executes the IaC commands, and returns +results. Sandboxes are automatically cleaned up after execution. ## API Surface @@ -131,9 +121,6 @@ failure, `error` contains the reason string. A `failed` response never includes - This package intentionally keeps job state in-memory. Use a persistent store (Redis, Postgres) before running multiple replicas. -- The local runner shell-outs to `terraform`. Sandbox machines therefore need - Terraform installed and accessible in `$PATH`. -- The E2B runner is wired as an interchangeable strategy: extend it or add - additional runners (Kubernetes, Nomad, etc.) as needed without touching - the Go control plane. +- E2B sandboxes are ephemeral and isolated - each run gets a fresh environment. +- Pre-built templates provide instant startup; custom versions install at runtime (~1-2s). diff --git a/sandbox-sidecar/src/config.ts b/sandbox-sidecar/src/config.ts index 3555a264c..d2d818f5d 100644 --- a/sandbox-sidecar/src/config.ts +++ b/sandbox-sidecar/src/config.ts @@ -2,17 +2,13 @@ import dotenv from "dotenv"; dotenv.config(); -export type RunnerType = "local" | "e2b"; +export type RunnerType = "e2b"; export interface AppConfig { port: number; runner: RunnerType; - local: { - terraformBinary: string; - }; e2b: { apiKey?: string; - defaultTemplateId?: string; // Pre-built template with TF 1.5.5 bareBonesTemplateId?: string; // Base template for custom versions }; } @@ -29,19 +25,18 @@ const parsePort = (value: string | undefined, fallback: number) => { }; export function loadConfig(): AppConfig { - const runnerEnv = (process.env.SANDBOX_RUNNER || "local").toLowerCase(); - const runner: RunnerType = runnerEnv === "e2b" ? "e2b" : "local"; + const runnerEnv = (process.env.SANDBOX_RUNNER || "e2b").toLowerCase(); + + if (runnerEnv !== "e2b") { + throw new Error("Only E2B runner is supported. Set SANDBOX_RUNNER=e2b"); + } return { port: parsePort(process.env.PORT, 9100), - runner, - local: { - terraformBinary: process.env.LOCAL_TERRAFORM_BIN || "terraform", - }, + runner: "e2b", e2b: { apiKey: process.env.E2B_API_KEY, - defaultTemplateId: process.env.E2B_DEFAULT_TEMPLATE_ID, // Pre-built with TF 1.5.5 - bareBonesTemplateId: process.env.E2B_BAREBONES_TEMPLATE_ID, // Base for custom versions + bareBonesTemplateId: process.env.E2B_BAREBONES_TEMPLATE_ID, }, }; } diff --git a/sandbox-sidecar/src/runners/index.ts b/sandbox-sidecar/src/runners/index.ts index 77d3c5e50..33e286e1f 100644 --- a/sandbox-sidecar/src/runners/index.ts +++ b/sandbox-sidecar/src/runners/index.ts @@ -1,14 +1,11 @@ import { AppConfig } from "../config.js"; import { SandboxRunner } from "./types.js"; -import { LocalTerraformRunner } from "./localRunner.js"; import { E2BSandboxRunner } from "./e2bRunner.js"; export function createRunner(config: AppConfig): SandboxRunner { - if (config.runner === "e2b") { - return new E2BSandboxRunner(config.e2b); + if (config.runner !== "e2b") { + throw new Error("Only E2B runner is supported. Set SANDBOX_RUNNER=e2b"); } - return new LocalTerraformRunner({ - terraformBinary: config.local.terraformBinary, - }); + return new E2BSandboxRunner(config.e2b); } diff --git a/sandbox-sidecar/src/runners/localRunner.ts b/sandbox-sidecar/src/runners/localRunner.ts deleted file mode 100644 index 7efef035c..000000000 --- a/sandbox-sidecar/src/runners/localRunner.ts +++ /dev/null @@ -1,228 +0,0 @@ -import os from "os"; -import path from "path"; -import { promises as fs } from "fs"; -import { spawn } from "child_process"; -import tar from "tar"; -import { SandboxRunner, RunnerOutput } from "./types.js"; -import { SandboxRunRecord, SandboxRunResult } from "../jobs/jobTypes.js"; -import { logger } from "../logger.js"; - -interface LocalRunnerOptions { - terraformBinary: string; -} - -interface CommandResult { - code: number; - stdout: string; - stderr: string; -} - -export class LocalTerraformRunner implements SandboxRunner { - readonly name = "local"; - - constructor(private readonly options: LocalRunnerOptions) {} - - async run(job: SandboxRunRecord): Promise { - if (job.payload.operation === "plan") { - return this.runPlan(job); - } - return this.runApply(job); - } - - private async runPlan(job: SandboxRunRecord): Promise { - const workspace = await this.createWorkspace(job); - try { - const logs: string[] = []; - await this.runTerraformCommand( - workspace.execCwd, - ["init", "-input=false", "-no-color"], - logs, - ); - - const planArgs = ["plan", "-input=false", "-no-color", "-out=tfplan.binary"]; - if (job.payload.isDestroy) { - planArgs.splice(1, 0, "-destroy"); - } - await this.runTerraformCommand(workspace.execCwd, planArgs, logs); - - const show = await this.runTerraformCommand( - workspace.execCwd, - ["show", "-json", "tfplan.binary"], - ); - logs.push(show.stdout); - - const planJSON = show.stdout; - const summary = summarizePlan(planJSON); - const result: SandboxRunResult = { - hasChanges: summary.hasChanges, - resourceAdditions: summary.additions, - resourceChanges: summary.changes, - resourceDestructions: summary.destroys, - planJSON: Buffer.from(planJSON, "utf8").toString("base64"), - }; - - return { logs: logs.join("\n"), result }; - } finally { - await fs.rm(workspace.root, { recursive: true, force: true }); - } - } - - private async runApply(job: SandboxRunRecord): Promise { - const workspace = await this.createWorkspace(job); - try { - const logs: string[] = []; - await this.runTerraformCommand( - workspace.execCwd, - ["init", "-input=false", "-no-color"], - logs, - ); - - const applyCommand = job.payload.isDestroy ? "destroy" : "apply"; - await this.runTerraformCommand( - workspace.execCwd, - [applyCommand, "-auto-approve", "-input=false", "-no-color"], - logs, - ); - - const statePath = path.join(workspace.execCwd, "terraform.tfstate"); - const stateBuffer = await fs.readFile(statePath); - const result: SandboxRunResult = { - state: stateBuffer.toString("base64"), - }; - - return { logs: logs.join("\n"), result }; - } finally { - await fs.rm(workspace.root, { recursive: true, force: true }); - } - } - - private async createWorkspace(job: SandboxRunRecord) { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "taco-sbx-")); - const archivePath = path.join(root, "bundle.tar.gz"); - await fs.writeFile( - archivePath, - Buffer.from(job.payload.configArchive, "base64"), - ); - await tar.x({ - file: archivePath, - cwd: root, - }); - - const workingDirectory = job.payload.workingDirectory - ? path.join(root, job.payload.workingDirectory) - : root; - const execCwd = path.resolve(workingDirectory); - const exists = await pathExists(execCwd); - if (!exists) { - throw new Error( - `Working directory ${job.payload.workingDirectory} not found in archive`, - ); - } - - if (job.payload.state) { - const statePath = path.join(execCwd, "terraform.tfstate"); - await fs.writeFile(statePath, Buffer.from(job.payload.state, "base64")); - } - - return { root, execCwd }; - } - - private async runTerraformCommand( - cwd: string, - args: string[], - logBuffer?: string[], - ): Promise { - const result = await runCommand(this.options.terraformBinary, args, cwd); - const mergedLogs = `${result.stdout}\n${result.stderr}`.trim(); - if (logBuffer && mergedLogs.length > 0) { - logBuffer.push(mergedLogs); - } - if (result.code !== 0) { - throw new Error( - `terraform ${args[0]} exited with code ${result.code}\n${mergedLogs}`, - ); - } - return result; - } -} - -async function runCommand( - command: string, - args: string[], - cwd: string, -): Promise { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd, - env: { - ...process.env, - TF_IN_AUTOMATION: "1", - }, - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (data) => { - stdout += data.toString(); - }); - child.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - child.on("error", (error) => { - reject(error); - }); - - child.on("close", (code) => { - resolve({ code: code ?? 0, stdout, stderr }); - }); - }); -} - -function summarizePlan(planJSON: string) { - try { - const parsed = JSON.parse(planJSON); - const changes = parsed?.resource_changes ?? []; - let additions = 0; - let updates = 0; - let destroys = 0; - - for (const change of changes) { - const actions: string[] = change?.change?.actions ?? []; - if (actions.includes("create")) additions += 1; - if (actions.includes("update")) updates += 1; - if (actions.includes("delete") || actions.includes("destroy")) - destroys += 1; - if (actions.includes("replace")) { - additions += 1; - destroys += 1; - } - } - - return { - hasChanges: additions + updates + destroys > 0, - additions, - changes: updates, - destroys, - }; - } catch (error) { - logger.warn({ error }, "failed to parse terraform plan JSON"); - return { - hasChanges: false, - additions: 0, - changes: 0, - destroys: 0, - }; - } -} - -async function pathExists(target: string) { - try { - await fs.access(target); - return true; - } catch { - return false; - } -} - From 1efb143c855e3f5bee517f0bd4ef06fc88d1e0c7 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 17:56:15 -0800 Subject: [PATCH 07/13] error handling, tfe engine, some cleanup --- sandbox-sidecar/src/index.ts | 18 +++ sandbox-sidecar/src/templateRegistry.ts | 43 +++++++ sandbox-sidecar/templates/Dockerfile | 31 ----- sandbox-sidecar/templates/debug-template.ts | 81 ------------- taco/internal/api/internal.go | 6 +- taco/internal/domain/interfaces.go | 26 +++++ .../remote_run_activity_repository.go | 110 ++++++++++++++++++ taco/internal/sandbox/e2b.go | 101 +++++++++++----- taco/internal/tfe/apply_executor.go | 6 +- taco/internal/tfe/plan_executor.go | 4 +- ui/src/api/statesman_serverFunctions.ts | 28 +++++ .../_dashboard/dashboard/units.$unitId.tsx | 55 ++++----- 12 files changed, 334 insertions(+), 175 deletions(-) delete mode 100644 sandbox-sidecar/templates/Dockerfile delete mode 100644 sandbox-sidecar/templates/debug-template.ts diff --git a/sandbox-sidecar/src/index.ts b/sandbox-sidecar/src/index.ts index ee7a9ccf9..b02f27b84 100644 --- a/sandbox-sidecar/src/index.ts +++ b/sandbox-sidecar/src/index.ts @@ -6,6 +6,7 @@ import { JobStore } from "./jobs/jobStore.js"; import { createRunner } from "./runners/index.js"; import { JobRunner } from "./jobs/jobRunner.js"; import { createRunRouter } from "./routes/runRoutes.js"; +import { validateTemplates } from "./templateRegistry.js"; const config = loadConfig(); const app = express(); @@ -35,6 +36,23 @@ app.use( }, ); +// Validate templates at startup (async) +validateTemplates(config.e2b.apiKey) + .then((results) => { + const failed = results.filter((r) => !r.valid); + if (failed.length > 0) { + logger.warn( + { failedTemplates: failed.map((f) => f.templateId) }, + "Some E2B templates failed validation - they may not exist or be inaccessible", + ); + } else { + logger.info("All E2B templates validated successfully"); + } + }) + .catch((err) => { + logger.error({ err }, "Failed to validate E2B templates"); + }); + app.listen(config.port, () => { logger.info( { port: config.port, runner: runner.name }, diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts index 24beab6df..d21bbe708 100644 --- a/sandbox-sidecar/src/templateRegistry.ts +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -46,3 +46,46 @@ export function getFallbackTemplateId(fallbackId?: string): string { return fallbackId || "rki5dems9wqfm4r03t7g"; // Default E2B base template } +/** + * Validate that E2B templates exist and are accessible + * Returns an array of validation results + */ +export async function validateTemplates(apiKey?: string): Promise> { + if (!apiKey) { + return [{ templateId: "N/A", valid: false, error: "No E2B API key provided" }]; + } + + const results: Array<{ templateId: string; valid: boolean; error?: string }> = []; + + // Validate each template alias + for (const template of TEMPLATE_REGISTRY) { + try { + // Try to resolve the template alias via E2B API + const response = await fetch(`https://api.e2b.dev/templates/${template.alias}`, { + method: "GET", + headers: { + "X-API-Key": apiKey, + }, + }); + + if (response.ok) { + results.push({ templateId: template.alias, valid: true }); + } else { + results.push({ + templateId: template.alias, + valid: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }); + } + } catch (err) { + results.push({ + templateId: template.alias, + valid: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return results; +} + diff --git a/sandbox-sidecar/templates/Dockerfile b/sandbox-sidecar/templates/Dockerfile deleted file mode 100644 index d2e51f2fe..000000000 --- a/sandbox-sidecar/templates/Dockerfile +++ /dev/null @@ -1,31 +0,0 @@ -FROM ubuntu:22.04 - -# Force rebuild - change this number to invalidate cache -ENV CACHE_BUST=20251118v3 - -# Install dependencies -RUN apt-get update && \ - apt-get install -y curl unzip wget ca-certificates && \ - rm -rf /var/lib/apt/lists/* - -# Install Terraform 1.5.5 -RUN cd /tmp && \ - curl -fsSL https://releases.hashicorp.com/terraform/1.5.5/terraform_1.5.5_linux_amd64.zip -o terraform.zip && \ - unzip terraform.zip && \ - mv terraform /usr/local/bin/terraform && \ - chmod +x /usr/local/bin/terraform && \ - rm terraform.zip && \ - echo "Terraform binary installed" - -# Verify installation - this will fail the build if terraform is not installed -RUN terraform version && \ - ls -la /usr/local/bin/terraform && \ - which terraform - -# Create user directory -RUN mkdir -p /home/user && chown -R 1000:1000 /home/user - -# Set default user (E2B requirement) -USER 1000 -WORKDIR /home/user - diff --git a/sandbox-sidecar/templates/debug-template.ts b/sandbox-sidecar/templates/debug-template.ts deleted file mode 100644 index dee397d8c..000000000 --- a/sandbox-sidecar/templates/debug-template.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Debug script to understand what's in the template -import { Sandbox } from "@e2b/code-interpreter"; - -async function debugTemplate() { - console.log("=== E2B Template Debug ===\n"); - // Use the template ID directly to bypass alias caching - const templateId = "vnjk0omiwu39qpbcsyf5"; // Template ID - console.log(`Creating sandbox from template ID ${templateId}...`); - - const sandbox = await Sandbox.create({ - apiKey: process.env.E2B_API_KEY!, - template: templateId, - }); - - console.log("āœ… Sandbox created:", sandbox.sandboxId); - console.log("\n--- Checking filesystem ---"); - - // Check if /usr/local/bin exists - const binCheck = await sandbox.commands.run("ls -la /usr/local/bin/ 2>&1 || echo 'Directory does not exist'"); - console.log("/usr/local/bin/ contents:"); - console.log(binCheck.stdout || binCheck.stderr); - - // Check PATH - const pathCheck = await sandbox.commands.run("echo $PATH"); - console.log("\nPATH:"); - console.log(pathCheck.stdout); - - // Check if /usr/local/bin is in PATH - const pathHasLocal = pathCheck.stdout.includes("/usr/local/bin"); - console.log("/usr/local/bin in PATH:", pathHasLocal); - - // Try to find terraform anywhere in the filesystem - console.log("\n--- Searching entire filesystem for terraform ---"); - const findTf = await sandbox.commands.run("find / -name terraform -type f 2>/dev/null | head -20"); - console.log("Found terraform at:"); - console.log(findTf.stdout || "(none)"); - - // Also search for any files in /usr/local/bin - const localBinFiles = await sandbox.commands.run("find /usr/local/bin -type f 2>/dev/null"); - console.log("\nAll files in /usr/local/bin:"); - console.log(localBinFiles.stdout || "(none)"); - - // Check which user we are - const whoami = await sandbox.commands.run("whoami"); - console.log("\nCurrent user:"); - console.log(whoami.stdout); - - // Test if we can install something else (jq) to see if it's terraform-specific - console.log("\n--- Testing installation of another package (jq) ---"); - const installJq = await sandbox.commands.run("sudo apt-get update -qq && sudo apt-get install -y jq 2>&1 | tail -5"); - console.log("jq installation output:"); - console.log(installJq.stdout); - - const jqCheck = await sandbox.commands.run("which jq && jq --version"); - console.log("\njq check:"); - console.log("Exit code:", jqCheck.exitCode); - console.log("Output:", jqCheck.stdout || jqCheck.stderr); - - // Try running terraform - console.log("\n--- Attempting to run terraform ---"); - const tfResult = await sandbox.commands.run("terraform version 2>&1 || echo 'Command failed'"); - console.log("Exit code:", tfResult.exitCode); - console.log("Output:", tfResult.stdout || tfResult.stderr); - - // Check if curl/unzip are available (should be from our build) - console.log("\n--- Checking installed packages ---"); - const curlCheck = await sandbox.commands.run("which curl"); - console.log("curl:", curlCheck.stdout.trim() || "NOT FOUND"); - - const unzipCheck = await sandbox.commands.run("which unzip"); - console.log("unzip:", unzipCheck.stdout.trim() || "NOT FOUND"); - - const wgetCheck = await sandbox.commands.run("which wget"); - console.log("wget:", wgetCheck.stdout.trim() || "NOT FOUND"); - - await sandbox.close(); - console.log("\nāœ… Debug complete"); -} - -debugTemplate().catch(console.error); - diff --git a/taco/internal/api/internal.go b/taco/internal/api/internal.go index c61330179..24beccb60 100644 --- a/taco/internal/api/internal.go +++ b/taco/internal/api/internal.go @@ -29,10 +29,14 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { // Create repositories first (needed for webhook middleware) var orgRepo domain.OrganizationRepository var userRepo domain.UserRepository + var remoteRunActivityRepo domain.RemoteRunActivityRepository if deps.QueryStore != nil { orgRepo = repositories.NewOrgRepositoryFromQueryStore(deps.QueryStore) userRepo = repositories.NewUserRepositoryFromQueryStore(deps.QueryStore) + if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { + remoteRunActivityRepo = repositories.NewRemoteRunActivityRepository(db) + } } // Create internal group with webhook auth @@ -145,7 +149,6 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { var runRepo domain.TFERunRepository var planRepo domain.TFEPlanRepository var configVerRepo domain.TFEConfigurationVersionRepository - var remoteRunActivityRepo domain.RemoteRunActivityRepository if deps.QueryStore != nil { if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { @@ -154,7 +157,6 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { runRepo = repositories.NewTFERunRepository(db) planRepo = repositories.NewTFEPlanRepository(db) configVerRepo = repositories.NewTFEConfigurationVersionRepository(db) - remoteRunActivityRepo = repositories.NewRemoteRunActivityRepository(db) log.Println("TFE repositories initialized successfully (internal routes)") } } diff --git a/taco/internal/domain/interfaces.go b/taco/internal/domain/interfaces.go index ec5e56eeb..e887592b5 100644 --- a/taco/internal/domain/interfaces.go +++ b/taco/internal/domain/interfaces.go @@ -120,6 +120,32 @@ type RemoteRunActivityRepository interface { CreateActivity(ctx context.Context, activity *RemoteRunActivity) (string, error) MarkRunning(ctx context.Context, activityID string, startedAt time.Time, sandboxProvider string) error MarkCompleted(ctx context.Context, activityID string, status string, completedAt time.Time, duration time.Duration, sandboxJobID *string, errorMessage *string) error + + // Query operations + ListActivities(ctx context.Context, filters ActivityFilters) ([]*RemoteRunActivity, error) + GetUsageSummary(ctx context.Context, orgID string, startDate, endDate *time.Time) (*UsageSummary, error) +} + +// ActivityFilters for querying remote run activities +type ActivityFilters struct { + OrgID string + UnitID *string + Status *string + StartDate *time.Time + EndDate *time.Time + Limit int + Offset int +} + +// UsageSummary aggregates remote run usage for billing +type UsageSummary struct { + TotalRuns int + TotalMinutes float64 + SuccessfulRuns int + FailedRuns int + ByOperation map[string]int // "plan" -> count, "apply" -> count + ByUnit map[string]float64 // unit_id -> minutes + EstimatedCostUSD float64 // Based on minutes * rate } // ============================================ diff --git a/taco/internal/repositories/remote_run_activity_repository.go b/taco/internal/repositories/remote_run_activity_repository.go index a5f29b656..fc6608d09 100644 --- a/taco/internal/repositories/remote_run_activity_repository.go +++ b/taco/internal/repositories/remote_run_activity_repository.go @@ -104,3 +104,113 @@ func (r *RemoteRunActivityRepository) MarkCompleted( } return nil } + +func (r *RemoteRunActivityRepository) ListActivities(ctx context.Context, filters domain.ActivityFilters) ([]*domain.RemoteRunActivity, error) { + query := r.db.WithContext(ctx).Model(&types.RemoteRunActivity{}) + + // Apply filters + query = query.Where("org_id = ?", filters.OrgID) + + if filters.UnitID != nil { + query = query.Where("unit_id = ?", *filters.UnitID) + } + if filters.Status != nil { + query = query.Where("status = ?", *filters.Status) + } + if filters.StartDate != nil { + query = query.Where("created_at >= ?", *filters.StartDate) + } + if filters.EndDate != nil { + query = query.Where("created_at <= ?", *filters.EndDate) + } + + // Pagination + if filters.Limit > 0 { + query = query.Limit(filters.Limit) + } else { + query = query.Limit(100) // Default limit + } + if filters.Offset > 0 { + query = query.Offset(filters.Offset) + } + + // Order by most recent first + query = query.Order("created_at DESC") + + var records []types.RemoteRunActivity + if err := query.Find(&records).Error; err != nil { + return nil, fmt.Errorf("failed to list remote run activities: %w", err) + } + + // Convert to domain models + activities := make([]*domain.RemoteRunActivity, len(records)) + for i, record := range records { + activities[i] = &domain.RemoteRunActivity{ + ID: record.ID, + RunID: record.RunID, + OrgID: record.OrgID, + UnitID: record.UnitID, + Operation: record.Operation, + Status: record.Status, + TriggeredBy: record.TriggeredBy, + TriggeredSource: record.TriggeredSource, + SandboxProvider: record.SandboxProvider, + SandboxJobID: record.SandboxJobID, + StartedAt: record.StartedAt, + CompletedAt: record.CompletedAt, + DurationMS: record.DurationMs, + ErrorMessage: record.ErrorMessage, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + } + } + + return activities, nil +} + +func (r *RemoteRunActivityRepository) GetUsageSummary(ctx context.Context, orgID string, startDate, endDate *time.Time) (*domain.UsageSummary, error) { + query := r.db.WithContext(ctx).Model(&types.RemoteRunActivity{}). + Where("org_id = ?", orgID) + + if startDate != nil { + query = query.Where("created_at >= ?", *startDate) + } + if endDate != nil { + query = query.Where("created_at <= ?", *endDate) + } + + var records []types.RemoteRunActivity + if err := query.Find(&records).Error; err != nil { + return nil, fmt.Errorf("failed to get usage summary: %w", err) + } + + summary := &domain.UsageSummary{ + ByOperation: make(map[string]int), + ByUnit: make(map[string]float64), + } + + for _, record := range records { + summary.TotalRuns++ + + if record.Status == "succeeded" { + summary.SuccessfulRuns++ + } else if record.Status == "failed" { + summary.FailedRuns++ + } + + // Count by operation + summary.ByOperation[record.Operation]++ + + // Sum minutes by unit + if record.DurationMs != nil && *record.DurationMs > 0 { + minutes := float64(*record.DurationMs) / 60000.0 + summary.TotalMinutes += minutes + summary.ByUnit[record.UnitID] += minutes + } + } + + // Estimate cost at $0.10 per minute (adjust as needed) + summary.EstimatedCostUSD = summary.TotalMinutes * 0.10 + + return summary, nil +} diff --git a/taco/internal/sandbox/e2b.go b/taco/internal/sandbox/e2b.go index 4ca13efe4..ea2d2f34b 100644 --- a/taco/internal/sandbox/e2b.go +++ b/taco/internal/sandbox/e2b.go @@ -30,15 +30,10 @@ func NewE2BSandbox(cfg E2BConfig) (Sandbox, error) { if cfg.APIKey == "" { return nil, fmt.Errorf("E2B sandbox requires an API key") } - if cfg.PollInterval <= 0 { - cfg.PollInterval = 5 * time.Second - } - if cfg.PollTimeout <= 0 { - cfg.PollTimeout = 30 * time.Minute - } - if cfg.HTTPTimeout <= 0 { - cfg.HTTPTimeout = 60 * time.Second - } + // Timeouts are configured via env vars in config.go with sensible defaults: + // - PollInterval: 5s (OPENTACO_E2B_POLL_INTERVAL) + // - PollTimeout: 30m (OPENTACO_E2B_POLL_TIMEOUT) + // - HTTPTimeout: 60s (OPENTACO_E2B_HTTP_TIMEOUT) return &e2bSandbox{ cfg: cfg, @@ -56,6 +51,14 @@ func (s *e2bSandbox) ExecutePlan(ctx context.Context, req *PlanRequest) (*PlanRe if req == nil { return nil, fmt.Errorf("plan request cannot be nil") } + + // Validate engine field is set + if req.Engine == "" { + return nil, fmt.Errorf("engine field is required but was empty") + } + if req.Engine != "terraform" && req.Engine != "tofu" { + return nil, fmt.Errorf("invalid engine %q, must be 'terraform' or 'tofu'", req.Engine) + } jobID, err := s.startRun(ctx, e2bRunRequest{ Operation: "plan", @@ -66,6 +69,7 @@ func (s *e2bSandbox) ExecutePlan(ctx context.Context, req *PlanRequest) (*PlanRe ConfigurationVersionID: req.ConfigurationVersionID, IsDestroy: req.IsDestroy, TerraformVersion: req.TerraformVersion, + Engine: req.Engine, WorkingDirectory: req.WorkingDirectory, ConfigArchive: base64.StdEncoding.EncodeToString(req.ConfigArchive), State: encodeOptional(req.State), @@ -108,6 +112,14 @@ func (s *e2bSandbox) ExecuteApply(ctx context.Context, req *ApplyRequest) (*Appl if req == nil { return nil, fmt.Errorf("apply request cannot be nil") } + + // Validate engine field is set + if req.Engine == "" { + return nil, fmt.Errorf("engine field is required but was empty") + } + if req.Engine != "terraform" && req.Engine != "tofu" { + return nil, fmt.Errorf("invalid engine %q, must be 'terraform' or 'tofu'", req.Engine) + } jobID, err := s.startRun(ctx, e2bRunRequest{ Operation: "apply", @@ -118,6 +130,7 @@ func (s *e2bSandbox) ExecuteApply(ctx context.Context, req *ApplyRequest) (*Appl ConfigurationVersionID: req.ConfigurationVersionID, IsDestroy: req.IsDestroy, TerraformVersion: req.TerraformVersion, + Engine: req.Engine, WorkingDirectory: req.WorkingDirectory, ConfigArchive: base64.StdEncoding.EncodeToString(req.ConfigArchive), State: encodeOptional(req.State), @@ -154,31 +167,58 @@ func (s *e2bSandbox) startRun(ctx context.Context, payload e2bRunRequest) (strin return "", fmt.Errorf("failed to marshal sandbox payload: %w", err) } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.endpoint(e2bRunsPath), bytes.NewReader(body)) - if err != nil { - return "", fmt.Errorf("failed to build sandbox request: %w", err) - } - s.decorateHeaders(req) + // Retry logic for transient failures (network issues, sidecar temporarily unavailable) + maxRetries := 3 + var lastErr error + + for attempt := 1; attempt <= maxRetries; attempt++ { + if attempt > 1 { + // Exponential backoff: 1s, 2s, 4s + backoff := time.Duration(1<= 300 { - msg, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("sandbox returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) - } + resp, err := s.httpClient.Do(req) + if err != nil { + lastErr = fmt.Errorf("failed to start sandbox run (attempt %d/%d): %w", attempt, maxRetries, err) + continue // Retry on network errors + } + defer resp.Body.Close() - var startResp e2bRunStartResponse - if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { - return "", fmt.Errorf("failed to decode sandbox start response: %w", err) - } - if startResp.ID == "" { - return "", fmt.Errorf("sandbox did not return a run identifier") + if resp.StatusCode >= 500 && resp.StatusCode < 600 { + // Server error - retry + msg, _ := io.ReadAll(resp.Body) + lastErr = fmt.Errorf("sandbox returned %d (attempt %d/%d): %s", resp.StatusCode, attempt, maxRetries, strings.TrimSpace(string(msg))) + continue + } + + if resp.StatusCode >= 300 { + // Client error - don't retry + msg, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("sandbox returned %d: %s", resp.StatusCode, strings.TrimSpace(string(msg))) + } + + var startResp e2bRunStartResponse + if err := json.NewDecoder(resp.Body).Decode(&startResp); err != nil { + return "", fmt.Errorf("failed to decode sandbox start response: %w", err) + } + if startResp.ID == "" { + return "", fmt.Errorf("sandbox did not return a run identifier") + } + return startResp.ID, nil } - return startResp.ID, nil + + return "", fmt.Errorf("failed to start sandbox run after %d attempts: %w", maxRetries, lastErr) } func (s *e2bSandbox) waitForCompletion(ctx context.Context, runID string) (*e2bRunStatusResponse, error) { @@ -286,6 +326,7 @@ type e2bRunRequest struct { ConfigurationVersionID string `json:"configuration_version_id"` IsDestroy bool `json:"is_destroy"` TerraformVersion string `json:"terraform_version,omitempty"` + Engine string `json:"engine,omitempty"` WorkingDirectory string `json:"working_directory,omitempty"` ConfigArchive string `json:"config_archive"` State string `json:"state,omitempty"` diff --git a/taco/internal/tfe/apply_executor.go b/taco/internal/tfe/apply_executor.go index 0785ff05e..222b24d44 100644 --- a/taco/internal/tfe/apply_executor.go +++ b/taco/internal/tfe/apply_executor.go @@ -99,7 +99,7 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { // Allow apply from "planned" (waiting for confirmation) or "apply_queued" status if run.Status != "planned" && run.Status != "apply_queued" { logger.Error("run cannot be applied", slog.String("status", run.Status)) - return fmt.Errorf("run cannot be applied in status: %s", run.Status) + return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Run cannot be applied in status: %s", run.Status)) } // Acquire lock before starting terraform apply @@ -149,7 +149,7 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { // Update run status to "applying" if err := e.runRepo.UpdateRunStatus(ctx, runID, "applying"); err != nil { logger.Error("failed to update run status", slog.String("error", err.Error())) - return fmt.Errorf("failed to update run status: %w", err) + return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to update run status: %v", err)) } logger.Info("updated run status to applying") @@ -157,7 +157,7 @@ func (e *ApplyExecutor) ExecuteApply(ctx context.Context, runID string) error { // Get configuration version configVer, err := e.configVerRepo.GetConfigurationVersion(ctx, run.ConfigurationVersionID) if err != nil { - return fmt.Errorf("failed to get configuration version: %w", err) + return e.handleApplyError(ctx, run.ID, logger, fmt.Sprintf("Failed to get configuration version: %v", err)) } // Download configuration archive diff --git a/taco/internal/tfe/plan_executor.go b/taco/internal/tfe/plan_executor.go index f00cfaefd..310151cec 100644 --- a/taco/internal/tfe/plan_executor.go +++ b/taco/internal/tfe/plan_executor.go @@ -187,14 +187,14 @@ func (e *PlanExecutor) ExecutePlan(ctx context.Context, runID string) error { // Update run status to "planning" if err := e.runRepo.UpdateRunStatus(ctx, runID, "planning"); err != nil { logger.Error("failed to update status to planning", slog.String("error", err.Error())) - return fmt.Errorf("failed to update run status: %w", err) + return e.handlePlanError(ctx, run.ID, run.PlanID, logger, fmt.Sprintf("Failed to update run status: %v", err)) } logger.Info("updated run status to planning") // Get configuration version configVer, err := e.configVerRepo.GetConfigurationVersion(ctx, run.ConfigurationVersionID) if err != nil { - return fmt.Errorf("failed to get configuration version: %w", err) + return e.handlePlanError(ctx, run.ID, run.PlanID, logger, fmt.Sprintf("Failed to get configuration version: %v", err)) } // Check if configuration was uploaded diff --git a/ui/src/api/statesman_serverFunctions.ts b/ui/src/api/statesman_serverFunctions.ts index 64b5778dc..49642c339 100644 --- a/ui/src/api/statesman_serverFunctions.ts +++ b/ui/src/api/statesman_serverFunctions.ts @@ -92,6 +92,34 @@ export const createUnitFn = createServerFn({method: 'POST'}) return unit; }) +export const updateUnitFn = createServerFn({method: 'POST'}) + .inputValidator((data : { + userId: string, + organisationId: string, + email: string, + unitId: string, + tfeAutoApply?: boolean, + tfeExecutionMode?: string, + tfeTerraformVersion?: string, + tfeEngine?: string, + tfeWorkingDirectory?: string + }) => data) + .handler(async ({ data }) => { + const { updateUnit } = await import("./statesman_units") + const unit : any = await updateUnit( + data.organisationId, + data.userId, + data.email, + data.unitId, + data.tfeAutoApply, + data.tfeExecutionMode, + data.tfeTerraformVersion, + data.tfeEngine, + data.tfeWorkingDirectory + ); + return unit; +}) + export const deleteUnitFn = createServerFn({method: 'POST'}) .inputValidator((data : {userId: string, organisationId: string, email: string, unitId: string}) => data) .handler(async ({ data }) => { diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx index 3c5f9b776..2f14cacfa 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx @@ -25,7 +25,7 @@ import { Badge } from "@/components/ui/badge" import { ArrowLeft, Lock, Unlock, MoreVertical, History, Trash2, Download, Upload, RefreshCcw, Copy, Check, ArrowUpRight, Save } from 'lucide-react' import { useState } from 'react' import { toast } from '@/hooks/use-toast' -import { getUnitFn, getUnitVersionsFn, lockUnitFn, unlockUnitFn, getUnitStatusFn, deleteUnitFn, downloadLatestStateFn, restoreUnitStateVersionFn } from '@/api/statesman_serverFunctions' +import { getUnitFn, getUnitVersionsFn, lockUnitFn, unlockUnitFn, getUnitStatusFn, deleteUnitFn, downloadLatestStateFn, restoreUnitStateVersionFn, updateUnitFn } from '@/api/statesman_serverFunctions' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' @@ -150,15 +150,25 @@ function RouteComponent() { }) // Settings state - handle null/undefined with sensible defaults + // Track versions separately for each engine to avoid confusion when switching const [engine, setEngine] = useState<'terraform' | 'tofu'>( (unit.tfe_engine as 'terraform' | 'tofu') || 'terraform' ) - const [version, setVersion] = useState( - unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' + const [terraformVersion, setTerraformVersion] = useState( + unit.tfe_engine === 'terraform' && unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' ? unit.tfe_terraform_version : '1.5.5' ) + const [tofuVersion, setTofuVersion] = useState( + unit.tfe_engine === 'tofu' && unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' + ? unit.tfe_terraform_version + : '1.6.0' + ) const [isSavingSettings, setIsSavingSettings] = useState(false) + + // Get the current version based on selected engine + const currentVersion = engine === 'terraform' ? terraformVersion : tofuVersion + const setCurrentVersion = engine === 'terraform' ? setTerraformVersion : setTofuVersion const handleUnlock = async () => { try { @@ -305,18 +315,19 @@ function RouteComponent() { const handleUpdateSettings = async () => { setIsSavingSettings(true) try { - const { updateUnit } = await import('@/api/statesman_units') - await updateUnit( - organisationId || '', - user?.id || '', - user?.email || '', - unit.id, - undefined, // tfeAutoApply - undefined, // tfeExecutionMode - version, - engine, - undefined // tfeWorkingDirectory - ) + await updateUnitFn({ + data: { + userId: user?.id || '', + organisationId: organisationId || '', + email: user?.email || '', + unitId: unit.id, + tfeAutoApply: undefined, + tfeExecutionMode: undefined, + tfeTerraformVersion: currentVersion, + tfeEngine: engine, + tfeWorkingDirectory: undefined + } + }) toast({ title: 'Settings updated', description: 'Unit settings were updated successfully.', @@ -487,12 +498,6 @@ function RouteComponent() { value={engine} onValueChange={(v) => { setEngine(v as 'terraform' | 'tofu') - // Set default version based on engine - if (v === 'tofu') { - setVersion('1.10.0') - } else { - setVersion('1.5.5') - } }} className="flex gap-4" > @@ -501,7 +506,6 @@ function RouteComponent() { className={`flex-1 cursor-pointer rounded-lg border p-4 transition-colors hover:bg-muted/50 ${engine === 'terraform' ? 'ring-2 ring-primary border-primary' : 'border-muted'}`} onClick={() => { setEngine('terraform') - setVersion('1.5.5') }} > @@ -513,7 +517,6 @@ function RouteComponent() { className={`flex-1 cursor-pointer rounded-lg border p-4 transition-colors hover:bg-muted/50 ${engine === 'tofu' ? 'ring-2 ring-primary border-primary' : 'border-muted'}`} onClick={() => { setEngine('tofu') - setVersion('1.10.0') }} > @@ -529,9 +532,9 @@ function RouteComponent() {
setVersion(e.target.value)} - placeholder={engine === 'tofu' ? '1.10.0' : '1.5.5'} + value={currentVersion} + onChange={(e) => setCurrentVersion(e.target.value)} + placeholder={engine === 'tofu' ? '1.6.0' : '1.5.5'} className="font-mono" />

From 379dad02034013083545e7eabfafb35d87a66b99 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 18:52:35 -0800 Subject: [PATCH 08/13] fix some issues with opentofu spawning, remake templates --- sandbox-sidecar/src/index.ts | 18 ------- sandbox-sidecar/src/routes/runRoutes.ts | 1 + sandbox-sidecar/src/runners/e2bRunner.ts | 57 ++++++++-------------- sandbox-sidecar/src/templateRegistry.ts | 3 +- sandbox-sidecar/templates/manifest.ts | 2 +- sandbox-sidecar/templates/tofu-template.ts | 13 +++-- taco/internal/tfe/sandbox_helpers.go | 14 +++++- 7 files changed, 44 insertions(+), 64 deletions(-) diff --git a/sandbox-sidecar/src/index.ts b/sandbox-sidecar/src/index.ts index b02f27b84..ee7a9ccf9 100644 --- a/sandbox-sidecar/src/index.ts +++ b/sandbox-sidecar/src/index.ts @@ -6,7 +6,6 @@ import { JobStore } from "./jobs/jobStore.js"; import { createRunner } from "./runners/index.js"; import { JobRunner } from "./jobs/jobRunner.js"; import { createRunRouter } from "./routes/runRoutes.js"; -import { validateTemplates } from "./templateRegistry.js"; const config = loadConfig(); const app = express(); @@ -36,23 +35,6 @@ app.use( }, ); -// Validate templates at startup (async) -validateTemplates(config.e2b.apiKey) - .then((results) => { - const failed = results.filter((r) => !r.valid); - if (failed.length > 0) { - logger.warn( - { failedTemplates: failed.map((f) => f.templateId) }, - "Some E2B templates failed validation - they may not exist or be inaccessible", - ); - } else { - logger.info("All E2B templates validated successfully"); - } - }) - .catch((err) => { - logger.error({ err }, "Failed to validate E2B templates"); - }); - app.listen(config.port, () => { logger.info( { port: config.port, runner: runner.name }, diff --git a/sandbox-sidecar/src/routes/runRoutes.ts b/sandbox-sidecar/src/routes/runRoutes.ts index 4d9d2876b..25988baea 100644 --- a/sandbox-sidecar/src/routes/runRoutes.ts +++ b/sandbox-sidecar/src/routes/runRoutes.ts @@ -26,6 +26,7 @@ export function createRunRouter( configurationVersionId: parsed.configuration_version_id, isDestroy: parsed.is_destroy, terraformVersion: parsed.terraform_version, + engine: parsed.engine, workingDirectory: parsed.working_directory, configArchive: parsed.config_archive, state: parsed.state, diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index b421cf3dd..9b89eaebe 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -33,10 +33,12 @@ export class E2BSandboxRunner implements SandboxRunner { private async runPlan(job: SandboxRunRecord): Promise { const requestedVersion = job.payload.terraformVersion || "1.5.5"; const requestedEngine = job.payload.engine || "terraform"; - const sandbox = await this.createSandbox(requestedVersion, requestedEngine); + const { sandbox, needsInstall } = await this.createSandbox(requestedVersion, requestedEngine); try { - // Install Terraform if not already present - await this.ensureTerraform(sandbox); + // Install IaC tool if using fallback template + if (needsInstall) { + await this.installIacTool(sandbox, requestedEngine, requestedVersion); + } const workDir = await this.setupWorkspace(sandbox, job); const logs: string[] = []; @@ -83,10 +85,12 @@ export class E2BSandboxRunner implements SandboxRunner { private async runApply(job: SandboxRunRecord): Promise { const requestedVersion = job.payload.terraformVersion || "1.5.5"; const requestedEngine = job.payload.engine || "terraform"; - const sandbox = await this.createSandbox(requestedVersion, requestedEngine); + const { sandbox, needsInstall } = await this.createSandbox(requestedVersion, requestedEngine); try { - // Install Terraform if not already present - await this.ensureTerraform(sandbox); + // Install IaC tool if using fallback template + if (needsInstall) { + await this.installIacTool(sandbox, requestedEngine, requestedVersion); + } const workDir = await this.setupWorkspace(sandbox, job); const logs: string[] = []; @@ -121,7 +125,7 @@ export class E2BSandboxRunner implements SandboxRunner { } } - private async createSandbox(requestedVersion?: string, requestedEngine?: string): Promise { + private async createSandbox(requestedVersion?: string, requestedEngine?: string): Promise<{ sandbox: Sandbox; needsInstall: boolean }> { const version = requestedVersion || "1.5.5"; const engine = requestedEngine === "tofu" ? "tofu" : "terraform"; @@ -140,7 +144,7 @@ export class E2BSandboxRunner implements SandboxRunner { // Fall back to bare-bones template and install at runtime templateId = getFallbackTemplateId(this.options.bareBonesTemplateId); needsInstall = true; - logger.info({ templateId, engine, version }, "no pre-built template found, will install at runtime"); + logger.warn({ templateId, engine, version }, "no pre-built template found, will install at runtime"); } logger.info({ templateId }, "creating E2B sandbox"); @@ -149,39 +153,16 @@ export class E2BSandboxRunner implements SandboxRunner { }); logger.info({ sandboxId: sandbox.sandboxId }, "E2B sandbox created"); - // Store metadata for installation - (sandbox as any)._needsTerraformInstall = needsInstall; - (sandbox as any)._requestedTerraformVersion = version; + // Store engine metadata for command execution (sandbox as any)._requestedEngine = engine; - return sandbox; + return { sandbox, needsInstall }; } - private async ensureTerraform(sandbox: Sandbox): Promise { - const engine = (sandbox as any)._requestedEngine || "terraform"; - const binaryName = engine === "tofu" ? "tofu" : "terraform"; - - // Always check if the binary is actually installed, even in pre-built templates - logger.info({ engine, binaryName }, "checking for IaC tool installation"); - const checkResult = await sandbox.commands.run(`which ${binaryName} 2>/dev/null || echo 'not-found'`); - if (!checkResult.stdout.includes("not-found")) { - const versionCheck = await sandbox.commands.run(`${binaryName} version`); - logger.info({ - engine, - path: checkResult.stdout.trim(), - version: versionCheck.stdout.split('\n')[0] - }, "IaC tool already installed"); - return; - } - - // If we expected it to be pre-installed but it's not, log a warning - if (!(sandbox as any)._needsTerraformInstall) { - logger.warn({ engine }, "IaC tool not found in pre-built template, installing at runtime"); - } - // Use requested version or default - const version = (sandbox as any)._requestedTerraformVersion || (engine === "tofu" ? "1.10.0" : "1.9.8"); - logger.info({ engine, version }, "installing IaC tool in sandbox"); + private async installIacTool(sandbox: Sandbox, engine: string, version: string): Promise { + const binaryName = engine === "tofu" ? "tofu" : "terraform"; + logger.info({ engine, version }, "installing IaC tool at runtime"); let installScript: string; @@ -190,8 +171,8 @@ export class E2BSandboxRunner implements SandboxRunner { installScript = ` set -e cd /tmp - wget -q https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.tar.gz - tar -xzf tofu_${version}_linux_amd64.tar.gz + wget -q -O tofu.zip https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.zip + unzip -q tofu.zip sudo mv tofu /usr/local/bin/ sudo chmod +x /usr/local/bin/tofu tofu version diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts index d21bbe708..3ab355626 100644 --- a/sandbox-sidecar/src/templateRegistry.ts +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -8,7 +8,7 @@ export interface TemplateInfo { } // Template version - bump this when the build recipe changes -const TEMPLATE_VERSION = "0.1.0"; +const TEMPLATE_VERSION = "0.1.1"; // Generate alias matching the build system function aliasFor(engine: string, version: string, tplVersion: string): string { @@ -23,6 +23,7 @@ export const TEMPLATE_REGISTRY: TemplateInfo[] = [ { engine: "terraform", version: "1.0.11", alias: aliasFor("terraform", "1.0.11", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.3.9", alias: aliasFor("terraform", "1.3.9", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.5.5", alias: aliasFor("terraform", "1.5.5", TEMPLATE_VERSION) }, + { engine: "terraform", version: "1.8.5", alias: aliasFor("terraform", "1.8.5", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.6.0", alias: aliasFor("tofu", "1.6.0", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.10.0", alias: aliasFor("tofu", "1.10.0", TEMPLATE_VERSION) }, ]; diff --git a/sandbox-sidecar/templates/manifest.ts b/sandbox-sidecar/templates/manifest.ts index 3b1baa4a2..f3830d14f 100644 --- a/sandbox-sidecar/templates/manifest.ts +++ b/sandbox-sidecar/templates/manifest.ts @@ -7,7 +7,7 @@ export interface TemplateSpec { tplVersion: string; } -export const TEMPLATE_VERSION = "0.1.0"; // bump this when recipe changes +export const TEMPLATE_VERSION = "0.1.1"; // bump this when recipe changes export const TEMPLATES: TemplateSpec[] = [ { engine: "terraform", engineVersion: "1.0.11", tplVersion: TEMPLATE_VERSION }, diff --git a/sandbox-sidecar/templates/tofu-template.ts b/sandbox-sidecar/templates/tofu-template.ts index a2e4175da..bc5749c18 100644 --- a/sandbox-sidecar/templates/tofu-template.ts +++ b/sandbox-sidecar/templates/tofu-template.ts @@ -5,16 +5,19 @@ export function tofuTemplate(version: string) { return Template() .fromUbuntuImage("22.04") .setUser("root") - .runCmd("apt-get update && apt-get install -y wget ca-certificates") + .runCmd("apt-get update && apt-get install -y wget unzip ca-certificates") .runCmd(` set -e cd /tmp echo "Installing OpenTofu ${version}..." - wget -O tofu.tar.gz https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.tar.gz - tar -xzf tofu.tar.gz + # OpenTofu releases use a zip file, not tar.gz + wget -O tofu.zip https://github.com/opentofu/opentofu/releases/download/v${version}/tofu_${version}_linux_amd64.zip + unzip tofu.zip + chmod +x tofu mv tofu /usr/local/bin/tofu - chmod +x /usr/local/bin/tofu - rm tofu.tar.gz + rm tofu.zip + # Verify installation + /usr/local/bin/tofu version `) .setUser("user"); } diff --git a/taco/internal/tfe/sandbox_helpers.go b/taco/internal/tfe/sandbox_helpers.go index 7c56e390a..653ed7da5 100644 --- a/taco/internal/tfe/sandbox_helpers.go +++ b/taco/internal/tfe/sandbox_helpers.go @@ -36,10 +36,22 @@ func terraformVersionForUnit(unit *storage.UnitMetadata) string { } func engineForUnit(unit *storage.UnitMetadata) string { - if unit == nil || unit.TFEEngine == nil { + if unit == nil { + slog.Warn("šŸ” engineForUnit: unit is nil, defaulting to terraform") + return "terraform" + } + if unit.TFEEngine == nil { + slog.Warn("šŸ” engineForUnit: TFEEngine is nil, defaulting to terraform", + slog.String("unit_id", unit.ID), + slog.String("unit_name", unit.Name)) return "terraform" // Default to terraform } engine := strings.TrimSpace(strings.ToLower(*unit.TFEEngine)) + slog.Info("šŸ” engineForUnit: engine determined", + slog.String("unit_id", unit.ID), + slog.String("unit_name", unit.Name), + slog.String("raw_engine", *unit.TFEEngine), + slog.String("normalized_engine", engine)) if engine == "tofu" || engine == "opentofu" { return "tofu" } From 3d315a6dfdebba8babd3202d432acb4c71ea2519 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 18:57:05 -0800 Subject: [PATCH 09/13] fix sidecar release --- .github/workflows/sidecar-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sidecar-release.yml b/.github/workflows/sidecar-release.yml index 9bcef0729..038570a47 100644 --- a/.github/workflows/sidecar-release.yml +++ b/.github/workflows/sidecar-release.yml @@ -51,7 +51,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix={{branch}}- + type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image From 75b8aeaacdf54bb4841c2f240b266eb55bdb4c36 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Tue, 18 Nov 2025 19:05:54 -0800 Subject: [PATCH 10/13] cleanup --- sandbox-sidecar/src/runners/e2bRunner.ts | 1 - sandbox-sidecar/src/templateRegistry.ts | 43 ------------------------ 2 files changed, 44 deletions(-) diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index 9b89eaebe..b5fefbdea 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -161,7 +161,6 @@ export class E2BSandboxRunner implements SandboxRunner { private async installIacTool(sandbox: Sandbox, engine: string, version: string): Promise { - const binaryName = engine === "tofu" ? "tofu" : "terraform"; logger.info({ engine, version }, "installing IaC tool at runtime"); let installScript: string; diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts index 3ab355626..c2877c09c 100644 --- a/sandbox-sidecar/src/templateRegistry.ts +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -47,46 +47,3 @@ export function getFallbackTemplateId(fallbackId?: string): string { return fallbackId || "rki5dems9wqfm4r03t7g"; // Default E2B base template } -/** - * Validate that E2B templates exist and are accessible - * Returns an array of validation results - */ -export async function validateTemplates(apiKey?: string): Promise> { - if (!apiKey) { - return [{ templateId: "N/A", valid: false, error: "No E2B API key provided" }]; - } - - const results: Array<{ templateId: string; valid: boolean; error?: string }> = []; - - // Validate each template alias - for (const template of TEMPLATE_REGISTRY) { - try { - // Try to resolve the template alias via E2B API - const response = await fetch(`https://api.e2b.dev/templates/${template.alias}`, { - method: "GET", - headers: { - "X-API-Key": apiKey, - }, - }); - - if (response.ok) { - results.push({ templateId: template.alias, valid: true }); - } else { - results.push({ - templateId: template.alias, - valid: false, - error: `HTTP ${response.status}: ${response.statusText}`, - }); - } - } catch (err) { - results.push({ - templateId: template.alias, - valid: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - return results; -} - From ca03035121253571c8b7dd633f659117d1c336b4 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Wed, 19 Nov 2025 10:02:51 -0800 Subject: [PATCH 11/13] fix side car timing --- sandbox-sidecar/README.md | 4 ++-- sandbox-sidecar/src/runners/e2bRunner.ts | 27 +++++++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/sandbox-sidecar/README.md b/sandbox-sidecar/README.md index 4a32137b4..ce5909719 100644 --- a/sandbox-sidecar/README.md +++ b/sandbox-sidecar/README.md @@ -69,12 +69,12 @@ The sidecar automatically selects the best execution environment: 2. **Runtime installation** (~1-2 seconds): For versions not in the registry, Terraform/OpenTofu is installed on-demand **Pre-built versions** (see `templates/manifest.ts`): -- Terraform: 1.0.11, 1.3.9, 1.5.5, 1.8.5 +- Terraform: 1.0.11, 1.3.9, 1.5.7, 1.8.5 - OpenTofu: 1.6.0, 1.10.0 **Building templates**: Run `cd templates && npm run build` to build all templates defined in `manifest.ts`. -Users specify the version when creating a unit in the UI (defaults to 1.5.5). +Users specify the version when creating a unit in the UI (defaults to 1.5.7). ### E2B Runner diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index b5fefbdea..3dcf12387 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -209,8 +209,8 @@ export class E2BSandboxRunner implements SandboxRunner { const archiveBuffer = Buffer.from(job.payload.configArchive, "base64"); await sandbox.files.write(archivePath, archiveBuffer.buffer); - // Extract the archive - await sandbox.commands.run(`cd ${workDir} && tar -xzf bundle.tar.gz`); + // Extract the archive (excluding any existing state files to avoid conflicts) + await sandbox.commands.run(`cd ${workDir} && tar -xzf bundle.tar.gz --exclude='terraform.tfstate' --exclude='terraform.tfstate.backup'`); // Determine the execution directory const execDir = job.payload.workingDirectory @@ -221,7 +221,28 @@ export class E2BSandboxRunner implements SandboxRunner { if (job.payload.state) { const statePath = `${execDir}/terraform.tfstate`; const stateBuffer = Buffer.from(job.payload.state, "base64"); - await sandbox.files.write(statePath, stateBuffer.buffer); + const stateText = stateBuffer.toString('utf-8'); + logger.info({ + stateSize: stateBuffer.length, + statePreview: stateText.slice(0, 200), + statePath + }, "writing state file to sandbox"); + + // Write as text string instead of ArrayBuffer to avoid padding issues + await sandbox.files.write(statePath, stateText); + + // Verify the state was written correctly + const verifyResult = await sandbox.commands.run(`wc -c < ${statePath}`); + const writtenSize = parseInt(verifyResult.stdout.trim()); + logger.info({ + expectedSize: stateBuffer.length, + writtenSize, + match: writtenSize === stateBuffer.length + }, "verified state file write"); + + if (writtenSize !== stateBuffer.length) { + throw new Error(`State file size mismatch: expected ${stateBuffer.length}, got ${writtenSize}`); + } } return execDir; From 8d6497199ec59f82bc9745148330db395394833d Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Wed, 19 Nov 2025 10:34:31 -0800 Subject: [PATCH 12/13] edit archiving to be compat across all versions --- sandbox-sidecar/src/runners/e2bRunner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index 3dcf12387..2b6e53000 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -210,7 +210,8 @@ export class E2BSandboxRunner implements SandboxRunner { await sandbox.files.write(archivePath, archiveBuffer.buffer); // Extract the archive (excluding any existing state files to avoid conflicts) - await sandbox.commands.run(`cd ${workDir} && tar -xzf bundle.tar.gz --exclude='terraform.tfstate' --exclude='terraform.tfstate.backup'`); + // Use gunzip + tar separately for better compatibility across tar versions + await sandbox.commands.run(`cd ${workDir} && gunzip -c bundle.tar.gz | tar -x --exclude='terraform.tfstate' --exclude='terraform.tfstate.backup'`); // Determine the execution directory const execDir = job.payload.workingDirectory From 97fee8fc5cd4d5f6e04e17c07de3c5e3fa602dc1 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Wed, 19 Nov 2025 11:58:42 -0800 Subject: [PATCH 13/13] remove unnecessary var, adjust ui --- sandbox-sidecar/src/runners/e2bRunner.ts | 6 +-- sandbox-sidecar/src/templateRegistry.ts | 2 +- taco/internal/sandbox/config.go | 7 ---- taco/internal/sandbox/e2b.go | 4 -- ui/src/components/UnitCreateForm.tsx | 39 +++++++++++++++---- .../_dashboard/dashboard/units.$unitId.tsx | 26 +++++++++++-- 6 files changed, 58 insertions(+), 26 deletions(-) diff --git a/sandbox-sidecar/src/runners/e2bRunner.ts b/sandbox-sidecar/src/runners/e2bRunner.ts index 2b6e53000..cb163b0fd 100644 --- a/sandbox-sidecar/src/runners/e2bRunner.ts +++ b/sandbox-sidecar/src/runners/e2bRunner.ts @@ -31,7 +31,7 @@ export class E2BSandboxRunner implements SandboxRunner { } private async runPlan(job: SandboxRunRecord): Promise { - const requestedVersion = job.payload.terraformVersion || "1.5.5"; + const requestedVersion = job.payload.terraformVersion || "1.5.7"; const requestedEngine = job.payload.engine || "terraform"; const { sandbox, needsInstall } = await this.createSandbox(requestedVersion, requestedEngine); try { @@ -83,7 +83,7 @@ export class E2BSandboxRunner implements SandboxRunner { } private async runApply(job: SandboxRunRecord): Promise { - const requestedVersion = job.payload.terraformVersion || "1.5.5"; + const requestedVersion = job.payload.terraformVersion || "1.5.7"; const requestedEngine = job.payload.engine || "terraform"; const { sandbox, needsInstall } = await this.createSandbox(requestedVersion, requestedEngine); try { @@ -126,7 +126,7 @@ export class E2BSandboxRunner implements SandboxRunner { } private async createSandbox(requestedVersion?: string, requestedEngine?: string): Promise<{ sandbox: Sandbox; needsInstall: boolean }> { - const version = requestedVersion || "1.5.5"; + const version = requestedVersion || "1.5.7"; const engine = requestedEngine === "tofu" ? "tofu" : "terraform"; // Try to find a pre-built template for this version diff --git a/sandbox-sidecar/src/templateRegistry.ts b/sandbox-sidecar/src/templateRegistry.ts index c2877c09c..9c23c63a8 100644 --- a/sandbox-sidecar/src/templateRegistry.ts +++ b/sandbox-sidecar/src/templateRegistry.ts @@ -22,7 +22,7 @@ function aliasFor(engine: string, version: string, tplVersion: string): string { export const TEMPLATE_REGISTRY: TemplateInfo[] = [ { engine: "terraform", version: "1.0.11", alias: aliasFor("terraform", "1.0.11", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.3.9", alias: aliasFor("terraform", "1.3.9", TEMPLATE_VERSION) }, - { engine: "terraform", version: "1.5.5", alias: aliasFor("terraform", "1.5.5", TEMPLATE_VERSION) }, + { engine: "terraform", version: "1.5.7", alias: aliasFor("terraform", "1.5.7", TEMPLATE_VERSION) }, { engine: "terraform", version: "1.8.5", alias: aliasFor("terraform", "1.8.5", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.6.0", alias: aliasFor("tofu", "1.6.0", TEMPLATE_VERSION) }, { engine: "tofu", version: "1.10.0", alias: aliasFor("tofu", "1.10.0", TEMPLATE_VERSION) }, diff --git a/taco/internal/sandbox/config.go b/taco/internal/sandbox/config.go index 34ed9b541..c0c1c6ecf 100644 --- a/taco/internal/sandbox/config.go +++ b/taco/internal/sandbox/config.go @@ -15,7 +15,6 @@ const ( // E2BConfig contains the settings needed to talk to the sidecar service that speaks to E2B. type E2BConfig struct { BaseURL string - APIKey string PollInterval time.Duration PollTimeout time.Duration HTTPTimeout time.Duration @@ -48,11 +47,6 @@ func loadE2BConfigFromEnv() (E2BConfig, error) { } baseURL = strings.TrimRight(baseURL, "/") - apiKey := strings.TrimSpace(os.Getenv("OPENTACO_E2B_API_KEY")) - if apiKey == "" { - return E2BConfig{}, fmt.Errorf("OPENTACO_E2B_API_KEY is required when using the E2B sandbox provider") - } - pollInterval, err := parseDurationWithDefault(os.Getenv("OPENTACO_E2B_POLL_INTERVAL"), 5*time.Second) if err != nil { return E2BConfig{}, fmt.Errorf("invalid OPENTACO_E2B_POLL_INTERVAL: %w", err) @@ -70,7 +64,6 @@ func loadE2BConfigFromEnv() (E2BConfig, error) { return E2BConfig{ BaseURL: baseURL, - APIKey: apiKey, PollInterval: pollInterval, PollTimeout: pollTimeout, HTTPTimeout: httpTimeout, diff --git a/taco/internal/sandbox/e2b.go b/taco/internal/sandbox/e2b.go index ea2d2f34b..032b71c84 100644 --- a/taco/internal/sandbox/e2b.go +++ b/taco/internal/sandbox/e2b.go @@ -27,9 +27,6 @@ func NewE2BSandbox(cfg E2BConfig) (Sandbox, error) { if cfg.BaseURL == "" { return nil, fmt.Errorf("E2B sandbox requires a base URL") } - if cfg.APIKey == "" { - return nil, fmt.Errorf("E2B sandbox requires an API key") - } // Timeouts are configured via env vars in config.go with sensible defaults: // - PollInterval: 5s (OPENTACO_E2B_POLL_INTERVAL) // - PollTimeout: 30m (OPENTACO_E2B_POLL_TIMEOUT) @@ -285,7 +282,6 @@ func (s *e2bSandbox) fetchStatus(ctx context.Context, runID string) (*e2bRunStat func (s *e2bSandbox) decorateHeaders(req *http.Request) { req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+s.cfg.APIKey) req.Header.Set("Accept", "application/json") } diff --git a/ui/src/components/UnitCreateForm.tsx b/ui/src/components/UnitCreateForm.tsx index 8b5fd1150..4d6b9dbc8 100644 --- a/ui/src/components/UnitCreateForm.tsx +++ b/ui/src/components/UnitCreateForm.tsx @@ -31,7 +31,7 @@ export default function UnitCreateForm({ const [unitName, setUnitName] = React.useState('') const [unitType, setUnitType] = React.useState<'local' | 'remote'>('local') const [engine, setEngine] = React.useState<'terraform' | 'tofu'>('terraform') - const [terraformVersion, setTerraformVersion] = React.useState('1.5.5') + const [terraformVersion, setTerraformVersion] = React.useState('1.5.7') const [isCreating, setIsCreating] = React.useState(false) const [error, setError] = React.useState(null) @@ -183,7 +183,7 @@ export default function UnitCreateForm({ if (v === 'tofu') { setTerraformVersion('1.10.0') } else { - setTerraformVersion('1.5.5') + setTerraformVersion('1.5.7') } }} className="flex gap-4" @@ -193,7 +193,7 @@ export default function UnitCreateForm({ className={`flex-1 cursor-pointer rounded-lg border p-4 transition-colors hover:bg-muted/50 ${engine === 'terraform' ? 'ring-2 ring-primary border-primary' : 'border-muted'}`} onClick={() => { setEngine('terraform') - setTerraformVersion('1.5.5') + setTerraformVersion('1.5.7') }} > @@ -223,12 +223,23 @@ export default function UnitCreateForm({ id="terraform-version" value={terraformVersion} onChange={(e) => setTerraformVersion(e.target.value)} - placeholder={engine === 'tofu' ? '1.10.0' : '1.5.5'} + placeholder={engine === 'tofu' ? '1.10.0' : '1.5.7'} className="font-mono" /> + {engine === 'terraform' && !!terraformVersion && parseFloat(terraformVersion) >= 1.6 && ( +

+ + + +
+ Warning: Unsupported version +

Terraform versions 1.6.0 and above are not officially supported.

+
+
+ )}

{engine === 'terraform' ? ( - <>Pre-built versions: 1.0.11, 1.3.9, 1.5.5 (fast startup). Custom versions installed at runtime. + <>Pre-built versions: 1.0.11, 1.3.9, 1.5.7 (fast startup). Custom versions installed at runtime. We do not support versions above 1.5.7 ) : ( <>Pre-built versions: 1.6.0, 1.10.0 (fast startup). Custom versions installed at runtime. )} @@ -244,13 +255,27 @@ export default function UnitCreateForm({ -

) : (
-
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx index 2f14cacfa..900572bcb 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/units.$unitId.tsx @@ -157,7 +157,7 @@ function RouteComponent() { const [terraformVersion, setTerraformVersion] = useState( unit.tfe_engine === 'terraform' && unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' ? unit.tfe_terraform_version - : '1.5.5' + : '1.5.7' ) const [tofuVersion, setTofuVersion] = useState( unit.tfe_engine === 'tofu' && unit.tfe_terraform_version && unit.tfe_terraform_version.trim() !== '' @@ -534,12 +534,23 @@ function RouteComponent() { id="settings-version" value={currentVersion} onChange={(e) => setCurrentVersion(e.target.value)} - placeholder={engine === 'tofu' ? '1.6.0' : '1.5.5'} + placeholder={engine === 'tofu' ? '1.6.0' : '1.5.7'} className="font-mono" /> + {engine === 'terraform' && !!currentVersion && parseFloat(currentVersion) >= 1.6 && ( +
+ + + +
+ Warning: Unsupported version +

Terraform versions 1.6.0 and above are not officially supported.

+
+
+ )}

{engine === 'terraform' ? ( - <>Pre-built versions: 1.0.11, 1.3.9, 1.5.5 (fast startup). Custom versions installed at runtime. + <>Pre-built versions: 1.0.11, 1.3.9, 1.5.7 (fast startup). Custom versions installed at runtime.We do not support versions above 1.5.7 ) : ( <>Pre-built versions: 1.6.0, 1.10.0 (fast startup). Custom versions installed at runtime. )} @@ -547,7 +558,14 @@ function RouteComponent() {

-