diff --git a/api/mittwaldv2/client_app.go b/api/mittwaldv2/client_app.go
index 8f3a015..d60632e 100644
--- a/api/mittwaldv2/client_app.go
+++ b/api/mittwaldv2/client_app.go
@@ -13,7 +13,8 @@ type AppClient interface {
GetAppInstallation(ctx context.Context, appInstallationID string) (*DeMittwaldV1AppAppInstallation, error)
WaitUntilAppInstallationIsReady(ctx context.Context, appID string) error
UninstallApp(ctx context.Context, appInstallationID string) error
- LinkAppInstallationToDatabase(ctx context.Context, appInstallationID string, databaseID string, purpose AppLinkDatabaseJSONBodyPurpose) error
+ LinkAppInstallationToDatabase(ctx context.Context, appInstallationID string, databaseID string, userID string, purpose AppLinkDatabaseJSONBodyPurpose) error
+ UnlinkAppInstallationFromDatabase(ctx context.Context, appInstallationID string, databaseID string) error
GetSystemSoftwareByName(ctx context.Context, name string) (*DeMittwaldV1AppSystemSoftware, bool, error)
SelectSystemSoftwareVersion(ctx context.Context, systemSoftwareID, versionSelector string) (DeMittwaldV1AppSystemSoftwareVersionSet, error)
GetSystemSoftwareAndVersion(ctx context.Context, systemSoftwareID, systemSoftwareVersionID string) (*DeMittwaldV1AppSystemSoftware, *DeMittwaldV1AppSystemSoftwareVersion, error)
diff --git a/api/mittwaldv2/client_app_installation.go b/api/mittwaldv2/client_app_installation.go
index da00859..88eb824 100644
--- a/api/mittwaldv2/client_app_installation.go
+++ b/api/mittwaldv2/client_app_installation.go
@@ -107,11 +107,17 @@ func (c *appClient) LinkAppInstallationToDatabase(
ctx context.Context,
appInstallationID string,
databaseID string,
+ userID string,
purpose AppLinkDatabaseJSONBodyPurpose,
) error {
+ userIDs := map[string]string{
+ "admin": userID,
+ }
+
response, err := c.client.AppLinkDatabaseWithResponse(ctx, uuid.MustParse(appInstallationID), AppLinkDatabaseJSONRequestBody{
- DatabaseId: uuid.MustParse(databaseID),
- Purpose: purpose,
+ DatabaseId: uuid.MustParse(databaseID),
+ Purpose: purpose,
+ DatabaseUserIds: &userIDs,
})
if err != nil {
return err
@@ -123,3 +129,16 @@ func (c *appClient) LinkAppInstallationToDatabase(
return errUnexpectedStatus(response.StatusCode(), response.Body)
}
+
+func (c *appClient) UnlinkAppInstallationFromDatabase(ctx context.Context, appInstallationID string, databaseID string) error {
+ resp, err := c.client.AppUnlinkDatabaseWithResponse(ctx, uuid.MustParse(appInstallationID), uuid.MustParse(databaseID))
+ if err != nil {
+ return err
+ }
+
+ if resp.StatusCode() >= 200 && resp.StatusCode() < 300 {
+ return nil
+ }
+
+ return errUnexpectedStatus(resp.StatusCode(), resp.Body)
+}
diff --git a/api/mittwaldv2/client_app_update.go b/api/mittwaldv2/client_app_update.go
index b545148..0abfe5f 100644
--- a/api/mittwaldv2/client_app_update.go
+++ b/api/mittwaldv2/client_app_update.go
@@ -54,7 +54,25 @@ func UpdateAppInstallationSystemSoftware(systemSoftwareID, systemSoftwareVersion
})
}
+func RemoveAppInstallationSystemSoftware(systemSoftwareID string) AppInstallationUpdater {
+ return AppInstallationUpdaterFunc(func(b *AppPatchAppinstallationJSONRequestBody) {
+ if b.SystemSoftware == nil {
+ systemSoftware := make(AppPatchInstallationSystemSoftware)
+ b.SystemSoftware = &systemSoftware
+ }
+
+ (*b.SystemSoftware)[systemSoftwareID] = AppPatchInstallationSystemSoftwareItem{
+ SystemSoftwareVersion: nil,
+ UpdatePolicy: nil,
+ }
+ })
+}
+
func (c *appClient) UpdateAppInstallation(ctx context.Context, appInstallationID string, updater ...AppInstallationUpdater) error {
+ if len(updater) == 0 {
+ return nil
+ }
+
body := AppPatchAppinstallationJSONRequestBody{}
for _, u := range updater {
diff --git a/api/mittwaldv2/client_opts.go b/api/mittwaldv2/client_opts.go
index ad685ec..0ec8b35 100644
--- a/api/mittwaldv2/client_opts.go
+++ b/api/mittwaldv2/client_opts.go
@@ -1,5 +1,13 @@
package mittwaldv2
+import (
+ "bytes"
+ "context"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "io"
+ "net/http"
+)
+
type ClientBuilderOption func(*clientBuilder, *Client)
func WithAPIToken(token string) ClientBuilderOption {
@@ -13,3 +21,46 @@ func WithEndpoint(endpoint string) ClientBuilderOption {
c.Server = endpoint
}
}
+
+type debuggingClient struct {
+ HttpRequestDoer
+ withRequestBodies bool
+}
+
+func (c *debuggingClient) Do(req *http.Request) (*http.Response, error) {
+ logFields := map[string]any{
+ "method": req.Method,
+ "url": req.URL.String(),
+ }
+
+ if req.Body != nil && c.withRequestBodies {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Body = io.NopCloser(bytes.NewBuffer(body))
+ logFields["body"] = string(body)
+ }
+
+ res, err := c.HttpRequestDoer.Do(req)
+
+ if res != nil {
+ logFields["status"] = res.StatusCode
+ }
+
+ if err != nil {
+ logFields["err"] = err
+ }
+
+ tflog.Debug(context.Background(), "executed request", logFields)
+
+ return res, err
+}
+
+func WithDebugging(withRequestBodies bool) ClientBuilderOption {
+ return func(_ *clientBuilder, c *Client) {
+ originalClient := c.Client
+ c.Client = &debuggingClient{HttpRequestDoer: originalClient, withRequestBodies: withRequestBodies}
+ }
+}
diff --git a/api/mittwaldv2/poll.go b/api/mittwaldv2/poll.go
index dd27e92..b8df880 100644
--- a/api/mittwaldv2/poll.go
+++ b/api/mittwaldv2/poll.go
@@ -3,6 +3,8 @@ package mittwaldv2
import (
"context"
"errors"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "math"
"time"
)
@@ -12,7 +14,8 @@ func poll[T any](ctx context.Context, f func() (T, error)) (T, error) {
res := make(chan T)
err := make(chan error)
- t := time.NewTicker(200 * time.Millisecond)
+ d := 100 * time.Millisecond
+ t := time.NewTicker(d)
defer func() {
t.Stop()
@@ -26,12 +29,17 @@ func poll[T any](ctx context.Context, f func() (T, error)) (T, error) {
return
}
+ d = time.Duration(math.Max(float64(d)*1.1, float64(10*time.Second)))
+ t.Reset(d)
+
r, e := f()
if e != nil {
if notFound := (ErrNotFound{}); errors.As(e, ¬Found) {
continue
} else if permissionDenied := (ErrPermissionDenied{}); errors.As(e, &permissionDenied) {
continue
+ } else if errors.Is(e, context.DeadlineExceeded) {
+ return
} else {
err <- e
return
@@ -45,10 +53,11 @@ func poll[T any](ctx context.Context, f func() (T, error)) (T, error) {
select {
case <-ctx.Done():
- return null, ctx.Err()
+ return null, ErrNotFound{}
case r := <-res:
return r, nil
case e := <-err:
+ tflog.Debug(ctx, "polling failed", map[string]any{"error": e})
return null, e
}
}
diff --git a/docs/index.md b/docs/index.md
index e3ea1d0..fe44228 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -31,4 +31,5 @@ provider "mittwald" {
### Optional
- `api_key` (String, Sensitive) API key for the Mittwald API; if omitted, the `MITTWALD_API_TOKEN` environment variable will be used.
+- `debug_request_bodies` (Boolean) Whether to log request bodies when debugging is enabled. CAUTION: This will log sensitive data such as passwords in plain text!
- `endpoint` (String) API endpoint for the Mittwald API. Default to `https://api.mittwald.de/v2` if omitted.
diff --git a/docs/resources/app.md b/docs/resources/app.md
index cc2f393..de78e87 100644
--- a/docs/resources/app.md
+++ b/docs/resources/app.md
@@ -52,8 +52,7 @@ resource "mittwald_app" "wordpress" {
}
resource "mittwald_app" "custom_php" {
- project_id = mittwald_project.foobar.id
- database_id = mittwald_mysql_database.foobar_database.id
+ project_id = mittwald_project.foobar.id
app = "php"
version = "1.0.0"
@@ -62,6 +61,15 @@ resource "mittwald_app" "custom_php" {
document_root = "/public"
update_policy = "none"
+ databases = [
+ {
+ kind = "mysql"
+ purpose = "primary"
+ id = mittwald_mysql_database.foobar_database.id
+ user_id = mittwald_mysql_database.foobar_database.user.id
+ }
+ ]
+
dependencies = {
(data.mittwald_systemsoftware.php.name) = {
version = data.mittwald_systemsoftware.php.version
@@ -91,7 +99,7 @@ resource "mittwald_app" "custom_php" {
### Optional
-- `database_id` (String) The ID of the database the app uses
+- `databases` (Attributes Set) The databases the app uses (see [below for nested schema](#nestedatt--databases))
- `dependencies` (Attributes Map) The dependencies of the app (see [below for nested schema](#nestedatt--dependencies))
- `description` (String) The description of the app
- `document_root` (String) The document root of the app
@@ -103,6 +111,17 @@ resource "mittwald_app" "custom_php" {
- `installation_path` (String) The installation path of the app
- `version_current` (String) The current version of the app
+
+### Nested Schema for `databases`
+
+Required:
+
+- `id` (String) The ID of the database
+- `kind` (String) The kind of the database; one of `mysql` or `redis`
+- `purpose` (String) The purpose of the database; use 'primary' for the primary data storage, or 'cache' for a cache database
+- `user_id` (String) The ID of the database user that the app should use
+
+
### Nested Schema for `dependencies`
diff --git a/examples/resources/mittwald_app/resource.tf b/examples/resources/mittwald_app/resource.tf
index d890d09..2325a5a 100644
--- a/examples/resources/mittwald_app/resource.tf
+++ b/examples/resources/mittwald_app/resource.tf
@@ -37,8 +37,7 @@ resource "mittwald_app" "wordpress" {
}
resource "mittwald_app" "custom_php" {
- project_id = mittwald_project.foobar.id
- database_id = mittwald_mysql_database.foobar_database.id
+ project_id = mittwald_project.foobar.id
app = "php"
version = "1.0.0"
@@ -47,6 +46,15 @@ resource "mittwald_app" "custom_php" {
document_root = "/public"
update_policy = "none"
+ databases = [
+ {
+ kind = "mysql"
+ purpose = "primary"
+ id = mittwald_mysql_database.foobar_database.id
+ user_id = mittwald_mysql_database.foobar_database.user.id
+ }
+ ]
+
dependencies = {
(data.mittwald_systemsoftware.php.name) = {
version = data.mittwald_systemsoftware.php.version
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index 9adbf6e..7cad4a6 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -32,8 +32,9 @@ type MittwaldProvider struct {
// MittwaldProviderModel describes the provider data model.
type MittwaldProviderModel struct {
- Endpoint types.String `tfsdk:"endpoint"`
- APIKey types.String `tfsdk:"api_key"`
+ Endpoint types.String `tfsdk:"endpoint"`
+ APIKey types.String `tfsdk:"api_key"`
+ DebugRequestBodies types.Bool `tfsdk:"debug_request_bodies"`
}
func (p *MittwaldProvider) Metadata(_ context.Context, _ provider.MetadataRequest, resp *provider.MetadataResponse) {
@@ -53,6 +54,10 @@ func (p *MittwaldProvider) Schema(_ context.Context, _ provider.SchemaRequest, r
Optional: true,
Sensitive: true,
},
+ "debug_request_bodies": schema.BoolAttribute{
+ MarkdownDescription: "Whether to log request bodies when debugging is enabled. CAUTION: This will log sensitive data such as passwords in plain text!",
+ Optional: true,
+ },
},
}
}
@@ -87,6 +92,8 @@ func (p *MittwaldProvider) Configure(ctx context.Context, req provider.Configure
opts = append(opts, mittwaldv2.WithEndpoint(data.Endpoint.ValueString()))
}
+ opts = append(opts, mittwaldv2.WithDebugging(data.DebugRequestBodies.ValueBool()))
+
client := mittwaldv2.New(opts...)
resp.DataSourceData = client
diff --git a/internal/provider/providerutil/err_diag.go b/internal/provider/providerutil/err_diag.go
index cd2156d..c0b4a2f 100644
--- a/internal/provider/providerutil/err_diag.go
+++ b/internal/provider/providerutil/err_diag.go
@@ -1,6 +1,10 @@
package providerutil
-import "github.com/hashicorp/terraform-plugin-framework/diag"
+import (
+ "errors"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
+)
func ErrorValueToDiag[T any](res T, err error) func(d *diag.Diagnostics, summary string) T {
return func(d *diag.Diagnostics, summary string) T {
@@ -20,23 +24,35 @@ func ErrorToDiag(err error) func(d *diag.Diagnostics, summary string) {
}
type WrappedError[T any] struct {
- diag *diag.Diagnostics
- summary string
+ diag *diag.Diagnostics
+ summary string
+ ignoreNotFound bool
}
func (w *WrappedError[T]) Do(err error) {
if err != nil {
+ if notFound := (mittwaldv2.ErrNotFound{}); errors.As(err, ¬Found) && w.ignoreNotFound {
+ return
+ }
+ if permissionDenied := (mittwaldv2.ErrPermissionDenied{}); errors.As(err, &permissionDenied) && w.ignoreNotFound {
+ return
+ }
w.diag.AddError(w.summary, err.Error())
}
}
+func (w *WrappedError[T]) IgnoreNotFound() *WrappedError[T] {
+ w.ignoreNotFound = true
+ return w
+}
+
func (w *WrappedError[T]) DoVal(res T, err error) T {
w.Do(err)
return res
}
func Try[T any](d *diag.Diagnostics, summary string) *WrappedError[T] {
- return &WrappedError[T]{d, summary}
+ return &WrappedError[T]{diag: d, summary: summary}
}
func EmbedDiag[T any](resultValue T, resultDiag diag.Diagnostics) func(outDiag *diag.Diagnostics) T {
diff --git a/internal/provider/resource/appresource/model.go b/internal/provider/resource/appresource/model.go
index b34d22d..f482956 100644
--- a/internal/provider/resource/appresource/model.go
+++ b/internal/provider/resource/appresource/model.go
@@ -5,7 +5,7 @@ import "github.com/hashicorp/terraform-plugin-framework/types"
type ResourceModel struct {
ID types.String `tfsdk:"id"`
ProjectID types.String `tfsdk:"project_id"`
- DatabaseID types.String `tfsdk:"database_id"` // TODO: There may theoretically be multiple database links
+ Databases types.Set `tfsdk:"databases"`
Description types.String `tfsdk:"description"`
App types.String `tfsdk:"app"`
Version types.String `tfsdk:"version"`
diff --git a/internal/provider/resource/appresource/model_api.go b/internal/provider/resource/appresource/model_api.go
new file mode 100644
index 0000000..6b75eb2
--- /dev/null
+++ b/internal/provider/resource/appresource/model_api.go
@@ -0,0 +1,216 @@
+package appresource
+
+import (
+ "context"
+ "fmt"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
+ "github.com/mittwald/terraform-provider-mittwald/internal/provider/providerutil"
+ "github.com/mittwald/terraform-provider-mittwald/internal/valueutil"
+)
+
+func (m *ResourceModel) ToCreateRequestWithUpdaters(ctx context.Context, d diag.Diagnostics, appClient mittwaldv2.AppClient) (mittwaldv2.AppRequestAppinstallationJSONRequestBody, []mittwaldv2.AppInstallationUpdater) {
+ return m.ToCreateRequest(ctx, d, appClient), m.ToCreateUpdaters(ctx, d, appClient)
+}
+
+func (m *ResourceModel) ToCreateUpdaters(ctx context.Context, d diag.Diagnostics, appClient mittwaldv2.AppClient) []mittwaldv2.AppInstallationUpdater {
+ updaters := make([]mittwaldv2.AppInstallationUpdater, 0)
+
+ if !m.DocumentRoot.IsNull() {
+ updaters = append(updaters, mittwaldv2.UpdateAppInstallationDocumentRoot(m.DocumentRoot.ValueString()))
+ }
+
+ if !m.UpdatePolicy.IsNull() {
+ updaters = append(updaters, mittwaldv2.UpdateAppInstallationUpdatePolicy(mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(m.UpdatePolicy.ValueString())))
+ }
+
+ if !m.Dependencies.IsNull() {
+ depUpdater := providerutil.
+ Try[mittwaldv2.AppInstallationUpdater](&d, "error while building dependency updaters").
+ DoVal(m.dependenciesToUpdater(ctx, appClient, nil))
+ updaters = append(updaters, depUpdater)
+ }
+
+ return updaters
+}
+
+func (m *ResourceModel) ToCreateRequest(ctx context.Context, d diag.Diagnostics, appClient mittwaldv2.AppClient) (b mittwaldv2.AppRequestAppinstallationJSONRequestBody) {
+ appID, ok := appNames[m.App.ValueString()]
+ if !ok {
+ d.AddError("app", "App not found")
+ return
+ }
+
+ b.Description = m.Description.ValueString()
+ b.UpdatePolicy = mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(m.UpdatePolicy.ValueString())
+
+ appVersions := providerutil.
+ Try[[]mittwaldv2.DeMittwaldV1AppAppVersion](&d, "error while listing app versions").
+ DoVal(appClient.ListAppVersions(ctx, appID))
+
+ for _, appVersion := range appVersions {
+ if appVersion.InternalVersion == m.Version.ValueString() {
+ b.AppVersionId = appVersion.Id
+ }
+ }
+
+ for key, value := range m.UserInputs.Elements() {
+ b.UserInputs = append(b.UserInputs, mittwaldv2.DeMittwaldV1AppSavedUserInput{
+ Name: key,
+ Value: value.String(),
+ })
+ }
+
+ return
+}
+
+func (m *ResourceModel) ToUpdateUpdaters(ctx context.Context, d diag.Diagnostics, current *ResourceModel, appClient mittwaldv2.AppClient) []mittwaldv2.AppInstallationUpdater {
+ updaters := make([]mittwaldv2.AppInstallationUpdater, 0)
+
+ if !m.DocumentRoot.Equal(current.DocumentRoot) {
+ updaters = append(updaters, mittwaldv2.UpdateAppInstallationDocumentRoot(m.DocumentRoot.ValueString()))
+ }
+
+ if !m.UpdatePolicy.Equal(current.UpdatePolicy) {
+ updaters = append(updaters, mittwaldv2.UpdateAppInstallationUpdatePolicy(mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(m.UpdatePolicy.ValueString())))
+ }
+
+ if !m.Dependencies.Equal(current.Dependencies) {
+ depUpdater := providerutil.
+ Try[mittwaldv2.AppInstallationUpdater](&d, "error while building dependency updaters").
+ DoVal(m.dependenciesToUpdater(ctx, appClient, ¤t.Dependencies))
+ updaters = append(updaters, depUpdater)
+ }
+
+ return updaters
+}
+
+func (m *ResourceModel) FromAPIModel(ctx context.Context, appInstallation *mittwaldv2.DeMittwaldV1AppAppInstallation, appClient mittwaldv2.AppClient) (res diag.Diagnostics) {
+ appDesiredVersion := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1AppAppVersion](&res, "error while fetching app version").
+ DoVal(appClient.GetAppVersion(ctx, appInstallation.AppId.String(), appInstallation.AppVersion.Desired))
+
+ if res.HasError() {
+ return
+ }
+
+ m.ProjectID = types.StringValue(appInstallation.ProjectId.String())
+ m.InstallationPath = types.StringValue(appInstallation.InstallationPath)
+ m.App = func() types.String {
+ for key, appID := range appNames {
+ if appID == appInstallation.AppId.String() {
+ return types.StringValue(key)
+ }
+ }
+ return types.StringNull()
+ }()
+
+ m.DocumentRoot = valueutil.StringPtrOrNull(appInstallation.CustomDocumentRoot)
+ m.Description = valueutil.StringOrNull(appInstallation.Description)
+ m.Version = types.StringValue(appDesiredVersion.InternalVersion)
+ m.UpdatePolicy = valueutil.StringPtrOrNull(appInstallation.UpdatePolicy)
+
+ if appInstallation.LinkedDatabases != nil {
+ databases := make([]DatabaseModel, 0)
+ for _, db := range *appInstallation.LinkedDatabases {
+ model := DatabaseModel{
+ ID: types.StringValue(db.DatabaseId.String()),
+ Kind: types.StringValue(string(db.Kind)),
+ Purpose: types.StringValue(string(db.Purpose)),
+ }
+
+ if db.DatabaseUserIds != nil {
+ userID, ok := (*db.DatabaseUserIds)["admin"]
+ if ok {
+ model.UserID = types.StringValue(userID)
+ }
+ }
+
+ databases = append(databases, model)
+ }
+
+ databaseModels, d := types.SetValueFrom(ctx, &databaseModelAttrType, databases)
+ res.Append(d...)
+
+ m.Databases = databaseModels
+ } else {
+ m.Databases = types.SetNull(&databaseModelAttrType)
+ }
+
+ if appInstallation.AppVersion.Current != nil {
+ appCurrentVersion := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1AppAppVersion](&res, "error while fetching app version").
+ DoVal(appClient.GetAppVersion(ctx, appInstallation.AppId.String(), *appInstallation.AppVersion.Current))
+ if appCurrentVersion != nil {
+ m.VersionCurrent = types.StringValue(appCurrentVersion.InternalVersion)
+ }
+ }
+
+ if appInstallation.SystemSoftware != nil {
+ m.Dependencies = InstalledSystemSoftwareToDependencyModelMap(ctx, res, appClient, *appInstallation.SystemSoftware)
+ }
+
+ return
+}
+
+func (m *ResourceModel) dependenciesToUpdater(ctx context.Context, appClient mittwaldv2.AppClient, currentDependencies *types.Map) (mittwaldv2.AppInstallationUpdater, error) {
+ updater := make(mittwaldv2.AppInstallationUpdaterChain, 0)
+ seen := make(map[string]struct{})
+
+ for name, options := range m.Dependencies.Elements() {
+ seen[name] = struct{}{}
+
+ dependency, ok, err := appClient.GetSystemSoftwareByName(ctx, name)
+ if err != nil {
+ return nil, err
+ } else if !ok {
+ return nil, fmt.Errorf("dependency %s not found", name)
+ }
+
+ optionsObj, ok := options.(types.Object)
+ if !ok {
+ return nil, fmt.Errorf("expected types.Object, got %T", options)
+ }
+
+ optionsModel := DependencyModel{}
+ optionsObj.As(ctx, &optionsModel, basetypes.ObjectAsOptions{})
+
+ versions, err := appClient.SelectSystemSoftwareVersion(ctx, dependency.Id, optionsModel.Version.ValueString())
+ if err != nil {
+ return nil, err
+ }
+
+ recommended, ok := versions.Recommended()
+ if !ok {
+ return nil, fmt.Errorf("no recommended version found for %s", name)
+ }
+
+ updater = append(
+ updater,
+ mittwaldv2.UpdateAppInstallationSystemSoftware(
+ dependency.Id,
+ recommended.Id.String(),
+ mittwaldv2.DeMittwaldV1AppSystemSoftwareUpdatePolicy(optionsModel.UpdatePolicy.ValueString()),
+ ),
+ )
+ }
+
+ if currentDependencies != nil {
+ for name := range currentDependencies.Elements() {
+ if _, ok := seen[name]; !ok {
+ dependency, ok, err := appClient.GetSystemSoftwareByName(ctx, name)
+ if err != nil {
+ return nil, err
+ } else if !ok {
+ return nil, fmt.Errorf("dependency %s not found", name)
+ }
+
+ updater = append(updater, mittwaldv2.RemoveAppInstallationSystemSoftware(dependency.Id))
+ }
+ }
+ }
+
+ return updater, nil
+}
diff --git a/internal/provider/resource/appresource/model_database.go b/internal/provider/resource/appresource/model_database.go
new file mode 100644
index 0000000..a5072b4
--- /dev/null
+++ b/internal/provider/resource/appresource/model_database.go
@@ -0,0 +1,29 @@
+package appresource
+
+import (
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+var databaseModelAttrType = types.ObjectType{
+ AttrTypes: map[string]attr.Type{
+ "id": types.StringType,
+ "kind": types.StringType,
+ "user_id": types.StringType,
+ "purpose": types.StringType,
+ },
+}
+
+type DatabaseModel struct {
+ ID types.String `tfsdk:"id"`
+ Kind types.String `tfsdk:"kind"`
+ UserID types.String `tfsdk:"user_id"`
+ Purpose types.String `tfsdk:"purpose"`
+}
+
+func (m *DatabaseModel) Equals(other *DatabaseModel) bool {
+ return m.ID.Equal(other.ID) &&
+ m.Kind.Equal(other.Kind) &&
+ m.UserID.Equal(other.UserID) &&
+ m.Purpose.Equal(other.Purpose)
+}
diff --git a/internal/provider/resource/appresource/resource.go b/internal/provider/resource/appresource/resource.go
index 0f9d42b..0e5da04 100644
--- a/internal/provider/resource/appresource/resource.go
+++ b/internal/provider/resource/appresource/resource.go
@@ -2,7 +2,6 @@ package appresource
import (
"context"
- "fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
@@ -10,10 +9,9 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
- "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
"github.com/mittwald/terraform-provider-mittwald/internal/provider/providerutil"
- "github.com/mittwald/terraform-provider-mittwald/internal/valueutil"
)
// Ensure provider defined types fully satisfy framework interfaces.
@@ -60,9 +58,29 @@ func (r *Resource) Schema(_ context.Context, _ resource.SchemaRequest, resp *res
stringplanmodifier.RequiresReplace(),
},
},
- "database_id": schema.StringAttribute{
- MarkdownDescription: "The ID of the database the app uses",
+ "databases": schema.SetNestedAttribute{
+ MarkdownDescription: "The databases the app uses",
Optional: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ MarkdownDescription: "The ID of the database",
+ Required: true,
+ },
+ "user_id": schema.StringAttribute{
+ MarkdownDescription: "The ID of the database user that the app should use",
+ Required: true,
+ },
+ "purpose": schema.StringAttribute{
+ MarkdownDescription: "The purpose of the database; use 'primary' for the primary data storage, or 'cache' for a cache database",
+ Required: true,
+ },
+ "kind": schema.StringAttribute{
+ MarkdownDescription: "The kind of the database; one of `mysql` or `redis`",
+ Required: true,
+ },
+ },
+ },
},
"app": schema.StringAttribute{
MarkdownDescription: "The name of the app",
@@ -132,127 +150,47 @@ func (r *Resource) Configure(_ context.Context, req resource.ConfigureRequest, r
func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
data := ResourceModel{}
+ databases := make([]DatabaseModel, 0)
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
-
- appID, ok := appNames[data.App.ValueString()]
- if !ok {
- resp.Diagnostics.AddError("app", "App not found")
- return
- }
+ resp.Diagnostics.Append(data.Databases.ElementsAs(ctx, &databases, false)...)
appClient := r.client.App()
- appInput := mittwaldv2.AppRequestAppinstallationJSONRequestBody{
- Description: data.Description.ValueString(),
- UpdatePolicy: mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(data.UpdatePolicy.ValueString()),
- }
-
- appVersions := providerutil.ErrorValueToDiag(appClient.ListAppVersions(ctx, appID))(&resp.Diagnostics, "API Error")
- for _, appVersion := range appVersions {
- if appVersion.InternalVersion == data.Version.ValueString() {
- appInput.AppVersionId = appVersion.Id
- }
- }
-
- for key, value := range data.UserInputs.Elements() {
- appInput.UserInputs = append(appInput.UserInputs, mittwaldv2.DeMittwaldV1AppSavedUserInput{
- Name: key,
- Value: value.String(),
- })
- }
+ appInput, appUpdaters := data.ToCreateRequestWithUpdaters(ctx, resp.Diagnostics, appClient)
if resp.Diagnostics.HasError() {
return
}
- appID, err := appClient.RequestAppInstallation(ctx, data.ProjectID.ValueString(), appInput)
- if err != nil {
- resp.Diagnostics.AddError("API Error", err.Error())
- return
- }
-
- data.ID = types.StringValue(appID)
-
- updaters := make([]mittwaldv2.AppInstallationUpdater, 0)
-
- if !data.DocumentRoot.IsNull() {
- updaters = append(updaters, mittwaldv2.UpdateAppInstallationDocumentRoot(data.DocumentRoot.ValueString()))
- }
-
- if !data.UpdatePolicy.IsNull() {
- updaters = append(updaters, mittwaldv2.UpdateAppInstallationUpdatePolicy(mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(data.UpdatePolicy.ValueString())))
- }
-
- if !data.Dependencies.IsNull() {
- depUpdater := providerutil.ErrorValueToDiag(r.appDependenciesToUpdater(ctx, &data))(&resp.Diagnostics, "Dependency version error")
- updaters = append(updaters, depUpdater)
- }
+ installationID := providerutil.
+ Try[string](&resp.Diagnostics, "error while requesting app installation").
+ DoVal(appClient.RequestAppInstallation(ctx, data.ProjectID.ValueString(), appInput))
if resp.Diagnostics.HasError() {
return
}
- if len(updaters) > 0 {
- providerutil.ErrorToDiag(appClient.UpdateAppInstallation(ctx, data.ID.ValueString(), updaters...))(&resp.Diagnostics, "API Error")
- }
+ data.ID = types.StringValue(installationID)
- if !data.DatabaseID.IsNull() {
- providerutil.ErrorToDiag(appClient.LinkAppInstallationToDatabase(
+ try := providerutil.Try[any](&resp.Diagnostics, "error while updating app installation")
+ try.Do(appClient.UpdateAppInstallation(ctx, installationID, appUpdaters...))
+
+ for _, database := range databases {
+ try.Do(appClient.LinkAppInstallationToDatabase(
ctx,
data.ID.ValueString(),
- data.DatabaseID.ValueString(),
+ database.ID.ValueString(),
+ database.UserID.ValueString(),
mittwaldv2.AppLinkDatabaseJSONBodyPurposePrimary,
- ))(&resp.Diagnostics, "API Error")
+ ))
}
- providerutil.ErrorToDiag(appClient.WaitUntilAppInstallationIsReady(ctx, appID))(&resp.Diagnostics, "API Error")
+ try.Do(appClient.WaitUntilAppInstallationIsReady(ctx, installationID))
resp.Diagnostics.Append(r.read(ctx, &data)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
-func (r *Resource) appDependenciesToUpdater(ctx context.Context, d *ResourceModel) (mittwaldv2.AppInstallationUpdater, error) {
- appClient := r.client.App()
- updater := make(mittwaldv2.AppInstallationUpdaterChain, 0)
- for name, options := range d.Dependencies.Elements() {
- dependency, ok, err := appClient.GetSystemSoftwareByName(ctx, name)
- if err != nil {
- return nil, err
- } else if !ok {
- return nil, fmt.Errorf("dependency %s not found", name)
- }
-
- optionsObj, ok := options.(types.Object)
- if !ok {
- return nil, fmt.Errorf("expected types.Object, got %T", options)
- }
-
- optionsModel := DependencyModel{}
- optionsObj.As(ctx, &optionsModel, basetypes.ObjectAsOptions{})
-
- versions, err := appClient.SelectSystemSoftwareVersion(ctx, dependency.Id, optionsModel.Version.ValueString())
- if err != nil {
- return nil, err
- }
-
- recommended, ok := versions.Recommended()
- if !ok {
- return nil, fmt.Errorf("no recommended version found for %s", name)
- }
-
- updater = append(
- updater,
- mittwaldv2.UpdateAppInstallationSystemSoftware(
- dependency.Id,
- recommended.Id.String(),
- mittwaldv2.DeMittwaldV1AppSystemSoftwareUpdatePolicy(optionsModel.UpdatePolicy.ValueString()),
- ),
- )
- }
-
- return updater, nil
-}
-
func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
data := ResourceModel{}
@@ -269,60 +207,20 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res
func (r *Resource) read(ctx context.Context, data *ResourceModel) (res diag.Diagnostics) {
appClient := r.client.App()
- appInstallation := providerutil.ErrorValueToDiag(appClient.GetAppInstallation(ctx, data.ID.ValueString()))(&res, "API Error")
- if res.HasError() {
- return
- }
+ appInstallation := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1AppAppInstallation](&res, "error while fetching app installation").
+ DoVal(appClient.GetAppInstallation(ctx, data.ID.ValueString()))
- appDesiredVersion := providerutil.ErrorValueToDiag(appClient.GetAppVersion(ctx, appInstallation.AppId.String(), appInstallation.AppVersion.Desired))(&res, "API Error")
if res.HasError() {
return
}
- data.ProjectID = types.StringValue(appInstallation.ProjectId.String())
- data.InstallationPath = types.StringValue(appInstallation.InstallationPath)
- data.App = func() types.String {
- for key, appID := range appNames {
- if appID == appInstallation.AppId.String() {
- return types.StringValue(key)
- }
- }
- return types.StringNull()
- }()
-
- data.DocumentRoot = valueutil.StringPtrOrNull(appInstallation.CustomDocumentRoot)
- data.Description = valueutil.StringOrNull(appInstallation.Description)
- data.Version = types.StringValue(appDesiredVersion.InternalVersion)
- data.UpdatePolicy = valueutil.StringPtrOrNull(appInstallation.UpdatePolicy)
-
- data.DatabaseID = func() types.String {
- if appInstallation.LinkedDatabases == nil {
- return types.StringNull()
- }
-
- for _, link := range *appInstallation.LinkedDatabases {
- if link.Purpose == "primary" {
- return types.StringValue(link.DatabaseId.String())
- }
- }
- return types.StringNull()
- }()
-
- if appInstallation.AppVersion.Current != nil {
- if appDesiredVersion := providerutil.ErrorValueToDiag(appClient.GetAppVersion(ctx, appInstallation.AppId.String(), appInstallation.AppVersion.Desired))(&res, "API Error"); appDesiredVersion != nil {
- data.VersionCurrent = types.StringValue(appDesiredVersion.InternalVersion)
- }
- }
-
- if appInstallation.SystemSoftware != nil {
- data.Dependencies = InstalledSystemSoftwareToDependencyModelMap(ctx, res, appClient, *appInstallation.SystemSoftware)
- }
+ res.Append(data.FromAPIModel(ctx, appInstallation, appClient)...)
return
}
func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
- updaters := make([]mittwaldv2.AppInstallationUpdater, 0)
planData := ResourceModel{}
currentData := ResourceModel{}
@@ -330,26 +228,60 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
resp.Diagnostics.Append(req.State.Get(ctx, ¤tData)...)
appClient := r.client.App()
+ updaters := planData.ToUpdateUpdaters(ctx, resp.Diagnostics, ¤tData, appClient)
- if !planData.DocumentRoot.Equal(currentData.DocumentRoot) {
- updaters = append(updaters, mittwaldv2.UpdateAppInstallationDocumentRoot(planData.DocumentRoot.ValueString()))
- }
+ try := providerutil.Try[any](&resp.Diagnostics, "error while updating app installation")
+ try.Do(appClient.UpdateAppInstallation(ctx, planData.ID.ValueString(), updaters...))
+
+ linkedDatabasesInState := make([]DatabaseModel, 0)
+ linkedDatabasesInPlan := make([]DatabaseModel, 0)
- if !planData.UpdatePolicy.Equal(currentData.UpdatePolicy) {
- updaters = append(updaters, mittwaldv2.UpdateAppInstallationUpdatePolicy(mittwaldv2.DeMittwaldV1AppAppUpdatePolicy(planData.UpdatePolicy.ValueString())))
+ resp.Diagnostics.Append(planData.Databases.ElementsAs(ctx, &linkedDatabasesInPlan, false)...)
+ resp.Diagnostics.Append(currentData.Databases.ElementsAs(ctx, &linkedDatabasesInState, false)...)
+
+ linkedDatabasesInStateByID := make(map[string]DatabaseModel)
+ linkedDatabasesInPlanByID := make(map[string]DatabaseModel)
+
+ for _, database := range linkedDatabasesInState {
+ linkedDatabasesInStateByID[database.ID.ValueString()] = database
}
- if len(updaters) > 0 {
- providerutil.ErrorToDiag(appClient.UpdateAppInstallation(ctx, planData.ID.ValueString(), updaters...))(&resp.Diagnostics, "API Error")
+ for _, database := range linkedDatabasesInPlan {
+ linkedDatabasesInPlanByID[database.ID.ValueString()] = database
+
+ existing, exists := linkedDatabasesInStateByID[database.ID.ValueString()]
+ if exists && !existing.Equals(&database) {
+ tflog.Debug(ctx, "database link changed; dropping", map[string]any{"database_id": database.ID.String()})
+ try.Do(appClient.UnlinkAppInstallationFromDatabase(
+ ctx,
+ planData.ID.ValueString(),
+ database.ID.ValueString(),
+ ))
+ exists = false
+ }
+
+ if !exists {
+ tflog.Debug(ctx, "creating database link", map[string]any{"database_id": database.ID.String()})
+ try.Do(appClient.LinkAppInstallationToDatabase(
+ ctx,
+ planData.ID.ValueString(),
+ database.ID.ValueString(),
+ database.UserID.ValueString(),
+ mittwaldv2.AppLinkDatabaseJSONBodyPurpose(database.Purpose.ValueString()),
+ ))
+ }
}
- if !planData.DatabaseID.Equal(currentData.DatabaseID) {
- providerutil.ErrorToDiag(appClient.LinkAppInstallationToDatabase(
- ctx,
- planData.ID.ValueString(),
- planData.DatabaseID.ValueString(),
- mittwaldv2.AppLinkDatabaseJSONBodyPurposePrimary,
- ))(&resp.Diagnostics, "API Error")
+ for _, database := range linkedDatabasesInState {
+ _, planned := linkedDatabasesInPlanByID[database.ID.ValueString()]
+ if !planned {
+ tflog.Debug(ctx, "dropping database link", map[string]any{"database_id": database.ID.String()})
+ try.Do(appClient.UnlinkAppInstallationFromDatabase(
+ ctx,
+ planData.ID.ValueString(),
+ database.ID.ValueString(),
+ ))
+ }
}
resp.Diagnostics.Append(r.read(ctx, &planData)...)
@@ -360,12 +292,13 @@ func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp
var data ResourceModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
-
if resp.Diagnostics.HasError() {
return
}
- providerutil.ErrorToDiag(r.client.App().UninstallApp(ctx, data.ID.ValueString()))(&resp.Diagnostics, "API Error")
+ providerutil.
+ Try[any](&resp.Diagnostics, "error while uninstalling app").
+ Do(r.client.App().UninstallApp(ctx, data.ID.ValueString()))
}
func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
diff --git a/internal/provider/resource/cronjobresource/model.go b/internal/provider/resource/cronjobresource/model.go
index 7717847..4a9c75c 100644
--- a/internal/provider/resource/cronjobresource/model.go
+++ b/internal/provider/resource/cronjobresource/model.go
@@ -34,13 +34,13 @@ type ResourceDestinationCommandModel struct {
Parameters types.List `tfsdk:"parameters"`
}
-func (m *ResourceModel) GetDestination(ctx context.Context, d diag.Diagnostics) *ResourceDestinationModel {
+func (m *ResourceModel) GetDestination(ctx context.Context, d *diag.Diagnostics) *ResourceDestinationModel {
out := ResourceDestinationModel{}
d.Append(m.Destination.As(ctx, &out, basetypes.ObjectAsOptions{})...)
return &out
}
-func (m *ResourceDestinationModel) GetURL(ctx context.Context, d diag.Diagnostics) (ResourceDestinationURLModel, bool) {
+func (m *ResourceDestinationModel) GetURL(ctx context.Context, d *diag.Diagnostics) (ResourceDestinationURLModel, bool) {
if m == nil {
return "", false
}
@@ -52,7 +52,7 @@ func (m *ResourceDestinationModel) GetURL(ctx context.Context, d diag.Diagnostic
return "", false
}
-func (m *ResourceDestinationModel) GetCommand(ctx context.Context, d diag.Diagnostics) (*ResourceDestinationCommandModel, bool) {
+func (m *ResourceDestinationModel) GetCommand(ctx context.Context, d *diag.Diagnostics) (*ResourceDestinationCommandModel, bool) {
if m == nil {
return nil, false
}
@@ -66,7 +66,7 @@ func (m *ResourceDestinationModel) GetCommand(ctx context.Context, d diag.Diagno
return nil, false
}
-func (m *ResourceDestinationModel) AsObject(ctx context.Context, d diag.Diagnostics) types.Object {
+func (m *ResourceDestinationModel) AsObject(ctx context.Context, d *diag.Diagnostics) types.Object {
obj, d2 := types.ObjectValueFrom(ctx, map[string]attr.Type{
"url": types.StringType,
"command": types.ObjectType{
@@ -125,7 +125,7 @@ func (m *ResourceDestinationCommandModel) AsAPIModel() mittwaldv2.DeMittwaldV1Cr
}
}
-func (m *ResourceDestinationCommandModel) AsDestinationModel(ctx context.Context, diag diag.Diagnostics) *ResourceDestinationModel {
+func (m *ResourceDestinationCommandModel) AsDestinationModel(ctx context.Context, diag *diag.Diagnostics) *ResourceDestinationModel {
value, d := types.ObjectValueFrom(ctx, map[string]attr.Type{
"interpreter": types.StringType,
"path": types.StringType,
diff --git a/internal/provider/resource/cronjobresource/model_api.go b/internal/provider/resource/cronjobresource/model_api.go
index d46f3ea..7c32c28 100644
--- a/internal/provider/resource/cronjobresource/model_api.go
+++ b/internal/provider/resource/cronjobresource/model_api.go
@@ -30,27 +30,27 @@ func (m *ResourceModel) FromAPIModel(ctx context.Context, apiModel *mittwaldv2.D
}
if asURL.Url != "" {
- m.Destination = ResourceDestinationURLModel(asURL.Url).AsDestinationModel().AsObject(ctx, res)
+ m.Destination = ResourceDestinationURLModel(asURL.Url).AsDestinationModel().AsObject(ctx, &res)
} else {
cmdModel := ResourceDestinationCommandModel{}
res.Append(cmdModel.FromAPIModel(ctx, &asCommand)...)
- m.Destination = cmdModel.AsDestinationModel(ctx, res).AsObject(ctx, res)
+ m.Destination = cmdModel.AsDestinationModel(ctx, &res).AsObject(ctx, &res)
}
return
}
-func (m *ResourceModel) ToCreateRequest(ctx context.Context, d diag.Diagnostics) mittwaldv2.CronjobCreateCronjobJSONRequestBody {
+func (m *ResourceModel) ToCreateRequest(ctx context.Context, d *diag.Diagnostics) mittwaldv2.CronjobCreateCronjobJSONRequestBody {
createCronjobBody := mittwaldv2.CronjobCreateCronjobJSONRequestBody{
Description: m.Description.ValueString(),
Active: true,
Interval: m.Interval.ValueString(),
- AppId: providerutil.ParseUUID(m.AppID.ValueString(), &d),
+ AppId: providerutil.ParseUUID(m.AppID.ValueString(), d),
Destination: mittwaldv2.DeMittwaldV1CronjobCronjobRequest_Destination{},
}
- try := providerutil.Try[any](&d, "Mapping error while building cron job request")
+ try := providerutil.Try[any](d, "Mapping error while building cron job request")
dest := m.GetDestination(ctx, d)
if url, ok := dest.GetURL(ctx, d); ok {
@@ -69,9 +69,9 @@ func (m *ResourceModel) ToCreateRequest(ctx context.Context, d diag.Diagnostics)
return createCronjobBody
}
-func (m *ResourceModel) ToUpdateRequest(ctx context.Context, d diag.Diagnostics, current *ResourceModel) mittwaldv2.CronjobUpdateCronjobJSONRequestBody {
+func (m *ResourceModel) ToUpdateRequest(ctx context.Context, d *diag.Diagnostics, current *ResourceModel) mittwaldv2.CronjobUpdateCronjobJSONRequestBody {
body := mittwaldv2.CronjobUpdateCronjobJSONRequestBody{}
- try := providerutil.Try[any](&d, "Mapping error while building cron job request")
+ try := providerutil.Try[any](d, "Mapping error while building cron job request")
if !m.Description.Equal(current.Description) && !m.Description.IsNull() {
body.Description = ptrutil.To(m.Description.ValueString())
@@ -118,6 +118,8 @@ func (m *ResourceDestinationCommandModel) FromAPIModel(ctx context.Context, apiM
res.Append(d...)
m.Parameters = params
+ } else {
+ m.Parameters = types.ListNull(types.StringType)
}
return
diff --git a/internal/provider/resource/cronjobresource/resource.go b/internal/provider/resource/cronjobresource/resource.go
index e21f04a..a45c958 100644
--- a/internal/provider/resource/cronjobresource/resource.go
+++ b/internal/provider/resource/cronjobresource/resource.go
@@ -81,15 +81,13 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
- createCronjobBody := data.ToCreateRequest(ctx, resp.Diagnostics)
-
if resp.Diagnostics.HasError() {
return
}
id := providerutil.
Try[string](&resp.Diagnostics, "API error while updating cron job").
- DoVal(r.client.Cronjob().CreateCronjob(ctx, data.ProjectID.ValueString(), createCronjobBody))
+ DoVal(r.client.Cronjob().CreateCronjob(ctx, data.ProjectID.ValueString(), data.ToCreateRequest(ctx, &resp.Diagnostics)))
if resp.Diagnostics.HasError() {
return
@@ -110,6 +108,7 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res
}
resp.Diagnostics.Append(r.read(ctx, &data)...)
+ resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *Resource) read(ctx context.Context, data *ResourceModel) (res diag.Diagnostics) {
@@ -132,7 +131,7 @@ func (r *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
resp.Diagnostics.Append(req.State.Get(ctx, &stateData)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...)
- body := planData.ToUpdateRequest(ctx, resp.Diagnostics, &stateData)
+ body := planData.ToUpdateRequest(ctx, &resp.Diagnostics, &stateData)
if resp.Diagnostics.HasError() {
return
}
diff --git a/internal/provider/resource/mysqldatabaseresource/model_api.go b/internal/provider/resource/mysqldatabaseresource/model_api.go
new file mode 100644
index 0000000..c579ffc
--- /dev/null
+++ b/internal/provider/resource/mysqldatabaseresource/model_api.go
@@ -0,0 +1,90 @@
+package mysqldatabaseresource
+
+import (
+ "context"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
+)
+
+func (m *ResourceModel) ToCreateRequest(ctx context.Context, d diag.Diagnostics) mittwaldv2.DatabaseCreateMysqlDatabaseJSONRequestBody {
+ dataCharset := MySQLDatabaseCharsetModel{}
+ dataUser := MySQLDatabaseUserModel{}
+
+ d.Append(m.CharacterSettings.As(ctx, &dataCharset, basetypes.ObjectAsOptions{})...)
+ d.Append(m.User.As(ctx, &dataUser, basetypes.ObjectAsOptions{})...)
+
+ return mittwaldv2.DatabaseCreateMysqlDatabaseJSONRequestBody{
+ Database: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlDatabase{
+ Description: m.Description.ValueString(),
+ Version: m.Version.ValueString(),
+ CharacterSettings: &mittwaldv2.DeMittwaldV1DatabaseCharacterSettings{
+ CharacterSet: dataCharset.Charset.ValueString(),
+ Collation: dataCharset.Collation.ValueString(),
+ },
+ },
+ User: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlUserWithDatabase{
+ Password: dataUser.Password.ValueString(),
+ AccessLevel: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlUserWithDatabaseAccessLevel(dataUser.AccessLevel.ValueString()),
+ },
+ }
+}
+
+func (m *ResourceModel) Reset() {
+ m.Name = types.StringNull()
+ m.Hostname = types.StringNull()
+ m.Description = types.StringNull()
+ m.Version = types.StringNull()
+ m.ProjectID = types.StringNull()
+ m.CharacterSettings = types.ObjectNull(charsetAttrs)
+ m.User = types.ObjectNull(userAttrs)
+}
+
+func (m *ResourceModel) FromAPIModel(ctx context.Context, apiDatabase *mittwaldv2.DeMittwaldV1DatabaseMySqlDatabase, apiUser *mittwaldv2.DeMittwaldV1DatabaseMySqlUser) (res diag.Diagnostics) {
+ if apiDatabase == nil {
+ m.Reset()
+ return
+ }
+
+ characterSet := MySQLDatabaseCharsetModel{}
+ user := MySQLDatabaseUserModel{}
+
+ if !m.CharacterSettings.IsNull() {
+ res.Append(m.CharacterSettings.As(ctx, &characterSet, basetypes.ObjectAsOptions{})...)
+ }
+
+ if !m.User.IsNull() {
+ res.Append(m.User.As(ctx, &user, basetypes.ObjectAsOptions{})...)
+ }
+
+ m.Name = types.StringValue(apiDatabase.Name)
+ m.Hostname = types.StringValue(apiDatabase.Hostname)
+ m.Description = types.StringValue(apiDatabase.Description)
+ m.Version = types.StringValue(apiDatabase.Version)
+ m.ProjectID = types.StringValue(apiDatabase.ProjectId.String())
+
+ characterSet.FromAPIModel(&apiDatabase.CharacterSettings)
+ m.CharacterSettings = characterSet.AsObject(ctx, res)
+
+ if apiUser != nil {
+ user.FromAPIModel(apiUser)
+ m.User = user.AsObject(ctx, res)
+ } else {
+ m.User = types.ObjectNull(userAttrs)
+ }
+
+ return
+}
+
+func (m *MySQLDatabaseCharsetModel) FromAPIModel(apiCharset *mittwaldv2.DeMittwaldV1DatabaseCharacterSettings) {
+ m.Charset = types.StringValue(apiCharset.CharacterSet)
+ m.Collation = types.StringValue(apiCharset.Collation)
+}
+
+func (m *MySQLDatabaseUserModel) FromAPIModel(apiUser *mittwaldv2.DeMittwaldV1DatabaseMySqlUser) {
+ m.ID = types.StringValue(apiUser.Id.String())
+ m.Name = types.StringValue(apiUser.Name)
+ m.AccessLevel = types.StringValue(string(apiUser.AccessLevel))
+ m.ExternalAccess = types.BoolValue(apiUser.ExternalAccess)
+}
diff --git a/internal/provider/resource/mysqldatabaseresource/model_value.go b/internal/provider/resource/mysqldatabaseresource/model_value.go
new file mode 100644
index 0000000..7ff934a
--- /dev/null
+++ b/internal/provider/resource/mysqldatabaseresource/model_value.go
@@ -0,0 +1,35 @@
+package mysqldatabaseresource
+
+import (
+ "context"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+var charsetAttrs = map[string]attr.Type{
+ "character_set": types.StringType,
+ "collation": types.StringType,
+}
+
+var userAttrs = map[string]attr.Type{
+ "id": types.StringType,
+ "name": types.StringType,
+ "password": types.StringType,
+ "access_level": types.StringType,
+ "external_access": types.BoolType,
+}
+
+func (m *MySQLDatabaseCharsetModel) AsObject(ctx context.Context, diag diag.Diagnostics) types.Object {
+ val, d := types.ObjectValueFrom(ctx, charsetAttrs, m)
+ diag.Append(d...)
+
+ return val
+}
+
+func (m *MySQLDatabaseUserModel) AsObject(ctx context.Context, diag diag.Diagnostics) types.Object {
+ val, d := types.ObjectValueFrom(ctx, userAttrs, m)
+ diag.Append(d...)
+
+ return val
+}
diff --git a/internal/provider/resource/mysqldatabaseresource/resource.go b/internal/provider/resource/mysqldatabaseresource/resource.go
index a2cf191..034e3df 100644
--- a/internal/provider/resource/mysqldatabaseresource/resource.go
+++ b/internal/provider/resource/mysqldatabaseresource/resource.go
@@ -2,6 +2,7 @@ package mysqldatabaseresource
import (
"context"
+ "fmt"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
@@ -10,8 +11,10 @@ import (
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
"github.com/mittwald/terraform-provider-mittwald/internal/provider/providerutil"
+ "time"
)
// Ensure provider defined types fully satisfy framework interfaces.
@@ -136,29 +139,11 @@ func (d *Resource) Create(ctx context.Context, req resource.CreateRequest, resp
resp.Diagnostics.Append(data.User.As(ctx, &dataUser, basetypes.ObjectAsOptions{})...)
resp.Diagnostics.Append(data.CharacterSettings.As(ctx, &dataCharset, basetypes.ObjectAsOptions{})...)
- if data.ProjectID.IsNull() {
- resp.Diagnostics.AddError("Invalid Input", "project_id is required")
- return
- }
-
if resp.Diagnostics.HasError() {
return
}
- createReq := mittwaldv2.DatabaseCreateMysqlDatabaseJSONRequestBody{
- Database: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlDatabase{
- Description: data.Description.ValueString(),
- Version: data.Version.ValueString(),
- CharacterSettings: &mittwaldv2.DeMittwaldV1DatabaseCharacterSettings{
- CharacterSet: dataCharset.Charset.ValueString(),
- Collation: dataCharset.Collation.ValueString(),
- },
- },
- User: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlUserWithDatabase{
- Password: dataUser.Password.ValueString(),
- AccessLevel: mittwaldv2.DeMittwaldV1DatabaseCreateMySqlUserWithDatabaseAccessLevel(dataUser.AccessLevel.ValueString()),
- },
- }
+ createReq := data.ToCreateRequest(ctx, resp.Diagnostics)
dbID, userID, err := d.client.Database().CreateMySQLDatabase(ctx, data.ProjectID.ValueString(), createReq)
if err != nil {
@@ -166,22 +151,17 @@ func (d *Resource) Create(ctx context.Context, req resource.CreateRequest, resp
return
}
- data.ID = types.StringValue(dbID)
dataUser.ID = types.StringValue(userID)
- resp.Diagnostics.Append(d.read(ctx, &data, &dataCharset)...)
- resp.Diagnostics.Append(d.readUser(ctx, dbID, &dataUser)...)
+ data.ID = types.StringValue(dbID)
+ data.User = dataUser.AsObject(ctx, resp.Diagnostics)
- // Save updated data into Terraform state
+ resp.Diagnostics.Append(d.read(ctx, &data)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user"), &dataUser)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("character_settings"), &dataCharset)...)
}
func (d *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
data := ResourceModel{}
- dataUser := MySQLDatabaseUserModel{}
- dataCharset := MySQLDatabaseCharsetModel{}
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
@@ -189,64 +169,72 @@ func (d *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res
return
}
- resp.Diagnostics.Append(d.read(ctx, &data, &dataCharset)...)
- resp.Diagnostics.Append(d.readUser(ctx, data.ID.ValueString(), &dataUser)...)
+ readCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
+ defer cancel()
+ resp.Diagnostics.Append(d.read(readCtx, &data)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user"), &dataUser)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("character_settings"), &dataCharset)...)
}
-func (d *Resource) read(ctx context.Context, data *ResourceModel, charset *MySQLDatabaseCharsetModel) (res diag.Diagnostics) {
- database, err := d.client.Database().PollMySQLDatabase(ctx, data.ID.ValueString())
- if err != nil {
- res.AddError("Client Error", err.Error())
+func (d *Resource) read(ctx context.Context, data *ResourceModel) (res diag.Diagnostics) {
+ client := d.client.Database()
+
+ dataUser := MySQLDatabaseUserModel{}
+
+ if !data.User.IsNull() {
+ res.Append(data.User.As(ctx, &dataUser, basetypes.ObjectAsOptions{})...)
+ }
+
+ if res.HasError() {
return
}
- data.Name = types.StringValue(database.Name)
- data.Hostname = types.StringValue(database.Hostname)
- data.Description = types.StringValue(database.Description)
- data.Version = types.StringValue(database.Version)
- data.ProjectID = types.StringValue(database.ProjectId.String())
+ database := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1DatabaseMySqlDatabase](&res, "error while reading database").
+ IgnoreNotFound().
+ DoVal(client.PollMySQLDatabase(ctx, data.ID.ValueString()))
+ databaseUser := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1DatabaseMySqlUser](&res, "error while reading database user").
+ DoVal(d.findDatabaseUser(ctx, data.ID.ValueString(), &dataUser))
- charset.Charset = types.StringValue(database.CharacterSettings.CharacterSet)
- charset.Collation = types.StringValue(database.CharacterSettings.Collation)
+ if res.HasError() {
+ return
+ }
+
+ res.Append(data.FromAPIModel(ctx, database, databaseUser)...)
return
}
-func (d *Resource) readUser(ctx context.Context, databaseID string, data *MySQLDatabaseUserModel) (res diag.Diagnostics) {
- if data.ID.IsNull() {
- databaseUserList, err := d.client.Database().PollMySQLUsersForDatabase(ctx, databaseID)
- if err != nil {
- res.AddError("Client Error", err.Error())
- return
- }
+func (d *Resource) findDatabaseUser(ctx context.Context, databaseID string, data *MySQLDatabaseUserModel) (*mittwaldv2.DeMittwaldV1DatabaseMySqlUser, error) {
+ client := d.client.Database()
- for _, user := range databaseUserList {
- if user.MainUser {
- data.ID = types.StringValue(user.Id.String())
- break
- }
- }
+ // This should be the regular case, in which we can simply look up the user by ID.
+ if !data.ID.IsNull() {
+ return client.PollMySQLUser(ctx, data.ID.ValueString())
}
- databaseUser, err := d.client.Database().PollMySQLUser(ctx, data.ID.ValueString())
+ // If the user ID is not set, we need to look up the user by database ID and check which one is the main user.
+ databaseUserList, err := client.PollMySQLUsersForDatabase(ctx, databaseID)
if err != nil {
- res.AddError("Client Error", err.Error())
- return
+ return nil, err
}
- data.Name = types.StringValue(databaseUser.Name)
- data.AccessLevel = types.StringValue(string(databaseUser.AccessLevel))
- data.ExternalAccess = types.BoolValue(databaseUser.ExternalAccess)
+ for _, user := range databaseUserList {
+ if user.MainUser {
+ data.ID = types.StringValue(user.Id.String())
+ return &user, nil
+ }
+ }
- return
+ return nil, fmt.Errorf("could not find main user for database %s", databaseID)
}
func (d *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var planData, stateData ResourceModel
+
+ client := d.client.Database()
+
dataUser := MySQLDatabaseUserModel{}
dataCharset := MySQLDatabaseCharsetModel{}
@@ -260,40 +248,36 @@ func (d *Resource) Update(ctx context.Context, req resource.UpdateRequest, resp
}
if !planData.Description.Equal(stateData.Description) {
- if err := d.client.Database().SetMySQLDatabaseDescription(ctx, planData.ID.ValueString(), planData.Description.ValueString()); err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
- return
- }
+ providerutil.
+ Try[any](&resp.Diagnostics, "error while updating database").
+ Do(client.SetMySQLDatabaseDescription(ctx, planData.ID.ValueString(), planData.Description.ValueString()))
}
- if err := d.client.Database().SetMySQLUserPassword(ctx, dataUser.ID.ValueString(), dataUser.Password.ValueString()); err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
- return
- }
+ providerutil.
+ Try[any](&resp.Diagnostics, "error while setting database user password").
+ Do(client.SetMySQLUserPassword(ctx, dataUser.ID.ValueString(), dataUser.Password.ValueString()))
resp.Diagnostics.Append(resp.State.Set(ctx, &planData)...)
- resp.Diagnostics.Append(d.read(ctx, &planData, &dataCharset)...)
- resp.Diagnostics.Append(d.readUser(ctx, planData.ID.ValueString(), &dataUser)...)
-
- //resp.Diagnostics.Append(resp.State.Set(ctx, &planData)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user"), &dataUser)...)
- resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("character_settings"), &dataCharset)...)
}
func (d *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data ResourceModel
- // Read Terraform prior state data into the model
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
- if err := d.client.Database().DeleteMySQLDatabase(ctx, data.ID.ValueString()); err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
+ if data.ID.IsNull() {
+ tflog.Debug(ctx, "database is null, skipping deletion")
return
}
+
+ providerutil.
+ Try[any](&resp.Diagnostics, "error while deleting database").
+ IgnoreNotFound().
+ Do(d.client.Database().DeleteMySQLDatabase(ctx, data.ID.ValueString()))
}
func (d *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
diff --git a/internal/provider/resource/projectresource/model_api.go b/internal/provider/resource/projectresource/model_api.go
new file mode 100644
index 0000000..08327e4
--- /dev/null
+++ b/internal/provider/resource/projectresource/model_api.go
@@ -0,0 +1,39 @@
+package projectresource
+
+import (
+ "context"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
+ "github.com/mittwald/terraform-provider-mittwald/internal/provider/providerutil"
+ "github.com/mittwald/terraform-provider-mittwald/internal/valueutil"
+)
+
+func (m *ResourceModel) Reset() {
+ m.ID = types.StringNull()
+ m.ServerID = types.StringNull()
+ m.Description = types.StringNull()
+ m.Directories = types.MapNull(types.StringType)
+ m.DefaultIPs = types.ListNull(types.StringType)
+}
+
+func (m *ResourceModel) FromAPIModel(ctx context.Context, project *mittwaldv2.DeMittwaldV1ProjectProject, ips []string) (res diag.Diagnostics) {
+ if project == nil {
+ m.Reset()
+ return
+ }
+
+ m.ID = types.StringValue(project.Id.String())
+ m.Description = types.StringValue(project.Description)
+ m.Directories = providerutil.EmbedDiag(types.MapValueFrom(ctx, types.StringType, project.Directories))(&res)
+ m.ServerID = valueutil.StringerOrNull(project.ServerId)
+ m.DefaultIPs = providerutil.EmbedDiag(types.ListValueFrom(ctx, types.StringType, ips))(&res)
+
+ return
+}
+
+func (m *ResourceModel) ToCreateRequest() mittwaldv2.ProjectCreateProjectJSONRequestBody {
+ return mittwaldv2.ProjectCreateProjectJSONRequestBody{
+ Description: m.Description.ValueString(),
+ }
+}
diff --git a/internal/provider/resource/projectresource/resource.go b/internal/provider/resource/projectresource/resource.go
index 4fba6bf..508e1c6 100644
--- a/internal/provider/resource/projectresource/resource.go
+++ b/internal/provider/resource/projectresource/resource.go
@@ -13,7 +13,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/mittwald/terraform-provider-mittwald/api/mittwaldv2"
"github.com/mittwald/terraform-provider-mittwald/internal/provider/providerutil"
- "github.com/mittwald/terraform-provider-mittwald/internal/valueutil"
+ "time"
)
// Ensure provider defined types fully satisfy framework interfaces.
@@ -80,7 +80,8 @@ func (r *Resource) Configure(_ context.Context, req resource.ConfigureRequest, r
func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data ResourceModel
- // Read Terraform plan data into the model
+ client := r.client.Project()
+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
resp.Diagnostics.Append(data.Validate()...)
@@ -88,16 +89,15 @@ func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp
return
}
- projectID, err := r.client.Project().CreateProjectOnServer(
- ctx,
- data.ServerID.ValueString(),
- mittwaldv2.ProjectCreateProjectJSONRequestBody{
- Description: data.Description.ValueString(),
- },
- )
+ projectID := providerutil.
+ Try[string](&resp.Diagnostics, "error while creating project").
+ DoVal(client.CreateProjectOnServer(
+ ctx,
+ data.ServerID.ValueString(),
+ data.ToCreateRequest(),
+ ))
- if err != nil {
- resp.Diagnostics.AddError("Client Error", err.Error())
+ if resp.Diagnostics.HasError() {
return
}
@@ -117,30 +117,25 @@ func (r *Resource) Read(ctx context.Context, req resource.ReadRequest, resp *res
return
}
- resp.Diagnostics.Append(r.read(ctx, &data)...)
+ readCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
+ defer cancel()
- // Save updated data into Terraform state
+ resp.Diagnostics.Append(r.read(readCtx, &data)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *Resource) read(ctx context.Context, data *ResourceModel) (res diag.Diagnostics) {
- project, err := r.client.Project().PollProject(ctx, data.ID.ValueString())
- if err != nil {
- res.AddError("API error while polling project", err.Error())
- return
- }
+ project := providerutil.
+ Try[*mittwaldv2.DeMittwaldV1ProjectProject](&res, "error while reading project").
+ IgnoreNotFound().
+ DoVal(r.client.Project().PollProject(ctx, data.ID.ValueString()))
- ips, err := r.client.Project().GetProjectDefaultIPs(ctx, data.ID.ValueString())
- if err != nil {
- res.AddError("API error while getting project ips", err.Error())
- return
- }
+ ips := providerutil.
+ Try[[]string](&res, "error while reading project ips").
+ IgnoreNotFound().
+ DoVal(r.client.Project().GetProjectDefaultIPs(ctx, data.ID.ValueString()))
- data.ID = types.StringValue(project.Id.String())
- data.Description = types.StringValue(project.Description)
- data.Directories = providerutil.EmbedDiag(types.MapValueFrom(ctx, types.StringType, project.Directories))(&res)
- data.ServerID = valueutil.StringerOrNull(project.ServerId)
- data.DefaultIPs = providerutil.EmbedDiag(types.ListValueFrom(ctx, types.StringType, ips))(&res)
+ res.Append(data.FromAPIModel(ctx, project, ips)...)
return
}
@@ -178,10 +173,10 @@ func (r *Resource) Delete(ctx context.Context, req resource.DeleteRequest, resp
return
}
- if err := r.client.Project().DeleteProject(ctx, data.ID.ValueString()); err != nil {
- resp.Diagnostics.AddError("Error while deleting project", err.Error())
- return
- }
+ providerutil.
+ Try[any](&resp.Diagnostics, "error while deleting project").
+ IgnoreNotFound().
+ Do(r.client.Project().DeleteProject(ctx, data.ID.ValueString()))
}
func (r *Resource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {