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