diff --git a/GNUmakefile b/GNUmakefile index 7771cd6..588afe0 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -4,3 +4,9 @@ default: testacc .PHONY: testacc testacc: TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m + +lint: + golangci-lint run ./... + +generate: + go generate ./... diff --git a/README.md b/README.md index ffa82e6..6d81e69 100644 --- a/README.md +++ b/README.md @@ -65,29 +65,34 @@ resource "aem_instance" "single" { } system { - data_dir = local.aem_single_compose_dir - bootstrap_script = < +### Nested Schema for `compose.create` + +Optional: + +- `inline` (List of String) Inline shell commands to be executed +- `script` (String) Multiline shell script to be executed + + + +### Nested Schema for `compose.delete` + +Optional: + +- `inline` (List of String) Inline shell commands to be executed +- `script` (String) Multiline shell script to be executed + + + +### Nested Schema for `compose.launch` + +Optional: + +- `inline` (List of String) Inline shell commands to be executed +- `script` (String) Multiline shell script to be executed + + ### Nested Schema for `system` Optional: -- `bootstrap_script` (String) Script executed once after connecting to the instance. Typically used for: providing AEM library files (quickstart.jar, license.properties, service packs), mounting data volume, etc. Forces instance recreation if changed. +- `bootstrap` (Attributes) Script executed once upon instance connection, often for mounting on VM data volumes from attached disks (e.g., AWS EBS, Azure Disk Storage). This script runs only once, even during instance recreation, as changes are typically persistent and system-wide. If re-execution is needed, it is recommended to set up a new VM. (see [below for nested schema](#nestedatt--system--bootstrap)) - `data_dir` (String) Remote root path in which AEM Compose files and unpacked instances will be stored - `env` (Map of String) Environment variables for AEM instances - `service_config` (String) Contents of the AEM 'systemd' service definition file - `user` (String) System user under which AEM instance will be running. By default, the same as the user used to connect to the machine. - `work_dir` (String) Remote root path in which AEM Compose TF provider temporary files will be stored + +### Nested Schema for `system.bootstrap` + +Optional: + +- `inline` (List of String) Inline shell commands to be executed +- `script` (String) Multiline shell script to be executed + + ### Nested Schema for `instances` diff --git a/examples/ssh/aem.tf b/examples/ssh/aem.tf index f62619a..206b1ac 100644 --- a/examples/ssh/aem.tf +++ b/examples/ssh/aem.tf @@ -15,29 +15,34 @@ resource "aem_instance" "single" { } system { - data_dir = local.aem_single_compose_dir - bootstrap_script = < 0 { // TODO rethink error handling + if len(out) > 0 { return nil, fmt.Errorf("cannot run command '%s': %w\n\n%s", cmdObj, err, string(out)) } return nil, err @@ -187,13 +190,6 @@ func (c Client) DirCopy(localPath string, remotePath string, override bool) erro return nil } -func (c Client) FileDelete(path string) error { - if _, err := c.RunShellPurely(fmt.Sprintf("rm -rf %s", path)); err != nil { - return fmt.Errorf("cannot delete file '%s': %w", path, err) - } - return nil -} - func (c Client) FileCopy(localPath string, remotePath string, override bool) error { if !override { exists, err := c.FileExists(remotePath) @@ -213,7 +209,11 @@ func (c Client) FileCopy(localPath string, remotePath string, override bool) err } else { remoteTmpPath = fmt.Sprintf("%s.tmp", remotePath) } - defer func() { _ = c.FileDelete(remoteTmpPath) }() + err := c.PathDelete(remoteTmpPath) + if err != nil { + return err + } + defer func() { _ = c.PathDelete(remoteTmpPath) }() if err := c.connection.CopyFile(localPath, remoteTmpPath); err != nil { return err } @@ -234,6 +234,13 @@ func (c Client) PathCopy(localPath string, remotePath string, override bool) err return c.FileCopy(localPath, remotePath, override) } +func (c Client) PathDelete(path string) error { + if _, err := c.RunShellPurely(fmt.Sprintf("rm -rf %s", path)); err != nil { + return fmt.Errorf("cannot delete file '%s': %w", path, err) + } + return nil +} + func (c Client) FileWrite(remotePath string, text string) error { file, err := os.CreateTemp(os.TempDir(), "tf-provider-aem-*.tmp") path := file.Name() diff --git a/internal/provider/instance/config.yml b/internal/provider/instance/aem.yml similarity index 100% rename from internal/provider/instance/config.yml rename to internal/provider/instance/aem.yml diff --git a/internal/provider/instance/consts.go b/internal/provider/instance/consts.go new file mode 100644 index 0000000..838a141 --- /dev/null +++ b/internal/provider/instance/consts.go @@ -0,0 +1,23 @@ +package instance + +import _ "embed" + +//go:embed aem.yml +var ConfigYML string + +//go:embed systemd.conf +var ServiceConf string + +var CreateScriptInline = []string{ + `sh aemw instance init`, + `sh aemw instance create`, +} + +var LaunchScriptInline = []string{ + `sh aemw osgi config save --pid 'org.apache.sling.jcr.davex.impl.servlets.SlingDavExServlet' --input-string 'alias: /crx/server'`, + `sh aemw repl agent setup -A --location 'author' --name 'publish' --input-string '{enabled: true, transportUri: "http://localhost:4503/bin/receive?sling:authRequestLogin=1", transportUser: admin, transportPassword: admin, userId: admin}'`, +} + +var DeleteScriptInline = []string{ + `sh aemw instance delete`, +} diff --git a/internal/provider/instance/create.sh b/internal/provider/instance/create.sh deleted file mode 100644 index 4de6a2e..0000000 --- a/internal/provider/instance/create.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh - -sh aemw instance init && \ -sh aemw instance create diff --git a/internal/provider/instance/delete.sh b/internal/provider/instance/delete.sh deleted file mode 100644 index 6a42454..0000000 --- a/internal/provider/instance/delete.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -sh aemw instance delete diff --git a/internal/provider/instance/embed.go b/internal/provider/instance/embed.go deleted file mode 100644 index 8dde733..0000000 --- a/internal/provider/instance/embed.go +++ /dev/null @@ -1,18 +0,0 @@ -package instance - -import _ "embed" - -//go:embed config.yml -var ConfigYML string - -//go:embed systemd.conf -var ServiceConf string - -//go:embed create.sh -var CreateScript string - -//go:embed launch.sh -var LaunchScript string - -//go:embed delete.sh -var DeleteScript string diff --git a/internal/provider/instance/launch.sh b/internal/provider/instance/launch.sh deleted file mode 100644 index 5b6172a..0000000 --- a/internal/provider/instance/launch.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh - -sh aemw osgi config save --pid "org.apache.sling.jcr.davex.impl.servlets.SlingDavExServlet" --input-string "alias: /crx/server" && \ -echo " -enabled: true -transportUri: http://localhost:4503/bin/receive?sling:authRequestLogin=1 -transportUser: admin -transportPassword: admin -userId: admin -" | sh aemw repl agent setup -A --location "author" --name "publish" diff --git a/internal/provider/instance_client.go b/internal/provider/instance_client.go index ef17f18..50db792 100644 --- a/internal/provider/instance_client.go +++ b/internal/provider/instance_client.go @@ -6,6 +6,7 @@ import ( "github.com/wttech/terraform-provider-aem/internal/utils" "golang.org/x/exp/maps" "gopkg.in/yaml.v3" + "time" ) const ( @@ -41,7 +42,7 @@ func (ic *InstanceClient) installComposeCLI() error { } if !exists { tflog.Info(ic.ctx, "Downloading AEM Compose CLI wrapper") - out, err := ic.cl.RunShellCommand(fmt.Sprintf("cd %s && curl -s 'https://raw.githubusercontent.com/wttech/aemc/main/pkg/project/common/aemw' -o 'aemw'", ic.dataDir())) + out, err := ic.cl.RunShellCommand("curl -s 'https://raw.githubusercontent.com/wttech/aemc/main/pkg/project/common/aemw' -o 'aemw'", ic.dataDir()) tflog.Info(ic.ctx, string(out)) if err != nil { return fmt.Errorf("cannot download AEM Compose CLI wrapper: %w", err) @@ -78,7 +79,7 @@ func (ic *InstanceClient) create() error { if err := ic.saveProfileScript(); err != nil { return err } - if err := ic.runScript("create", ic.data.Compose.CreateScript.ValueString(), ic.dataDir()); err != nil { + if err := ic.runScript("create", ic.data.Compose.Create, ic.dataDir()); err != nil { return err } tflog.Info(ic.ctx, "Created AEM instance(s)") @@ -136,7 +137,7 @@ func (ic *InstanceClient) runServiceAction(action string) error { ic.cl.Sudo = true defer func() { ic.cl.Sudo = false }() - outBytes, err := ic.cl.RunShellCommand(fmt.Sprintf("systemctl %s %s.service", action, ServiceName)) + outBytes, err := ic.cl.RunShellCommand(fmt.Sprintf("systemctl %s %s.service", action, ServiceName), ".") if err != nil { return fmt.Errorf("unable to perform AEM system service action '%s': %w", action, err) } @@ -150,20 +151,34 @@ func (ic *InstanceClient) launch() error { if err := ic.runServiceAction("start"); err != nil { return err } - if err := ic.runScript("launch", ic.data.Compose.LaunchScript.ValueString(), ic.dataDir()); err != nil { + if err := ic.applyConfig(); err != nil { + return err + } + if err := ic.runScript("launch", ic.data.Compose.Launch, ic.dataDir()); err != nil { return err } tflog.Info(ic.ctx, "Launched AEM instance(s)") return nil } -// TODO consider using "delete --kill". +func (ic *InstanceClient) applyConfig() error { + tflog.Info(ic.ctx, "Applying AEM instance configuration") + outBytes, err := ic.cl.RunShellCommand("sh aemw instance launch", ic.dataDir()) + if err != nil { + return fmt.Errorf("unable to apply AEM instance configuration: %w", err) + } + outText := string(outBytes) + tflog.Info(ic.ctx, outText) + tflog.Info(ic.ctx, "Applied AEM instance configuration") + return nil +} + func (ic *InstanceClient) terminate() error { tflog.Info(ic.ctx, "Terminating AEM instance(s)") if err := ic.runServiceAction("stop"); err != nil { return err } - if err := ic.runScript("delete", ic.data.Compose.DeleteScript.ValueString(), ic.dataDir()); err != nil { + if err := ic.runScript("delete", ic.data.Compose.Delete, ic.dataDir()); err != nil { return err } tflog.Info(ic.ctx, "Terminated AEM instance(s)") @@ -171,7 +186,7 @@ func (ic *InstanceClient) terminate() error { } func (ic *InstanceClient) deleteDataDir() error { - if _, err := ic.cl.RunShellPurely(fmt.Sprintf("rm -fr %s", ic.dataDir())); err != nil { + if err := ic.cl.PathDelete(ic.dataDir()); err != nil { return fmt.Errorf("cannot delete AEM data directory: %w", err) } return nil @@ -193,7 +208,7 @@ type InstanceStatus struct { func (ic *InstanceClient) ReadStatus() (InstanceStatus, error) { var status InstanceStatus - yamlBytes, err := ic.cl.RunShellCommand(fmt.Sprintf("cd %s && sh aemw instance status --output-format yaml", ic.dataDir())) + yamlBytes, err := ic.cl.RunShellCommand("sh aemw instance status --output-format yaml", ic.dataDir()) if err != nil { return status, err } @@ -204,24 +219,74 @@ func (ic *InstanceClient) ReadStatus() (InstanceStatus, error) { } func (ic *InstanceClient) bootstrap() error { - return ic.runScript("bootstrap", ic.data.System.BootstrapScript.ValueString(), ".") + return ic.doActionOnce("bootstrap", ic.cl.WorkDir, func() error { + return ic.runScript("bootstrap", ic.data.System.Bootstrap, ".") + }) } -func (ic *InstanceClient) runScript(name, cmdScript, dir string) error { - if cmdScript == "" { - return nil +func (ic *InstanceClient) runScript(name string, script InstanceScript, dir string) error { + scriptCmd := script.Script.ValueString() + inlineCmds := []string{} + diags := script.Inline.ElementsAs(ic.ctx, &inlineCmds, true) + if diags.HasError() { + return fmt.Errorf("unable to parse script '%s' properly: %s", name, diags) } - tflog.Info(ic.ctx, fmt.Sprintf("Executing instance script '%s'", name)) + if scriptCmd != "" { + if err := ic.runScriptMultiline(name, scriptCmd, dir); err != nil { + return err + } + } + if len(inlineCmds) > 0 { + if err := ic.runScriptInline(name, inlineCmds, dir); err != nil { + return err + } + } + + return nil +} - textOut, err := ic.cl.RunShellScript(name, cmdScript, dir) +func (ic *InstanceClient) runScriptInline(name string, inlineCmds []string, dir string) error { + for i, cmd := range inlineCmds { + tflog.Info(ic.ctx, fmt.Sprintf("Executing command '%s' of script '%s' (%d/%d)", cmd, name, i+1, len(inlineCmds))) + textOut, err := ic.cl.RunShellScript(name, cmd, dir) + if err != nil { + return fmt.Errorf("unable to execute command '%s' of script '%s' properly: %w", cmd, name, err) + } + textStr := string(textOut) + tflog.Info(ic.ctx, fmt.Sprintf("Executed command '%s' of script '%s' (%d/%d)", cmd, name, i+1, len(inlineCmds))) + tflog.Info(ic.ctx, textStr) + } + return nil +} + +func (ic *InstanceClient) runScriptMultiline(name string, scriptCmd string, dir string) error { + tflog.Info(ic.ctx, fmt.Sprintf("Executing instance script '%s'", name)) + textOut, err := ic.cl.RunShellScript(name, scriptCmd, dir) if err != nil { return fmt.Errorf("unable to execute script '%s' properly: %w", name, err) } - textStr := string(textOut) // TODO how about streaming it line by line to tflog ;) - + textStr := string(textOut) tflog.Info(ic.ctx, fmt.Sprintf("Executed instance script '%s'", name)) tflog.Info(ic.ctx, textStr) + return nil +} +func (ic *InstanceClient) doActionOnce(name string, lockDir string, action func() error) error { + lock := fmt.Sprintf("%s/provider/%s.lock", lockDir, name) + exists, err := ic.cl.FileExists(lock) + if err != nil { + return fmt.Errorf("cannot read lock file '%s': %w", lock, err) + } + if exists { + tflog.Info(ic.ctx, fmt.Sprintf("Skipping AEM instance action '%s' (lock file already exists '%s')", name, lock)) + return nil + } + if err := action(); err != nil { + return err + } + if err := ic.cl.FileWrite(lock, time.Now().String()); err != nil { + return fmt.Errorf("cannot save lock file '%s': %w", lock, err) + } return nil } diff --git a/internal/provider/instance_model.go b/internal/provider/instance_model.go new file mode 100644 index 0000000..7956ec4 --- /dev/null +++ b/internal/provider/instance_model.go @@ -0,0 +1,331 @@ +package provider + +import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "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/wttech/terraform-provider-aem/internal/provider/instance" +) + +type InstanceResourceModel struct { + Client struct { + Type types.String `tfsdk:"type"` + Settings types.Map `tfsdk:"settings"` + Credentials types.Map `tfsdk:"credentials"` + } `tfsdk:"client"` + Files types.Map `tfsdk:"files"` + System struct { + DataDir types.String `tfsdk:"data_dir"` + WorkDir types.String `tfsdk:"work_dir"` + Env types.Map `tfsdk:"env"` + ServiceConfig types.String `tfsdk:"service_config"` + User types.String `tfsdk:"user"` + Bootstrap InstanceScript `tfsdk:"bootstrap"` + } `tfsdk:"system"` + Compose struct { + Download types.Bool `tfsdk:"download"` + Version types.String `tfsdk:"version"` + Config types.String `tfsdk:"config"` + Create InstanceScript `tfsdk:"create"` + Launch InstanceScript `tfsdk:"launch"` + Delete InstanceScript `tfsdk:"delete"` + } `tfsdk:"compose"` + Instances types.List `tfsdk:"instances"` +} + +type InstanceScript struct { + Inline types.List `tfsdk:"inline"` + Script types.String `tfsdk:"script"` +} + +// workaround for https://github.com/hashicorp/terraform-plugin-framework/issues/777 +func instanceScriptSchemaDefault(inline []string, script *string) defaults.Object { + return objectdefault.StaticValue(types.ObjectValueMust( + map[string]attr.Type{ + "inline": types.ListType{ElemType: types.StringType}, + "script": types.StringType, + }, + map[string]attr.Value{ + "inline": instanceScriptSchemaInlineValue(inline), + "script": types.StringPointerValue(script), + }, + )) +} + +func instanceScriptSchemaInlineValue(inline []string) basetypes.ListValue { + var result basetypes.ListValue + if inline == nil { + result = types.ListNull(types.StringType) + } else { + vals := make([]attr.Value, len(inline)) + for i, v := range inline { + vals[i] = types.StringValue(v) + } + result = types.ListValueMust(types.StringType, vals) + } + return result +} + +type InstanceStatusItemModel struct { + ID types.String `tfsdk:"id"` + URL types.String `tfsdk:"url"` + AemVersion types.String `tfsdk:"aem_version"` + Dir types.String `tfsdk:"dir"` + Attributes types.List `tfsdk:"attributes"` + RunModes types.List `tfsdk:"run_modes"` +} + +// fix for https://github.com/hashicorp/terraform-plugin-framework/issues/713 +func (o InstanceStatusItemModel) attrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "id": types.StringType, + "url": types.StringType, + "aem_version": types.StringType, + "dir": types.StringType, + "attributes": types.ListType{ElemType: types.StringType}, + "run_modes": types.ListType{ElemType: types.StringType}, + } +} + +func (r *InstanceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "AEM Instance resource", + Blocks: map[string]schema.Block{ + "client": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "Type of connection to use to connect to the machine on which AEM instance will be running", + Required: true, + }, + "settings": schema.MapAttribute{ + MarkdownDescription: "Settings for the connection type", + ElementType: types.StringType, + Required: true, + }, + "credentials": schema.MapAttribute{ + MarkdownDescription: "Credentials for the connection type", + ElementType: types.StringType, + Optional: true, + Sensitive: true, + }, + }, + }, + "system": schema.SingleNestedBlock{ + Attributes: map[string]schema.Attribute{ + "bootstrap": schema.SingleNestedAttribute{ + MarkdownDescription: "Script executed once upon instance connection, often for mounting on VM data volumes from attached disks (e.g., AWS EBS, Azure Disk Storage). This script runs only once, even during instance recreation, as changes are typically persistent and system-wide. If re-execution is needed, it is recommended to set up a new VM.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "inline": schema.ListAttribute{ + MarkdownDescription: "Inline shell commands to be executed", + ElementType: types.StringType, + Optional: true, + }, + "script": schema.StringAttribute{ + MarkdownDescription: "Multiline shell script to be executed", + Optional: true, + }, + }, + }, + "data_dir": schema.StringAttribute{ + MarkdownDescription: "Remote root path in which AEM Compose files and unpacked instances will be stored", + Computed: true, + Optional: true, + Default: stringdefault.StaticString("/mnt/aemc"), + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "work_dir": schema.StringAttribute{ + MarkdownDescription: "Remote root path in which AEM Compose TF provider temporary files will be stored", + Computed: true, + Optional: true, + Default: stringdefault.StaticString("/tmp/aemc"), + }, + "service_config": schema.StringAttribute{ + MarkdownDescription: "Contents of the AEM 'systemd' service definition file", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(instance.ServiceConf), + }, + "user": schema.StringAttribute{ + MarkdownDescription: "System user under which AEM instance will be running. By default, the same as the user used to connect to the machine.", + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "env": schema.MapAttribute{ + MarkdownDescription: "Environment variables for AEM instances", + ElementType: types.StringType, + Computed: true, + Optional: true, + Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + }, + }, + "compose": schema.SingleNestedBlock{ + MarkdownDescription: "AEM Compose CLI configuration", + Attributes: map[string]schema.Attribute{ + "download": schema.BoolAttribute{ + MarkdownDescription: "Toggle automatic AEM Compose CLI wrapper download. If set to false, assume the wrapper is present in the data directory.", + Computed: true, + Optional: true, + Default: booldefault.StaticBool(true), + }, + "version": schema.StringAttribute{ + MarkdownDescription: "Version of AEM Compose tool to use on remote AEM machine.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString("1.5.9"), + }, + "config": schema.StringAttribute{ + MarkdownDescription: "Contents o f the AEM Compose YML configuration file.", + Computed: true, + Optional: true, + Default: stringdefault.StaticString(instance.ConfigYML), + }, + "create": schema.SingleNestedAttribute{ + MarkdownDescription: "Creates the instance or restores from backup, typically customized to provide AEM library files (quickstart.jar, license.properties, service packs) from alternative sources (e.g., AWS S3, Azure Blob Storage). Instance recreation is forced if changed.", + Optional: true, + Computed: true, + Default: instanceScriptSchemaDefault(instance.CreateScriptInline, nil), + Attributes: map[string]schema.Attribute{ + "inline": schema.ListAttribute{ + MarkdownDescription: "Inline shell commands to be executed", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(instanceScriptSchemaInlineValue(instance.CreateScriptInline)), + PlanModifiers: []planmodifier.List{listplanmodifier.RequiresReplace()}, + }, + "script": schema.StringAttribute{ + MarkdownDescription: "Multiline shell script to be executed", + Optional: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + }, + }, + "launch": schema.SingleNestedAttribute{ + MarkdownDescription: "Configures launched instance. Must be idempotent as it is executed always when changed. Typically used for installing AEM service packs, setting up replication agents, etc.", + Optional: true, + Computed: true, + Default: instanceScriptSchemaDefault(instance.LaunchScriptInline, nil), + Attributes: map[string]schema.Attribute{ + "inline": schema.ListAttribute{ + MarkdownDescription: "Inline shell commands to be executed", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(instanceScriptSchemaInlineValue(instance.LaunchScriptInline)), + }, + "script": schema.StringAttribute{ + MarkdownDescription: "Multiline shell script to be executed", + Optional: true, + }, + }, + }, + "delete": schema.SingleNestedAttribute{ + MarkdownDescription: "Deletes the instance.", + Optional: true, + Computed: true, + Default: instanceScriptSchemaDefault(instance.DeleteScriptInline, nil), + Attributes: map[string]schema.Attribute{ + "inline": schema.ListAttribute{ + MarkdownDescription: "Inline shell commands to be executed", + ElementType: types.StringType, + Optional: true, + Computed: true, + Default: listdefault.StaticValue(instanceScriptSchemaInlineValue(instance.DeleteScriptInline)), + }, + "script": schema.StringAttribute{ + MarkdownDescription: "Multiline shell script to be executed", + Optional: true, + }, + }, + }, + }, + }, + }, + + Attributes: map[string]schema.Attribute{ + "files": schema.MapAttribute{ + MarkdownDescription: "Files or directories to be copied into the machine", + ElementType: types.StringType, + Computed: true, + Optional: true, + Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), + }, + "instances": schema.ListNestedAttribute{ + Computed: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + }, + "url": schema.StringAttribute{ + Computed: true, + }, + "aem_version": schema.StringAttribute{ + Computed: true, + }, + "attributes": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + }, + "run_modes": schema.ListAttribute{ + ElementType: types.StringType, + Computed: true, + }, + "dir": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + }, + } +} + +func (r *InstanceResource) newModel() InstanceResourceModel { + model := InstanceResourceModel{} + model.Instances = types.ListValueMust(types.ObjectType{AttrTypes: InstanceStatusItemModel{}.attrTypes()}, []attr.Value{}) + return model +} + +func (r *InstanceResource) fillModelWithStatus(ctx context.Context, model *InstanceResourceModel, status InstanceStatus) diag.Diagnostics { + var allDiags diag.Diagnostics + + instances := make([]InstanceStatusItemModel, len(status.Data.Instances)) + for i, instance := range status.Data.Instances { + attributeList, diags := types.ListValueFrom(ctx, types.StringType, instance.Attributes) + allDiags.Append(diags...) + runModeList, diags := types.ListValueFrom(ctx, types.StringType, instance.RunModes) + allDiags.Append(diags...) + + instances[i] = InstanceStatusItemModel{ + ID: types.StringValue(instance.ID), + URL: types.StringValue(instance.URL), + AemVersion: types.StringValue(instance.AemVersion), + Dir: types.StringValue(instance.Dir), + Attributes: attributeList, + RunModes: runModeList, + } + } + instanceList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: InstanceStatusItemModel{}.attrTypes()}, instances) + allDiags.Append(diags...) + model.Instances = instanceList + + return allDiags +} diff --git a/internal/provider/instance_resource.go b/internal/provider/instance_resource.go index fc2d589..417c557 100644 --- a/internal/provider/instance_resource.go +++ b/internal/provider/instance_resource.go @@ -3,22 +3,12 @@ package provider import ( "context" "fmt" - "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/tfsdk" - "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/wttech/terraform-provider-aem/internal/client" - "github.com/wttech/terraform-provider-aem/internal/provider/instance" "golang.org/x/exp/maps" "time" ) @@ -35,207 +25,6 @@ type InstanceResource struct { clientManager *client.ClientManager } -type InstanceResourceModel struct { - Client struct { - Type types.String `tfsdk:"type"` - Settings types.Map `tfsdk:"settings"` - Credentials types.Map `tfsdk:"credentials"` - } `tfsdk:"client"` - Files types.Map `tfsdk:"files"` - System struct { - DataDir types.String `tfsdk:"data_dir"` - WorkDir types.String `tfsdk:"work_dir"` - Env types.Map `tfsdk:"env"` - ServiceConfig types.String `tfsdk:"service_config"` - User types.String `tfsdk:"user"` - BootstrapScript types.String `tfsdk:"bootstrap_script"` - } `tfsdk:"system"` - Compose struct { - Download types.Bool `tfsdk:"download"` - Version types.String `tfsdk:"version"` - Config types.String `tfsdk:"config"` - CreateScript types.String `tfsdk:"create_script"` - LaunchScript types.String `tfsdk:"launch_script"` - DeleteScript types.String `tfsdk:"delete_script"` - } `tfsdk:"compose"` - Instances types.List `tfsdk:"instances"` -} - -type InstanceStatusItemModel struct { - ID types.String `tfsdk:"id"` - URL types.String `tfsdk:"url"` - AemVersion types.String `tfsdk:"aem_version"` - Dir types.String `tfsdk:"dir"` - Attributes types.List `tfsdk:"attributes"` - RunModes types.List `tfsdk:"run_modes"` -} - -func (o InstanceStatusItemModel) attrTypes() map[string]attr.Type { - return map[string]attr.Type{ - "id": types.StringType, - "url": types.StringType, - "aem_version": types.StringType, - "dir": types.StringType, - "attributes": types.ListType{ElemType: types.StringType}, - "run_modes": types.ListType{ElemType: types.StringType}, - } -} - -func (r *InstanceResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { - resp.Schema = schema.Schema{ - MarkdownDescription: "AEM Instance resource", - Blocks: map[string]schema.Block{ - "client": schema.SingleNestedBlock{ - Attributes: map[string]schema.Attribute{ - "type": schema.StringAttribute{ - MarkdownDescription: "Type of connection to use to connect to the machine on which AEM instance will be running", - Required: true, - }, - "settings": schema.MapAttribute{ - MarkdownDescription: "Settings for the connection type", - ElementType: types.StringType, - Required: true, - }, - "credentials": schema.MapAttribute{ - MarkdownDescription: "Credentials for the connection type", - ElementType: types.StringType, - Optional: true, - Sensitive: true, - }, - }, - }, - "system": schema.SingleNestedBlock{ - Attributes: map[string]schema.Attribute{ - "bootstrap_script": schema.StringAttribute{ - MarkdownDescription: "Script executed once after connecting to the instance. Typically used for: providing AEM library files (quickstart.jar, license.properties, service packs), mounting data volume, etc. Forces instance recreation if changed.", - Optional: true, - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - }, - "data_dir": schema.StringAttribute{ - MarkdownDescription: "Remote root path in which AEM Compose files and unpacked instances will be stored", - Computed: true, - Optional: true, - Default: stringdefault.StaticString("/mnt/aemc"), - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - }, - "work_dir": schema.StringAttribute{ - MarkdownDescription: "Remote root path in which AEM Compose TF provider temporary files will be stored", - Computed: true, - Optional: true, - Default: stringdefault.StaticString("/tmp/aemc"), - }, - "service_config": schema.StringAttribute{ - MarkdownDescription: "Contents of the AEM 'systemd' service definition file", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(instance.ServiceConf), - }, - "user": schema.StringAttribute{ - MarkdownDescription: "System user under which AEM instance will be running. By default, the same as the user used to connect to the machine.", - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - Optional: true, - Computed: true, - Default: stringdefault.StaticString(""), - }, - "env": schema.MapAttribute{ - MarkdownDescription: "Environment variables for AEM instances", - ElementType: types.StringType, - Computed: true, - Optional: true, - Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), - }, - }, - }, - "compose": schema.SingleNestedBlock{ - - Attributes: map[string]schema.Attribute{ - "download": schema.BoolAttribute{ - MarkdownDescription: "Toggle automatic AEM Compose CLI wrapper download. If set to false, assume the wrapper is present in the data directory.", - Computed: true, - Optional: true, - Default: booldefault.StaticBool(true), - }, - "version": schema.StringAttribute{ - MarkdownDescription: "Version of AEM Compose tool to use on remote AEM machine.", - Computed: true, - Optional: true, - Default: stringdefault.StaticString("1.5.8"), - }, - "config": schema.StringAttribute{ - MarkdownDescription: "Contents of the AEM Compose YML configuration file.", - Computed: true, - Optional: true, - Default: stringdefault.StaticString(instance.ConfigYML), - }, - "create_script": schema.StringAttribute{ - MarkdownDescription: "Creates the instance or restores from backup. Forces instance recreation if changed.", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(instance.CreateScript), - PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, - }, - "launch_script": schema.StringAttribute{ - MarkdownDescription: "Configures launched instance. Must be idempotent as it is executed always when changed. Typically used for setting up replication agents, installing service packs, etc.", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(instance.LaunchScript), - }, - "delete_script": schema.StringAttribute{ - MarkdownDescription: "Deletes the instance.", - Optional: true, - Computed: true, - Default: stringdefault.StaticString(instance.DeleteScript), - }, - }, - }, - }, - - Attributes: map[string]schema.Attribute{ - "files": schema.MapAttribute{ // TODO handle it, instead of copying lib dir - MarkdownDescription: "Files or directories to be copied into the machine", - ElementType: types.StringType, - Computed: true, - Optional: true, - Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})), - }, - "instances": schema.ListNestedAttribute{ - Computed: true, - NestedObject: schema.NestedAttributeObject{ - Attributes: map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "url": schema.StringAttribute{ - Computed: true, - }, - "aem_version": schema.StringAttribute{ - Computed: true, - }, - "attributes": schema.ListAttribute{ - ElementType: types.StringType, - Computed: true, - }, - "run_modes": schema.ListAttribute{ - ElementType: types.StringType, - Computed: true, - }, - "dir": schema.StringAttribute{ - Computed: true, - }, - }, - }, - PlanModifiers: []planmodifier.List{ - listplanmodifier.UseStateForUnknown(), - listplanmodifier.RequiresReplaceIf(func(ctx context.Context, request planmodifier.ListRequest, response *listplanmodifier.RequiresReplaceIfFuncResponse) { - // TODO check if: [1] list is not empty; [2] the same instances are still created; [3] dirs have not changed - // response.RequiresReplace = true - }, "If the value of this attribute changes, Terraform will destroy and recreate the resource.", "If the value of this attribute changes, Terraform will destroy and recreate the resource."), - }, - }, - }, - } -} - func (r *InstanceResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_instance" } @@ -268,17 +57,17 @@ func (r *InstanceResource) Update(ctx context.Context, req resource.UpdateReques } func (r *InstanceResource) createOrUpdate(ctx context.Context, plan *tfsdk.Plan, diags *diag.Diagnostics, state *tfsdk.State, create bool) { - model := r.newModel() + plannedModel := r.newModel() - // Read Terraform plan data into the model - diags.Append(plan.Get(ctx, &model)...) + // Read Terraform planned data into the model + diags.Append(plan.Get(ctx, &plannedModel)...) if diags.HasError() { return } tflog.Info(ctx, "Started setting up AEM instance resource") - ic, err := r.client(ctx, model, time.Minute*5) + ic, err := r.client(ctx, plannedModel, time.Minute*5) if err != nil { diags.AddError("Unable to connect to AEM instance", fmt.Sprintf("%s", err)) return @@ -290,16 +79,16 @@ func (r *InstanceResource) createOrUpdate(ctx context.Context, plan *tfsdk.Plan, } }(ic) - if err := ic.copyFiles(); err != nil { - diags.AddError("Unable to copy AEM instance files", fmt.Sprintf("%s", err)) - return - } if create { if err := ic.bootstrap(); err != nil { diags.AddError("Unable to bootstrap AEM instance machine", fmt.Sprintf("%s", err)) return } } + if err := ic.copyFiles(); err != nil { + diags.AddError("Unable to copy AEM instance files", fmt.Sprintf("%s", err)) + return + } if err := ic.prepareWorkDir(); err != nil { diags.AddError("Unable to prepare AEM work directory", fmt.Sprintf("%s", err)) return @@ -335,42 +124,10 @@ func (r *InstanceResource) createOrUpdate(ctx context.Context, plan *tfsdk.Plan, return } - diags.Append(r.fillModelWithStatus(ctx, &model, status)...) + diags.Append(r.fillModelWithStatus(ctx, &plannedModel, status)...) // Save data into Terraform state - diags.Append(state.Set(ctx, &model)...) -} - -func (r *InstanceResource) newModel() InstanceResourceModel { - model := InstanceResourceModel{} - model.Instances = types.ListValueMust(types.ObjectType{AttrTypes: InstanceStatusItemModel{}.attrTypes()}, []attr.Value{}) - return model -} - -func (r *InstanceResource) fillModelWithStatus(ctx context.Context, model *InstanceResourceModel, status InstanceStatus) diag.Diagnostics { - var allDiags diag.Diagnostics - - instances := make([]InstanceStatusItemModel, len(status.Data.Instances)) - for i, instance := range status.Data.Instances { - attributeList, diags := types.ListValueFrom(ctx, types.StringType, instance.Attributes) - allDiags.Append(diags...) - runModeList, diags := types.ListValueFrom(ctx, types.StringType, instance.RunModes) - allDiags.Append(diags...) - - instances[i] = InstanceStatusItemModel{ - ID: types.StringValue(instance.ID), - URL: types.StringValue(instance.URL), - AemVersion: types.StringValue(instance.AemVersion), - Dir: types.StringValue(instance.Dir), - Attributes: attributeList, - RunModes: runModeList, - } - } - instanceList, diags := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: InstanceStatusItemModel{}.attrTypes()}, instances) - allDiags.Append(diags...) - model.Instances = instanceList - - return allDiags + diags.Append(state.Set(ctx, &plannedModel)...) } func (r *InstanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {