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 <mauritz.uphoff@me.com>
Co-authored-by: Mauritz Uphoff <39736813+h3adex@users.noreply.github.com>
This commit is contained in:
Patrick Koss 2025-03-28 16:20:25 +01:00 committed by GitHub
parent 68859a3fad
commit 435de4c9eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1436 additions and 45 deletions

View file

@ -27,6 +27,7 @@ type ProviderData struct {
LogMeCustomEndpoint string
MariaDBCustomEndpoint string
MongoDBFlexCustomEndpoint string
ModelServingCustomEndpoint string
ObjectStorageCustomEndpoint string
ObservabilityCustomEndpoint string
OpenSearchCustomEndpoint string

View file

@ -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
}

View file

@ -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
}
}
```

View file

@ -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
}

View file

@ -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)
}
}
})
}
}

View file

@ -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 `

View file

@ -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)

View file

@ -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

View file

@ -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(),
))
}
},
}
}

View file

@ -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())
}
})
}
}

View file

@ -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,