From 435de4c9eb51d761e0d5c5a2ab291a492da397de Mon Sep 17 00:00:00 2001 From: Patrick Koss <49844980+PatrickKoss@users.noreply.github.com> Date: Fri, 28 Mar 2025 16:20:25 +0100 Subject: [PATCH] feat: add model serving resource * add model serving * add right provider config * rename model_serving to modelserving * add model serving custom endpoint everywhere * rename file * add default region, docs for model serving * add right order of wait handler * rotate after to token * fixes * add initial doc files * address code comments * refactor region description * remove warning for not found resources * add service enablement * address code comments * address code comments * fix datasource * fix acc test * review changes * review changes * review changes * review changes * review changes * review changes * review changes * review changes * review changes * embed markdown description * go tidy --------- Co-authored-by: Mauritz Uphoff Co-authored-by: Mauritz Uphoff <39736813+h3adex@users.noreply.github.com> --- docs/index.md | 1 + docs/resources/modelserving_token.md | 71 ++ go.mod | 21 +- go.sum | 60 +- stackit/internal/core/core.go | 1 + .../modelserving/modelserving_acc_test.go | 158 ++++ .../modelserving/token/description.md | 20 + .../services/modelserving/token/resource.go | 676 ++++++++++++++++++ .../modelserving/token/resource_test.go | 341 +++++++++ stackit/internal/testutil/testutil.go | 17 + stackit/internal/utils/regions.go | 2 +- stackit/internal/utils/utils.go | 8 +- stackit/internal/validate/validate.go | 18 + stackit/internal/validate/validate_test.go | 76 ++ stackit/provider.go | 11 + 15 files changed, 1436 insertions(+), 45 deletions(-) create mode 100644 docs/resources/modelserving_token.md create mode 100644 stackit/internal/services/modelserving/modelserving_acc_test.go create mode 100644 stackit/internal/services/modelserving/token/description.md create mode 100644 stackit/internal/services/modelserving/token/resource.go create mode 100644 stackit/internal/services/modelserving/token/resource_test.go diff --git a/docs/index.md b/docs/index.md index ae1998cd..d02d6e8b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -162,6 +162,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de - `loadbalancer_custom_endpoint` (String) Custom endpoint for the Load Balancer service - `logme_custom_endpoint` (String) Custom endpoint for the LogMe service - `mariadb_custom_endpoint` (String) Custom endpoint for the MariaDB service +- `modelserving_custom_endpoint` (String) Custom endpoint for the Model Serving service - `mongodbflex_custom_endpoint` (String) Custom endpoint for the MongoDB Flex service - `objectstorage_custom_endpoint` (String) Custom endpoint for the Object Storage service - `observability_custom_endpoint` (String) Custom endpoint for the Observability service diff --git a/docs/resources/modelserving_token.md b/docs/resources/modelserving_token.md new file mode 100644 index 00000000..ad5b1350 --- /dev/null +++ b/docs/resources/modelserving_token.md @@ -0,0 +1,71 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "stackit_modelserving_token Resource - stackit" +subcategory: "" +description: |- + Model Serving Auth Token Resource schema. + Example Usage + Automatically rotate model serving token + + resource "time_rotating" "rotate" { + rotation_days = 80 + } + + resource "stackit_modelserving_token" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } + + } +--- + +# stackit_modelserving_token (Resource) + +Model Serving Auth Token Resource schema. + +## Example Usage + +### Automatically rotate model serving token +```terraform +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_modelserving_token" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } + +} +``` + + + + +## Schema + +### Required + +- `name` (String) Name of the model serving auth token. +- `project_id` (String) STACKIT project ID to which the model serving auth token is associated. + +### Optional + +- `description` (String) The description of the model serving auth token. +- `region` (String) Region to which the model serving auth token is associated. If not defined, the provider region is used +- `rotate_when_changed` (Map of String) A map of arbitrary key/value pairs that will force recreation of the token when they change, enabling token rotation based on external conditions such as a rotating timestamp. Changing this forces a new resource to be created. +- `ttl_duration` (String) The TTL duration of the model serving auth token. E.g. 5h30m40s,5h,5h30m,30m,30s + +### Read-Only + +- `id` (String) Terraform's internal data source. ID. It is structured as "`project_id`,`region`,`token_id`". +- `state` (String) State of the model serving auth token. +- `token` (String, Sensitive) Content of the model serving auth token. +- `token_id` (String) The model serving auth token ID. +- `valid_until` (String) The time until the model serving auth token is valid. diff --git a/go.mod b/go.mod index 44d03780..d4d787c4 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stackitcloud/stackit-sdk-go/services/loadbalancer v1.0.0 github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0 github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.21.0 + github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.2.1 github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.18.0 github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.1.0 github.com/stackitcloud/stackit-sdk-go/services/observability v0.3.0 @@ -40,7 +41,7 @@ require ( require github.com/hashicorp/go-retryablehttp v0.7.7 // indirect require ( - github.com/ProtonMail/go-crypto v1.1.0-alpha.2 // indirect + github.com/ProtonMail/go-crypto v1.1.3 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/cloudflare/circl v1.3.7 // indirect @@ -56,17 +57,17 @@ require ( github.com/hashicorp/go-plugin v1.6.2 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect - github.com/hashicorp/hc-install v0.9.0 // indirect + github.com/hashicorp/hc-install v0.9.1 // indirect github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-exec v0.21.0 // indirect - github.com/hashicorp/terraform-json v0.23.0 // indirect + github.com/hashicorp/terraform-exec v0.22.0 // indirect + github.com/hashicorp/terraform-json v0.24.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.35.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect @@ -74,19 +75,19 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/stackitcloud/stackit-sdk-go/services/authorization v0.6.0 github.com/stretchr/testify v1.8.4 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - github.com/zclconf/go-cty v1.15.0 // indirect + github.com/zclconf/go-cty v1.16.2 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.10.0 // indirect + golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/text v0.22.0 // indirect + golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/grpc v1.69.4 // indirect diff --git a/go.sum b/go.sum index 784cb61d..2d8d559b 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= -github.com/ProtonMail/go-crypto v1.1.0-alpha.2 h1:bkyFVUP+ROOARdgCiJzNQo2V2kiB97LyUpzH9P6Hrlg= -github.com/ProtonMail/go-crypto v1.1.0-alpha.2/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= +github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= @@ -14,8 +14,8 @@ github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= -github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= +github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -26,10 +26,10 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= -github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= -github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= +github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= +github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= +github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= +github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -76,16 +76,16 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/hc-install v0.9.0 h1:2dIk8LcvANwtv3QZLckxcjyF5w8KVtiMxu6G6eLhghE= -github.com/hashicorp/hc-install v0.9.0/go.mod h1:+6vOP+mf3tuGgMApVYtmsnDoKWMDcFXeTxCACYZ8SFg= +github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= +github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= -github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= -github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= -github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= +github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= +github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= +github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= +github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY= github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4= github.com/hashicorp/terraform-plugin-framework-validators v0.17.0 h1:0uYQcqqgW3BMyyve07WJgpKorXST3zkpzvrOnf3mpbg= @@ -120,11 +120,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -145,12 +144,12 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/skeema/knownhosts v1.2.2 h1:Iug2P4fLmDw9f41PB6thxUkNUkJzB5i+1/exaj40L3A= -github.com/skeema/knownhosts v1.2.2/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= +github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= +github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/stackitcloud/stackit-sdk-go/core v0.16.2 h1:F8A4P/LLlQSbz0S0+G3m8rb3BUOK6EcR/CKx5UQY5jQ= github.com/stackitcloud/stackit-sdk-go/core v0.16.2/go.mod h1:8KIw3czdNJ9sdil9QQimxjR6vHjeINFrRv0iZ67wfn0= github.com/stackitcloud/stackit-sdk-go/services/argus v0.11.0 h1:JVEx/ouHB6PlwGzQa3ywyDym1HTWo3WgrxAyXprCnuM= @@ -167,6 +166,8 @@ github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0 h1:P7bxaVzkZPGMWIt github.com/stackitcloud/stackit-sdk-go/services/logme v0.21.0/go.mod h1:os4Kp2+jkMUJ2dZtgU9A91N3EJSw3MMh2slxgK1609g= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.21.0 h1:ks1i+cfD/YPRss//4aq6uvxbLvUwb5QvcUrOPeboLFY= github.com/stackitcloud/stackit-sdk-go/services/mariadb v0.21.0/go.mod h1:kGAT87SO5Wkv/CSZevMZcPml3V38G6tnT1Wvdkdmkv4= +github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.2.1 h1:6G6AZWZMWYRw4l+Gvg0KG3VcYnl/phOA8bpJ+2Yz/Rw= +github.com/stackitcloud/stackit-sdk-go/services/modelserving v0.2.1/go.mod h1:i3wU8mtK1sdu8AaVDFc39nfKyjuRAd/0oCnuGl1wMfs= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.18.0 h1:mXVFa5/5uvOibPAUU0HTM7uf7H95IbnYnIzNvR5gB00= github.com/stackitcloud/stackit-sdk-go/services/mongodbflex v0.18.0/go.mod h1:uuTdgDo4Ju2W0eMfHc3a5n9SXNKJPdxuq15e3AFss6Q= github.com/stackitcloud/stackit-sdk-go/services/objectstorage v1.1.0 h1:kWfmDQeTMijx0ySPiPfL4EU1TL6lcpkRVrzXenSaX6w= @@ -213,8 +214,8 @@ github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.15.0 h1:tTCRWxsexYUmtt/wVxgDClUe+uQusuI443uL6e+5sXQ= -github.com/zclconf/go-cty v1.15.0/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= +github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= @@ -243,8 +244,8 @@ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -255,7 +256,6 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -268,13 +268,13 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go index d1fbe1b9..f4502311 100644 --- a/stackit/internal/core/core.go +++ b/stackit/internal/core/core.go @@ -27,6 +27,7 @@ type ProviderData struct { LogMeCustomEndpoint string MariaDBCustomEndpoint string MongoDBFlexCustomEndpoint string + ModelServingCustomEndpoint string ObjectStorageCustomEndpoint string ObservabilityCustomEndpoint string OpenSearchCustomEndpoint string diff --git a/stackit/internal/services/modelserving/modelserving_acc_test.go b/stackit/internal/services/modelserving/modelserving_acc_test.go new file mode 100644 index 00000000..4f31d5ca --- /dev/null +++ b/stackit/internal/services/modelserving/modelserving_acc_test.go @@ -0,0 +1,158 @@ +package modelserving_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/modelserving" + "github.com/stackitcloud/stackit-sdk-go/services/modelserving/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil" +) + +// Token resource data +var tokenResource = map[string]string{ + "project_id": testutil.ProjectId, + "name": "token01", + "description": "my description", + "description_updated": "my description updated", + "region": testutil.Region, + "ttl_duration": "1h", +} + +func inputTokenConfig(name, description string) string { + return fmt.Sprintf(` + %s + + resource "stackit_modelserving_token" "token" { + project_id = "%s" + region = "%s" + name = "%s" + description = "%s" + ttl_duration = "%s" + } + `, + testutil.ModelServingProviderConfig(), + tokenResource["project_id"], + tokenResource["region"], + name, + description, + tokenResource["ttl_duration"], + ) +} + +func TestAccModelServingTokenResource(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckModelServingTokenDestroy, + Steps: []resource.TestStep{ + // Creation + { + Config: inputTokenConfig( + tokenResource["name"], + tokenResource["description"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "project_id", tokenResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "region", tokenResource["region"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "name", tokenResource["name"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "description", tokenResource["description"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "ttl_duration", tokenResource["ttl_duration"]), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "token_id"), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "state"), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "valid_until"), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "token"), + ), + }, + // Update + { + Config: inputTokenConfig( + tokenResource["name"], + tokenResource["description_updated"], + ), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "project_id", tokenResource["project_id"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "region", tokenResource["region"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "name", tokenResource["name"]), + resource.TestCheckResourceAttr("stackit_modelserving_token.token", "description", tokenResource["description_updated"]), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "token_id"), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "state"), + resource.TestCheckResourceAttrSet("stackit_modelserving_token.token", "valid_until"), + ), + }, + // Deletion is done by the framework implicitly + }, + }) +} + +func testAccCheckModelServingTokenDestroy(s *terraform.State) error { + ctx := context.Background() + var client *modelserving.APIClient + var err error + + if testutil.ModelServingCustomEndpoint == "" { + client, err = modelserving.NewAPIClient() + } else { + client, err = modelserving.NewAPIClient( + config.WithEndpoint(testutil.ModelServingCustomEndpoint), + ) + } + + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + tokensToDestroy := []string{} + for _, rs := range s.RootModule().Resources { + if rs.Type != "stackit_modelserving_token" { + continue + } + + // Token terraform ID: "[project_id],[region],[token_id]" + idParts := strings.Split(rs.Primary.ID, core.Separator) + if len(idParts) != 3 { + return fmt.Errorf("invalid ID: %s", rs.Primary.ID) + } + if idParts[2] != "" { + tokensToDestroy = append(tokensToDestroy, idParts[2]) + } + } + + if len(tokensToDestroy) == 0 { + return nil + } + + tokensResp, err := client.ListTokens(ctx, testutil.Region, testutil.ProjectId).Execute() + if err != nil { + return fmt.Errorf("getting tokensResp: %w", err) + } + + if tokensResp.Tokens == nil || (tokensResp.Tokens != nil && len(*tokensResp.Tokens) == 0) { + fmt.Print("No tokens found for project \n") + return nil + } + + items := *tokensResp.Tokens + for i := range items { + if items[i].Name == nil { + continue + } + if utils.Contains(tokensToDestroy, *items[i].Name) { + _, err := client.DeleteToken(ctx, testutil.Region, testutil.ProjectId, *items[i].Id).Execute() + if err != nil { + return fmt.Errorf("destroying token %s during CheckDestroy: %w", *items[i].Name, err) + } + _, err = wait.DeleteModelServingWaitHandler(ctx, client, testutil.Region, testutil.ProjectId, *items[i].Id).WaitWithContext(ctx) + if err != nil { + return fmt.Errorf("destroying token %s during CheckDestroy: waiting for deletion %w", *items[i].Name, err) + } + } + } + return nil +} diff --git a/stackit/internal/services/modelserving/token/description.md b/stackit/internal/services/modelserving/token/description.md new file mode 100644 index 00000000..43ad6daf --- /dev/null +++ b/stackit/internal/services/modelserving/token/description.md @@ -0,0 +1,20 @@ +Model Serving Auth Token Resource schema. + +## Example Usage + +### Automatically rotate model serving token +```terraform +resource "time_rotating" "rotate" { + rotation_days = 80 +} + +resource "stackit_modelserving_token" "example" { + project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + name = "Example token" + + rotate_when_changed = { + rotation = time_rotating.rotate.id + } + +} +``` \ No newline at end of file diff --git a/stackit/internal/services/modelserving/token/resource.go b/stackit/internal/services/modelserving/token/resource.go new file mode 100644 index 00000000..8ad04a97 --- /dev/null +++ b/stackit/internal/services/modelserving/token/resource.go @@ -0,0 +1,676 @@ +package token + +import ( + "context" + _ "embed" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/stackitcloud/stackit-sdk-go/core/config" + "github.com/stackitcloud/stackit-sdk-go/core/oapierror" + "github.com/stackitcloud/stackit-sdk-go/services/modelserving" + "github.com/stackitcloud/stackit-sdk-go/services/modelserving/wait" + "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement" + serviceEnablementWait "github.com/stackitcloud/stackit-sdk-go/services/serviceenablement/wait" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils" + "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &tokenResource{} + _ resource.ResourceWithConfigure = &tokenResource{} + _ resource.ResourceWithModifyPlan = &tokenResource{} +) + +const ( + inactiveState = "inactive" +) + +//go:embed description.md +var markdownDescription string + +type Model struct { + Id types.String `tfsdk:"id"` // needed by TF + ProjectId types.String `tfsdk:"project_id"` + Region types.String `tfsdk:"region"` + TokenId types.String `tfsdk:"token_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + State types.String `tfsdk:"state"` + ValidUntil types.String `tfsdk:"valid_until"` + TTLDuration types.String `tfsdk:"ttl_duration"` + Token types.String `tfsdk:"token"` + // RotateWhenChanged is a map of arbitrary key/value pairs that will force + // recreation of the token when they change, enabling token rotation based on + // external conditions such as a rotating timestamp. Changing this forces a new + // resource to be created. + RotateWhenChanged types.Map `tfsdk:"rotate_when_changed"` +} + +// NewTokenResource is a helper function to simplify the provider implementation. +func NewTokenResource() resource.Resource { + return &tokenResource{} +} + +// tokenResource is the resource implementation. +type tokenResource struct { + client *modelserving.APIClient + providerData core.ProviderData + serviceEnablementClient *serviceenablement.APIClient +} + +// Metadata returns the resource type name. +func (r *tokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_modelserving_token" +} + +// Configure adds the provider configured client to the resource. +func (r *tokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(core.ProviderData) + if !ok { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error configuring API client", fmt.Sprintf("Expected configure type stackit.ProviderData, got %T", req.ProviderData)) + return + } + + var apiClient *modelserving.APIClient + var err error + if providerData.ModelServingCustomEndpoint != "" { + ctx = tflog.SetField( + ctx, + "modelserving_custom_endpoint", + providerData.ModelServingCustomEndpoint, + ) + apiClient, err = modelserving.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.ModelServingCustomEndpoint), + ) + } else { + apiClient, err = modelserving.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error configuring API client", + fmt.Sprintf( + "Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", + err, + ), + ) + return + } + + var serviceEnablementClient *serviceenablement.APIClient + if providerData.ServiceEnablementCustomEndpoint != "" { + serviceEnablementClient, err = serviceenablement.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + config.WithEndpoint(providerData.ServiceEnablementCustomEndpoint), + ) + } else { + serviceEnablementClient, err = serviceenablement.NewAPIClient( + config.WithCustomAuth(providerData.RoundTripper), + ) + } + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error configuring service enablement client", + fmt.Sprintf( + "Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", + err, + ), + ) + return + } + + r.client = apiClient + r.providerData = providerData + r.serviceEnablementClient = serviceEnablementClient + tflog.Info(ctx, "Model-Serving auth token client configured") +} + +// ModifyPlan implements resource.ResourceWithModifyPlan. +// Use the modifier to set the effective region in the current plan. +func (r *tokenResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { // nolint:gocritic // function signature required by Terraform + var configModel Model + + // skip initial empty configuration to avoid follow-up errors + if req.Config.Raw.IsNull() { + return + } + resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...) + if resp.Diagnostics.HasError() { + return + } + + var planModel Model + resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...) + if resp.Diagnostics.HasError() { + return + } + + utils.AdaptRegion( + ctx, + configModel.Region, + &planModel.Region, + r.providerData.GetRegion(), + resp, + ) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Schema defines the schema for the resource. +func (r *tokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: markdownDescription, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Terraform's internal data source. ID. It is structured as \"`project_id`,`region`,`token_id`\".", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "STACKIT project ID to which the model serving auth token is associated.", + Required: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "region": schema.StringAttribute{ + Optional: true, + // must be computed to allow for storing the override value from the provider + Computed: true, + Description: "Region to which the model serving auth token is associated. If not defined, the provider region is used", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "token_id": schema.StringAttribute{ + Description: "The model serving auth token ID.", + Computed: true, + Validators: []validator.String{ + validate.UUID(), + validate.NoSeparator(), + }, + }, + "ttl_duration": schema.StringAttribute{ + Description: "The TTL duration of the model serving auth token. E.g. 5h30m40s,5h,5h30m,30m,30s", + Required: false, + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + validate.ValidDurationString(), + }, + }, + "rotate_when_changed": schema.MapAttribute{ + Description: "A map of arbitrary key/value pairs that will force " + + "recreation of the token when they change, enabling token rotation " + + "based on external conditions such as a rotating timestamp. Changing " + + "this forces a new resource to be created.", + Optional: true, + Required: false, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Map{ + mapplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + Description: "The description of the model serving auth token.", + Required: false, + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 2000), + }, + }, + "name": schema.StringAttribute{ + Description: "Name of the model serving auth token.", + Required: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 200), + }, + }, + "state": schema.StringAttribute{ + Description: "State of the model serving auth token.", + Computed: true, + }, + "token": schema.StringAttribute{ + Description: "Content of the model serving auth token.", + Computed: true, + Sensitive: true, + }, + "valid_until": schema.StringAttribute{ + Description: "The time until the model serving auth token is valid.", + Computed: true, + }, + }, + } +} + +// Create creates the resource and sets the initial Terraform state. +func (r *tokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.GetRegion() + } else { + region = model.Region.ValueString() + } + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "region", region) + + // If model serving is not enabled, enable it + err := r.serviceEnablementClient.EnableServiceRegional(ctx, region, projectId, utils.ModelServingServiceId). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error enabling model serving", + fmt.Sprintf("Service not available in region %s \n%v", region, err), + ) + return + } + } + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error enabling model serving", + fmt.Sprintf("Error enabling model serving: %v", err), + ) + return + } + + _, err = serviceEnablementWait.EnableServiceWaitHandler(ctx, r.serviceEnablementClient, region, projectId, utils.ModelServingServiceId). + WaitWithContext(ctx) + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error enabling model serving", + fmt.Sprintf("Error enabling model serving: %v", err), + ) + return + } + + // Generate API request body from model + payload, err := toCreatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating model serving auth token", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Create new model serving auth token + createTokenResp, err := r.client.CreateToken(ctx, region, projectId). + CreateTokenPayload(*payload). + Execute() + if err != nil { + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error creating model serving auth token", + fmt.Sprintf("Calling API: %v", err), + ) + return + } + + waitResp, err := wait.CreateModelServingWaitHandler(ctx, r.client, region, projectId, *createTokenResp.Token.Id).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating model serving auth token", fmt.Sprintf("Waiting for token to be active: %v", err)) + return + } + + // Map response body to schema + err = mapCreateResponse(createTokenResp, waitResp, &model, region) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating model serving auth token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set state to fully populated data + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model-Serving auth token created") +} + +// Read refreshes the Terraform state with the latest data. +func (r *tokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // nolint:gocritic // function signature required by Terraform + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + tokenId := model.TokenId.ValueString() + + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.GetRegion() + } else { + region = model.Region.ValueString() + } + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "region", region) + + getTokenResp, err := r.client.GetToken(ctx, region, projectId, tokenId). + Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + // Remove the resource from the state so Terraform will recreate it + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading model serving auth token", fmt.Sprintf("Calling API: %v", err)) + return + } + + if getTokenResp != nil && getTokenResp.Token.State != nil && + *getTokenResp.Token.State == inactiveState { + resp.State.RemoveResource(ctx) + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Error reading model serving auth token", "Model serving auth token has expired") + return + } + + // Map response body to schema + err = mapGetResponse(getTokenResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading model serving auth token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + // Set refreshed state + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model-Serving auth token read") +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *tokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.Plan.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get current state + var state Model + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := state.ProjectId.ValueString() + tokenId := state.TokenId.ValueString() + + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.GetRegion() + } else { + region = model.Region.ValueString() + } + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "region", region) + + // Generate API request body from model + payload, err := toUpdatePayload(&model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating model serving auth token", fmt.Sprintf("Creating API payload: %v", err)) + return + } + + // Update model serving auth token + updateTokenResp, err := r.client.PartialUpdateToken(ctx, region, projectId, tokenId).PartialUpdateTokenPayload(*payload).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + // Remove the resource from the state so Terraform will recreate it + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError( + ctx, + &resp.Diagnostics, + "Error updating model serving auth token", + fmt.Sprintf( + "Calling API: %v, tokenId: %s, region: %s, projectId: %s", + err, + tokenId, + region, + projectId, + ), + ) + return + } + + if updateTokenResp != nil && updateTokenResp.Token.State != nil && + *updateTokenResp.Token.State == inactiveState { + resp.State.RemoveResource(ctx) + core.LogAndAddWarning(ctx, &resp.Diagnostics, "Error updating model serving auth token", "Model serving auth token has expired") + return + } + + waitResp, err := wait.UpdateModelServingWaitHandler(ctx, r.client, region, projectId, tokenId).WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating model serving auth token", fmt.Sprintf("Waiting for token to be updated: %v", err)) + return + } + + // Since STACKIT is not saving the content of the token. We have to use it from the state. + model.Token = state.Token + err = mapGetResponse(waitResp, &model) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating model serving auth token", fmt.Sprintf("Processing API payload: %v", err)) + return + } + + diags = resp.State.Set(ctx, model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Info(ctx, "Model-Serving auth token updated") +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *tokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // nolint:gocritic // function signature required by Terraform + // Retrieve values from plan + var model Model + diags := req.State.Get(ctx, &model) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + projectId := model.ProjectId.ValueString() + tokenId := model.TokenId.ValueString() + + var region string + if utils.IsUndefined(model.Region) { + region = r.providerData.GetRegion() + } else { + region = model.Region.ValueString() + } + + ctx = tflog.SetField(ctx, "project_id", projectId) + ctx = tflog.SetField(ctx, "token_id", tokenId) + ctx = tflog.SetField(ctx, "region", region) + + // Delete existing model serving auth token. We will ignore the state 'deleting' for now. + _, err := r.client.DeleteToken(ctx, region, projectId, tokenId).Execute() + if err != nil { + var oapiErr *oapierror.GenericOpenAPIError + if errors.As(err, &oapiErr) { + if oapiErr.StatusCode == http.StatusNotFound { + resp.State.RemoveResource(ctx) + return + } + } + + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting model serving auth token", fmt.Sprintf("Calling API: %v", err)) + return + } + + _, err = wait.DeleteModelServingWaitHandler(ctx, r.client, region, projectId, tokenId). + WaitWithContext(ctx) + if err != nil { + core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting model serving auth token", fmt.Sprintf("Waiting for token to be deleted: %v", err)) + return + } + + tflog.Info(ctx, "Model-Serving auth token deleted") +} + +func mapCreateResponse(tokenCreateResp *modelserving.CreateTokenResponse, waitResp *modelserving.GetTokenResponse, model *Model, region string) error { + if tokenCreateResp == nil || tokenCreateResp.Token == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + token := tokenCreateResp.Token + + if token.Id == nil { + return fmt.Errorf("token id not present") + } + + validUntil := types.StringNull() + if token.ValidUntil != nil { + validUntil = types.StringValue(token.ValidUntil.Format(time.RFC3339)) + } + + if waitResp == nil || waitResp.Token == nil || waitResp.Token.State == nil { + return fmt.Errorf("response input is nil") + } + + idParts := []string{model.ProjectId.ValueString(), region, *tokenCreateResp.Token.Id} + model.Id = types.StringValue( + strings.Join(idParts, core.Separator), + ) + model.TokenId = types.StringPointerValue(token.Id) + model.Name = types.StringPointerValue(token.Name) + model.State = types.StringPointerValue(waitResp.Token.State) + model.ValidUntil = validUntil + model.Token = types.StringPointerValue(token.Content) + model.Description = types.StringPointerValue(token.Description) + + return nil +} + +func mapGetResponse(tokenGetResp *modelserving.GetTokenResponse, model *Model) error { + if tokenGetResp == nil { + return fmt.Errorf("response input is nil") + } + + if tokenGetResp.Token == nil { + return fmt.Errorf("response input is nil") + } + if model == nil { + return fmt.Errorf("model input is nil") + } + + // theoretically, should never happen, but still catch null pointers + validUntil := types.StringNull() + if tokenGetResp.Token.ValidUntil != nil { + validUntil = types.StringValue(tokenGetResp.Token.ValidUntil.Format(time.RFC3339)) + } + + idParts := []string{model.ProjectId.ValueString(), model.Region.ValueString(), model.TokenId.ValueString()} + model.Id = types.StringValue(strings.Join(idParts, core.Separator)) + model.TokenId = types.StringPointerValue(tokenGetResp.Token.Id) + model.Name = types.StringPointerValue(tokenGetResp.Token.Name) + model.State = types.StringPointerValue(tokenGetResp.Token.State) + model.ValidUntil = validUntil + model.Description = types.StringPointerValue(tokenGetResp.Token.Description) + + return nil +} + +func toCreatePayload(model *Model) (*modelserving.CreateTokenPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &modelserving.CreateTokenPayload{ + Name: conversion.StringValueToPointer(model.Name), + Description: conversion.StringValueToPointer(model.Description), + TtlDuration: conversion.StringValueToPointer(model.TTLDuration), + }, nil +} + +func toUpdatePayload(model *Model) (*modelserving.PartialUpdateTokenPayload, error) { + if model == nil { + return nil, fmt.Errorf("nil model") + } + + return &modelserving.PartialUpdateTokenPayload{ + Name: conversion.StringValueToPointer(model.Name), + Description: conversion.StringValueToPointer(model.Description), + }, nil +} diff --git a/stackit/internal/services/modelserving/token/resource_test.go b/stackit/internal/services/modelserving/token/resource_test.go new file mode 100644 index 00000000..c8a2bc61 --- /dev/null +++ b/stackit/internal/services/modelserving/token/resource_test.go @@ -0,0 +1,341 @@ +package token + +import ( + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stackitcloud/stackit-sdk-go/core/utils" + "github.com/stackitcloud/stackit-sdk-go/services/modelserving" +) + +func TestMapGetTokenFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + input *modelserving.GetTokenResponse + expected Model + isValid bool + }{ + { + description: "should error when response is nil", + state: &Model{}, + input: nil, + expected: Model{}, + isValid: false, + }, + { + description: "should error when token is nil in response", + state: &Model{}, + input: &modelserving.GetTokenResponse{Token: nil}, + expected: Model{}, + isValid: false, + }, + { + description: "should error when state is nil in response", + state: nil, + input: &modelserving.GetTokenResponse{ + Token: &modelserving.Token{}, + }, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,tid"), + ProjectId: types.StringValue("pid"), + TokenId: types.StringValue("tid"), + Region: types.StringValue("eu01"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + input: &modelserving.GetTokenResponse{ + Token: &modelserving.Token{ + Id: utils.Ptr("tid"), + ValidUntil: utils.Ptr( + time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + ), + State: utils.Ptr("active"), + Name: utils.Ptr("name"), + Description: utils.Ptr("desc"), + Region: utils.Ptr("eu01"), + }, + }, + expected: Model{ + Id: types.StringValue("pid,eu01,tid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + TokenId: types.StringValue("tid"), + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + State: types.StringValue("active"), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + err := mapGetResponse(tt.input, tt.state) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestMapCreateTokenFields(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + state *Model + inputCreateTokenResponse *modelserving.CreateTokenResponse + inputGetTokenResponse *modelserving.GetTokenResponse + expected Model + isValid bool + }{ + { + description: "should error when create token response is nil", + state: &Model{}, + inputCreateTokenResponse: nil, + inputGetTokenResponse: nil, + expected: Model{}, + isValid: false, + }, + { + description: "should error when token is nil in create token response", + state: &Model{}, + inputCreateTokenResponse: &modelserving.CreateTokenResponse{ + Token: nil, + }, + inputGetTokenResponse: nil, + expected: Model{}, + isValid: false, + }, + { + description: "should error when get token response is nil", + state: &Model{}, + inputCreateTokenResponse: &modelserving.CreateTokenResponse{ + Token: &modelserving.TokenCreated{}, + }, + inputGetTokenResponse: nil, + expected: Model{}, + isValid: false, + }, + { + description: "should error when get token response is nil", + state: &Model{ + Id: types.StringValue("pid,eu01,tid"), + ProjectId: types.StringValue("pid"), + }, + inputCreateTokenResponse: &modelserving.CreateTokenResponse{ + Token: &modelserving.TokenCreated{ + Id: utils.Ptr("tid"), + ValidUntil: utils.Ptr( + time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + ), + State: utils.Ptr("active"), + Name: utils.Ptr("name"), + Description: utils.Ptr("desc"), + Region: utils.Ptr("eu01"), + Content: utils.Ptr("content"), + }, + }, + inputGetTokenResponse: nil, + expected: Model{}, + isValid: false, + }, + { + description: "should map fields correctly", + state: &Model{ + Id: types.StringValue("pid,eu01,tid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + inputCreateTokenResponse: &modelserving.CreateTokenResponse{ + Token: &modelserving.TokenCreated{ + Id: utils.Ptr("tid"), + ValidUntil: utils.Ptr( + time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC), + ), + State: utils.Ptr("active"), + Name: utils.Ptr("name"), + Description: utils.Ptr("desc"), + Region: utils.Ptr("eu01"), + Content: utils.Ptr("content"), + }, + }, + inputGetTokenResponse: &modelserving.GetTokenResponse{ + Token: &modelserving.Token{ + State: utils.Ptr("active"), + }, + }, + expected: Model{ + Id: types.StringValue("pid,eu01,tid"), + ProjectId: types.StringValue("pid"), + Region: types.StringValue("eu01"), + TokenId: types.StringValue("tid"), + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + State: types.StringValue("active"), + ValidUntil: types.StringValue("2099-01-01T00:00:00Z"), + Token: types.StringValue("content"), + RotateWhenChanged: types.MapNull(types.StringType), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + err := mapCreateResponse( + tt.inputCreateTokenResponse, + tt.inputGetTokenResponse, + tt.state, + "eu01", + ) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(tt.state, &tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToCreatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelserving.CreateTokenPayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + TTLDuration: types.StringValue("1h"), + }, + expected: &modelserving.CreateTokenPayload{ + Name: utils.Ptr("name"), + Description: utils.Ptr("desc"), + TtlDuration: utils.Ptr("1h"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toCreatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} + +func TestToUpdatePayload(t *testing.T) { + t.Parallel() + + tests := []struct { + description string + input *Model + expected *modelserving.PartialUpdateTokenPayload + isValid bool + }{ + { + description: "should error on nil input", + input: nil, + expected: nil, + isValid: false, + }, + { + description: "should convert correctly", + input: &Model{ + Name: types.StringValue("name"), + Description: types.StringValue("desc"), + }, + expected: &modelserving.PartialUpdateTokenPayload{ + Name: utils.Ptr("name"), + Description: utils.Ptr("desc"), + }, + isValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + t.Parallel() + + output, err := toUpdatePayload(tt.input) + if !tt.isValid && err == nil { + t.Fatalf("Should have failed") + } + + if tt.isValid && err != nil { + t.Fatalf("Should not have failed: %v", err) + } + + if tt.isValid { + diff := cmp.Diff(output, tt.expected) + if diff != "" { + t.Fatalf("Data does not match: %s", diff) + } + } + }) + } +} diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go index 133aa5be..70347c0b 100644 --- a/stackit/internal/testutil/testutil.go +++ b/stackit/internal/testutil/testutil.go @@ -56,6 +56,7 @@ var ( LoadBalancerCustomEndpoint = os.Getenv("TF_ACC_LOADBALANCER_CUSTOM_ENDPOINT") LogMeCustomEndpoint = os.Getenv("TF_ACC_LOGME_CUSTOM_ENDPOINT") MariaDBCustomEndpoint = os.Getenv("TF_ACC_MARIADB_CUSTOM_ENDPOINT") + ModelServingCustomEndpoint = os.Getenv("TF_ACC_MODELSERVING_CUSTOM_ENDPOINT") AuthorizationCustomEndpoint = os.Getenv("TF_ACC_authorization_custom_endpoint") MongoDBFlexCustomEndpoint = os.Getenv("TF_ACC_MONGODBFLEX_CUSTOM_ENDPOINT") OpenSearchCustomEndpoint = os.Getenv("TF_ACC_OPENSEARCH_CUSTOM_ENDPOINT") @@ -178,6 +179,22 @@ func MariaDBProviderConfig() string { ) } +func ModelServingProviderConfig() string { + if ModelServingCustomEndpoint == "" { + return ` + provider "stackit" { + region = "eu01" + } + ` + } + return fmt.Sprintf(` + provider "stackit" { + modelserving_custom_endpoint = "%s" + }`, + ModelServingCustomEndpoint, + ) +} + func MongoDBFlexProviderConfig() string { if MongoDBFlexCustomEndpoint == "" { return ` diff --git a/stackit/internal/utils/regions.go b/stackit/internal/utils/regions.go index 554d9b0a..7af581c7 100644 --- a/stackit/internal/utils/regions.go +++ b/stackit/internal/utils/regions.go @@ -26,7 +26,7 @@ func AdaptRegion(ctx context.Context, configRegion types.String, planRegion *typ // check if the currently configured region corresponds to the planned region // on mismatch override the planned region with the intended region - // and force a replace of the resource + // and force a replacement of the resource p := path.Root("region") if !intendedRegion.Equal(*planRegion) { resp.RequiresReplace.Append(p) diff --git a/stackit/internal/utils/utils.go b/stackit/internal/utils/utils.go index 4e9942b5..dc938bb2 100644 --- a/stackit/internal/utils/utils.go +++ b/stackit/internal/utils/utils.go @@ -5,14 +5,14 @@ import ( "regexp" "strings" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/stackitcloud/stackit-sdk-go/core/utils" - - "github.com/hashicorp/terraform-plugin-framework/types" ) const ( - SKEServiceId = "cloud.stackit.ske" + SKEServiceId = "cloud.stackit.ske" + ModelServingServiceId = "cloud.stackit.model-serving" ) var ( @@ -72,7 +72,7 @@ func ListValuetoStringSlice(list basetypes.ListValue) ([]string, error) { return result, nil } -// Remove leading 0s from backup schedule numbers (e.g. "00 00 * * *" becomes "0 0 * * *") +// SimplifyBackupSchedule removes leading 0s from backup schedule numbers (e.g. "00 00 * * *" becomes "0 0 * * *") // Needed as the API does it internally and would otherwise cause inconsistent result in Terraform func SimplifyBackupSchedule(schedule string) string { regex := regexp.MustCompile(`0+\d+`) // Matches series of one or more zeros followed by a series of one or more digits diff --git a/stackit/internal/validate/validate.go b/stackit/internal/validate/validate.go index 90f4779f..57b14966 100644 --- a/stackit/internal/validate/validate.go +++ b/stackit/internal/validate/validate.go @@ -295,3 +295,21 @@ func FileExists() *Validator { }, } } + +func ValidDurationString() *Validator { + description := "value must be in a valid duration string. Such as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\"." + + return &Validator{ + description: description, + validate: func(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { + _, err := time.ParseDuration(req.ConfigValue.ValueString()) + if err != nil { + resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic( + req.Path, + description, + req.ConfigValue.ValueString(), + )) + } + }, + } +} diff --git a/stackit/internal/validate/validate_test.go b/stackit/internal/validate/validate_test.go index eba170ca..31618006 100644 --- a/stackit/internal/validate/validate_test.go +++ b/stackit/internal/validate/validate_test.go @@ -769,3 +769,79 @@ func TestFileExists(t *testing.T) { }) } } + +func TestValidTtlDuration(t *testing.T) { + tests := []struct { + description string + input string + isValid bool + }{ + { + "valid duration with hours, minutes, and seconds", + "5h30m40s", + true, + }, + { + "valid duration with hours only", + "5h", + true, + }, + { + "valid duration with hours and minutes", + "5h30m", + true, + }, + { + "valid duration with minutes only", + "30m", + true, + }, + { + "valid duration with seconds only", + "30s", + true, + }, + { + "invalid duration with incorrect unit", + "30o", + false, + }, + { + "invalid duration without unit", + "30", + false, + }, + { + "invalid duration with invalid letters", + "30e", + false, + }, + { + "invalid duration with letters in middle", + "1h30x", + false, + }, + { + "empty string", + "", + false, + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + r := validator.StringResponse{} + va := ValidDurationString() + va.ValidateString(context.Background(), validator.StringRequest{ + ConfigValue: types.StringValue(tt.input), + }, &r) + + if !tt.isValid && !r.Diagnostics.HasError() { + t.Fatalf("Expected validation to fail for input: %v", tt.input) + } + if tt.isValid && r.Diagnostics.HasError() { + t.Fatalf("Expected validation to succeed for input: %v, but got errors: %v", tt.input, r.Diagnostics.Errors()) + } + }) + } +} diff --git a/stackit/provider.go b/stackit/provider.go index ef83c2ef..1090cb34 100644 --- a/stackit/provider.go +++ b/stackit/provider.go @@ -42,6 +42,7 @@ import ( logMeInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/logme/instance" mariaDBCredential "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/credential" mariaDBInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mariadb/instance" + modelServingToken "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/modelserving/token" mongoDBFlexInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/instance" mongoDBFlexUser "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/mongodbflex/user" objectStorageBucket "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/objectstorage/bucket" @@ -118,6 +119,7 @@ type providerModel struct { IaaSCustomEndpoint types.String `tfsdk:"iaas_custom_endpoint"` PostgresFlexCustomEndpoint types.String `tfsdk:"postgresflex_custom_endpoint"` MongoDBFlexCustomEndpoint types.String `tfsdk:"mongodbflex_custom_endpoint"` + ModelServingCustomEndpoint types.String `tfsdk:"modelserving_custom_endpoint"` LoadBalancerCustomEndpoint types.String `tfsdk:"loadbalancer_custom_endpoint"` LogMeCustomEndpoint types.String `tfsdk:"logme_custom_endpoint"` RabbitMQCustomEndpoint types.String `tfsdk:"rabbitmq_custom_endpoint"` @@ -156,6 +158,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro "dns_custom_endpoint": "Custom endpoint for the DNS service", "iaas_custom_endpoint": "Custom endpoint for the IaaS service", "mongodbflex_custom_endpoint": "Custom endpoint for the MongoDB Flex service", + "modelserving_custom_endpoint": "Custom endpoint for the Model Serving service", "loadbalancer_custom_endpoint": "Custom endpoint for the Load Balancer service", "logme_custom_endpoint": "Custom endpoint for the LogMe service", "rabbitmq_custom_endpoint": "Custom endpoint for the RabbitMQ service", @@ -246,6 +249,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro Optional: true, Description: descriptions["mariadb_custom_endpoint"], }, + "modelserving_custom_endpoint": schema.StringAttribute{ + Optional: true, + Description: descriptions["modelserving_custom_endpoint"], + }, "authorization_custom_endpoint": schema.StringAttribute{ Optional: true, Description: descriptions["authorization_custom_endpoint"], @@ -376,6 +383,9 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest, if !(providerConfig.PostgresFlexCustomEndpoint.IsUnknown() || providerConfig.PostgresFlexCustomEndpoint.IsNull()) { providerData.PostgresFlexCustomEndpoint = providerConfig.PostgresFlexCustomEndpoint.ValueString() } + if !(providerConfig.ModelServingCustomEndpoint.IsUnknown() || providerConfig.ModelServingCustomEndpoint.IsNull()) { + providerData.ModelServingCustomEndpoint = providerConfig.ModelServingCustomEndpoint.ValueString() + } if !(providerConfig.MongoDBFlexCustomEndpoint.IsUnknown() || providerConfig.MongoDBFlexCustomEndpoint.IsNull()) { providerData.MongoDBFlexCustomEndpoint = providerConfig.MongoDBFlexCustomEndpoint.ValueString() } @@ -537,6 +547,7 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource { logMeCredential.NewCredentialResource, mariaDBInstance.NewInstanceResource, mariaDBCredential.NewCredentialResource, + modelServingToken.NewTokenResource, mongoDBFlexInstance.NewInstanceResource, mongoDBFlexUser.NewUserResource, objectStorageBucket.NewBucketResource,