diff --git a/.gitattributes b/.gitattributes index 43165c68..dda208fc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,14 @@ # Force UNIX line-ending to avoid errors on higher environments * text=auto eol=lf + +# Handle embedded binaries as Git LFS resources + +**/resource/*.zip filter=lfs diff=lfs merge=lfs -text +**/resource/*.tar.gz filter=lfs diff=lfs merge=lfs -text +**/resource/*.tar filter=lfs diff=lfs merge=lfs -text +**/resource/*.jar filter=lfs diff=lfs merge=lfs -text +**/resource/*.exe filter=lfs diff=lfs merge=lfs -text +**/resource/*.rpm filter=lfs diff=lfs merge=lfs -text +**/resource/*.deb filter=lfs diff=lfs merge=lfs -text +**/resource/*.so filter=lfs diff=lfs merge=lfs -text diff --git a/.run/instance_create.run.xml b/.run/instance_create.run.xml deleted file mode 100644 index bacb334c..00000000 --- a/.run/instance_create.run.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/cmd/aem/cli.go b/cmd/aem/cli.go index 487c763c..d643a449 100644 --- a/cmd/aem/cli.go +++ b/cmd/aem/cli.go @@ -25,7 +25,7 @@ import ( ) const ( - OutputFileDefault = common.HomeDir + "/aem.log" + OutputFileDefault = common.LogDir + "/aem.log" ) type CLI struct { diff --git a/cmd/aem/config.go b/cmd/aem/config.go index a72bbf8d..7a56b514 100644 --- a/cmd/aem/config.go +++ b/cmd/aem/config.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "github.com/spf13/cobra" "github.com/wttech/aemc/pkg/cfg" ) @@ -32,12 +31,13 @@ func (c *CLI) configListCmd() *cobra.Command { func (c *CLI) configInitCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "init", - Short: "Initialize configuration", + Use: "initialize", + Aliases: []string{"init"}, + Short: "Initialize configuration", Run: func(cmd *cobra.Command, args []string) { - err := c.config.Init() + err := c.config.Initialize() if err != nil { - c.Fail(fmt.Sprintf("cannot initialize config: %s", err)) + c.Error(err) return } c.SetOutput("path", cfg.File()) diff --git a/cmd/aem/init.go b/cmd/aem/init.go new file mode 100644 index 00000000..50186510 --- /dev/null +++ b/cmd/aem/init.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/spf13/cobra" + "github.com/wttech/aemc/pkg/cfg" + "github.com/wttech/aemc/pkg/common" + "strings" +) + +func (c *CLI) initCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "initialize", + Aliases: []string{"init"}, + Short: "Initializes configuration and dependencies", + Run: func(cmd *cobra.Command, args []string) { + if !c.config.IsInitialized() { + if err := c.config.Initialize(); err != nil { + c.Error(err) + return + } + } + c.SetOutput("gettingStarted", strings.Join([]string{ + "The next step is providing AEM files (JAR or SDK ZIP, license) to directory '" + common.LibDir + "'.", + "Alternatively, instruct the tool where these files are located by adjusting properties: 'dist_file', 'license_file' in configuration file '" + cfg.FileDefault + "'.", + "To avoid problems with IDE performance, make sure to exclude from indexing the directory '" + common.HomeDir + "'.", + "Finally, use control scripts to manage AEM instances:", + "", + + "sh aemw [setup|resetup|up|down|restart]", + + "", + "It is also possible to run individual AEM Compose CLI commands separately.", + "Discover available commands by running:", + "", + + "sh aemw --help", + }, "\n")) + c.Ok("initialized properly") + }, + } + return cmd +} diff --git a/cmd/aem/root.go b/cmd/aem/root.go index 0e034ce2..14bf3a40 100644 --- a/cmd/aem/root.go +++ b/cmd/aem/root.go @@ -22,6 +22,7 @@ func (c *CLI) rootCmd() *cobra.Command { } cmd.AddCommand(c.appCmd()) cmd.AddCommand(c.versionCmd()) + cmd.AddCommand(c.initCmd()) cmd.AddCommand(c.configCmd()) cmd.AddCommand(c.instanceCmd()) cmd.AddCommand(c.osgiCmd()) diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 315e10aa..b0a2edc3 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -2,7 +2,6 @@ package cfg import ( "bytes" - _ "embed" "fmt" log "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -18,11 +17,13 @@ import ( ) const ( - EnvPrefix = "AEM" - InputStdin = "STDIN" - OutputFileDefault = common.HomeDir + "/aem.log" - FilePathDefault = common.HomeDir + "/aem.yml" - FilePathEnvVar = "AEM_CONFIG_FILE" + EnvPrefix = "AEM" + InputStdin = "STDIN" + OutputFileDefault = common.LogDir + "/aem.log" + FileDefault = common.ConfigDir + "/aem.yml" + FileEnvVar = "AEM_CONFIG_FILE" + TemplateFileDefault = common.DefaultDir + "/" + common.ConfigDirName + "/aem.yml" + TemplateFileEnvVar = "AEM_CONFIG_TEMPLATE" ) // Config defines a place for managing input configuration from various sources (YML file, env vars, etc) @@ -102,37 +103,48 @@ func readFromFile(v *viper.Viper) { } func File() string { - path := os.Getenv(FilePathEnvVar) - if len(path) == 0 { - path = FilePathDefault + path := os.Getenv(FileEnvVar) + if path == "" { + path = FileDefault } return path } -func (c *Config) ConfigureLogger() { - log.SetFormatter(&log.TextFormatter{ - TimestampFormat: c.values.Log.TimestampFormat, - FullTimestamp: c.values.Log.FullTimestamp, - }) - - level, err := log.ParseLevel(c.values.Log.Level) - if err != nil { - log.Fatalf("unsupported log level specified: '%s'", c.values.Log.Level) +func TemplateFile() string { + path := os.Getenv(TemplateFileEnvVar) + if path == "" { + path = TemplateFileDefault } - log.SetLevel(level) + return path } -//go:embed aem.yml -var configYml string +func (c *Config) IsInitialized() bool { + return pathx.Exists(File()) +} -func (c *Config) Init() error { +func (c *Config) Initialize() error { file := File() if pathx.Exists(file) { return fmt.Errorf("config file already exists: '%s'", file) } - err := filex.WriteString(file, configYml) + templateFile := TemplateFile() + if !pathx.Exists(templateFile) { + return fmt.Errorf("config file template does not exist: '%s'", templateFile) + } + ymlTplStr, err := filex.ReadString(templateFile) + if err != nil { + return err + } + ymlTplParsed, err := tplx.New("config-tpl").Delims("[[", "]]").Parse(ymlTplStr) if err != nil { - return fmt.Errorf("cannot create initial config file '%s': '%w'", file, err) + return fmt.Errorf("cannot parse config file template '%s': '%w'", templateFile, err) + } + var yml bytes.Buffer + if err := ymlTplParsed.Execute(&yml, map[string]any{ /* TODO future hook (not sure if needed or not */ }); err != nil { + return fmt.Errorf("cannot render config file template '%s': '%w'", templateFile, err) + } + if err = filex.WriteString(file, yml.String()); err != nil { + return fmt.Errorf("cannot save config file '%s': '%w'", file, err) } return nil } @@ -141,7 +153,18 @@ func InputFormats() []string { return []string{fmtx.YML, fmtx.JSON} } -// OutputFormats returns all available output formats func OutputFormats() []string { return []string{fmtx.Text, fmtx.YML, fmtx.JSON, fmtx.None} } + +func (c *Config) ConfigureLogger() { + log.SetFormatter(&log.TextFormatter{ + TimestampFormat: c.values.Log.TimestampFormat, + FullTimestamp: c.values.Log.FullTimestamp, + }) + level, err := log.ParseLevel(c.values.Log.Level) + if err != nil { + log.Fatalf("unsupported log level specified: '%s'", c.values.Log.Level) + } + log.SetLevel(level) +} diff --git a/pkg/cfg/values.go b/pkg/cfg/values.go index 0038cf88..52471347 100644 --- a/pkg/cfg/values.go +++ b/pkg/cfg/values.go @@ -88,12 +88,17 @@ type ConfigValues struct { Local struct { UnpackDir string `mapstructure:"unpack_dir" yaml:"unpack_dir"` + ToolDir string `mapstructure:"tool_dir" yaml:"tool_dir"` BackupDir string `mapstructure:"backup_dir" yaml:"backup_dir"` Quickstart struct { DistFile string `mapstructure:"dist_file" yaml:"dist_file"` LicenseFile string `mapstructure:"license_file" yaml:"license_file"` } `mapstructure:"quickstart" yaml:"quickstart"` + OakRun struct { + DownloadURL string `mapstructure:"download_url" yaml:"download_url"` + StorePath string `mapstructure:"store_path" yaml:"store_path"` + } `mapstructure:"oak_run" yaml:"oak_run"` } `mapstructure:"local" yaml:"local"` Package struct { diff --git a/pkg/common/constants.go b/pkg/common/constants.go index 40786e5d..b482b06e 100644 --- a/pkg/common/constants.go +++ b/pkg/common/constants.go @@ -1,6 +1,21 @@ package common const ( - MainDir = "aem" - HomeDir = MainDir + "/home" + MainDir = "aem" + HomeDirName = "home" + HomeDir = MainDir + "/" + HomeDirName + VarDirName = "var" + VarDir = HomeDir + "/" + VarDirName + ConfigDirName = "etc" + ConfigDir = HomeDir + "/" + ConfigDirName + LogDirName = "log" + LogDir = VarDir + "/" + LogDirName + ToolDirName = "opt" + ToolDir = HomeDir + "/" + ToolDirName + LibDirName = "lib" + LibDir = HomeDir + "/" + LibDirName + TmpDirName = "tmp" + TmpDir = HomeDir + "/" + TmpDirName + DefaultDirName = "default" + DefaultDir = MainDir + "/" + DefaultDirName ) diff --git a/pkg/common/cryptox/cryptox.go b/pkg/common/cryptox/cryptox.go new file mode 100644 index 00000000..4967a316 --- /dev/null +++ b/pkg/common/cryptox/cryptox.go @@ -0,0 +1,38 @@ +package cryptox + +import ( + "crypto/aes" + "crypto/sha256" + "encoding/hex" + "fmt" + log "github.com/sirupsen/logrus" + "io" +) + +func EncryptString(key []byte, text string) string { + c, err := aes.NewCipher(key) + if err != nil { + log.Fatalf("encryption key/salt is invalid: %s", err) + } + encrypted := make([]byte, len(text)) + c.Encrypt(encrypted, []byte(text)) + return hex.EncodeToString(encrypted) +} + +func DecryptString(key []byte, encrypted string) string { + c, err := aes.NewCipher(key) + if err != nil { + log.Fatalf("decryption key/salt is invalid: %s", err) + } + decoded, _ := hex.DecodeString(encrypted) + dest := make([]byte, len(decoded)) + c.Decrypt(dest, decoded) + s := string(dest[:]) + return s +} + +func HashString(text string) string { + hash := sha256.New() + _, _ = io.WriteString(hash, text) + return fmt.Sprintf("%x", hash.Sum(nil)) +} diff --git a/pkg/common/osx/lock.go b/pkg/common/osx/lock.go index 63768e2a..b45c165c 100644 --- a/pkg/common/osx/lock.go +++ b/pkg/common/osx/lock.go @@ -8,16 +8,16 @@ import ( ) type Lock[T comparable] struct { - path string - dataCurrent T + path string + dataProvider func() T } -func NewLock[T comparable](path string, data T) Lock[T] { - return Lock[T]{path: path, dataCurrent: data} +func NewLock[T comparable](path string, dataProvider func() T) Lock[T] { + return Lock[T]{path, dataProvider} } func (l Lock[T]) Lock() error { - err := fmtx.MarshalToFile(l.path, l.dataCurrent) + err := fmtx.MarshalToFile(l.path, l.dataProvider()) if err != nil { return fmt.Errorf("cannot save lock file '%s': %w", l.path, err) } @@ -36,7 +36,7 @@ func (l Lock[T]) IsLocked() bool { } func (l Lock[T]) DataCurrent() T { - return l.dataCurrent + return l.dataProvider() } func (l Lock[T]) DataLocked() (T, error) { @@ -58,6 +58,6 @@ func (l Lock[T]) IsUpToDate() (bool, error) { if err != nil { return false, err } - upToDate := cmp.Equal(l.dataCurrent, dataLocked) + upToDate := cmp.Equal(l.DataCurrent(), dataLocked) return upToDate, nil } diff --git a/pkg/common/tplx/tplx.go b/pkg/common/tplx/tplx.go index baa42c6b..1f075758 100644 --- a/pkg/common/tplx/tplx.go +++ b/pkg/common/tplx/tplx.go @@ -1,6 +1,9 @@ package tplx import ( + "bytes" + "fmt" + "github.com/wttech/aemc/pkg/common/filex" "reflect" "text/template" ) @@ -33,3 +36,26 @@ var funcMap = template.FuncMap{ func recovery() { recover() } + +func RenderString(tplContent string, data any) (string, error) { + tplParsed, err := New("string-template").Parse(tplContent) + if err != nil { + return "", err + } + var tplOutput bytes.Buffer + if err := tplParsed.Execute(&tplOutput, data); err != nil { + return "", err + } + return tplOutput.String(), nil +} + +func RenderFile(file string, content string, data map[string]any) error { + scriptContent, err := RenderString(content, data) + if err != nil { + return err + } + if err := filex.WriteString(file, scriptContent); err != nil { + return fmt.Errorf("cannot render template file '%s': %w", file, err) + } + return nil +} diff --git a/pkg/http.go b/pkg/http.go index b266ba26..10aa2cf3 100644 --- a/pkg/http.go +++ b/pkg/http.go @@ -16,22 +16,21 @@ type HTTP struct { func NewHTTP(instance *Instance, baseURL string) *HTTP { return &HTTP{ - client: newInstanceHTTPClient(instance, baseURL), + client: newInstanceHTTPClient(baseURL), instance: instance, } } -func newInstanceHTTPClient(instance *Instance, baseURL string) *resty.Client { +func newInstanceHTTPClient(baseURL string) *resty.Client { client := resty.New() client.SetBaseURL(baseURL) - client.SetBasicAuth(instance.User(), instance.Password()) client.SetDisableWarn(true) client.SetDoNotParseResponse(true) return client } func (hc *HTTP) Request() *resty.Request { - return hc.client.R() + return hc.client.R().SetBasicAuth(hc.instance.User(), hc.instance.Password()) } func (hc *HTTP) RequestFormData(props map[string]any) *resty.Request { diff --git a/pkg/instance.go b/pkg/instance.go index 06b70698..75ff9586 100644 --- a/pkg/instance.go +++ b/pkg/instance.go @@ -7,32 +7,13 @@ import ( "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/wttech/aemc/pkg/common/fmtx" + "github.com/wttech/aemc/pkg/instance" "golang.org/x/exp/maps" nurl "net/url" "strings" "time" ) -const ( - IDDelimiter = "_" - URLLocalAuthor = "http://localhost:4502" - URLLocalPublish = "http://localhost:4503" - PasswordDefault = "admin" - UserDefault = "admin" - LocationLocal = "local" - LocationRemote = "remote" - RoleAuthorPortSuffix = "02" - ClassifierDefault = "" - AemVersionUnknown = "unknown" -) - -type Role string - -const ( - RoleAuthor Role = "author" - RolePublish Role = "publish" -) - // Instance represents AEM instance type Instance struct { manager *InstanceManager @@ -109,28 +90,28 @@ func (i Instance) PackageManager() *PackageManager { } func (i Instance) IDInfo() IDInfo { - parts := strings.Split(i.id, IDDelimiter) + parts := strings.Split(i.id, instance.IDDelimiter) if len(parts) == 2 { return IDInfo{ Location: parts[0], - Role: Role(parts[1]), + Role: instance.Role(parts[1]), } } return IDInfo{ Location: parts[0], - Role: Role(parts[1]), + Role: instance.Role(parts[1]), Classifier: parts[2], } } type IDInfo struct { Location string - Role Role + Role instance.Role Classifier string } func (i Instance) IsLocal() bool { - return i.IDInfo().Location == LocationLocal + return i.IDInfo().Location == instance.LocationLocal } func (i Instance) IsRemote() bool { @@ -138,35 +119,35 @@ func (i Instance) IsRemote() bool { } func (i Instance) IsAuthor() bool { - return i.IDInfo().Role == RoleAuthor + return i.IDInfo().Role == instance.RoleAuthor } func (i Instance) IsPublish() bool { - return i.IDInfo().Role == RolePublish + return i.IDInfo().Role == instance.RolePublish } func locationByURL(config *nurl.URL) string { if lo.Contains(localHosts(), config.Hostname()) { - return LocationLocal + return instance.LocationLocal } - return LocationRemote + return instance.LocationRemote } -func roleByURL(config *nurl.URL) Role { - if strings.HasSuffix(config.Port(), RoleAuthorPortSuffix) { - return RoleAuthor +func roleByURL(config *nurl.URL) instance.Role { + if strings.HasSuffix(config.Port(), instance.RoleAuthorPortSuffix) { + return instance.RoleAuthor } - return RolePublish + return instance.RolePublish } // TODO local-publish-preview etc func classifierByURL(_ *nurl.URL) string { - return ClassifierDefault + return instance.ClassifierDefault } func credentialsByURL(config *nurl.URL) (string, string) { - user := UserDefault - pwd := PasswordDefault + user := instance.UserDefault + pwd := instance.PasswordDefault urlUser := config.User.Username() if urlUser != "" { @@ -199,7 +180,7 @@ func (i Instance) AemVersion() string { version, err := i.status.AemVersion() if err != nil { log.Debugf("cannot determine AEM version of instance '%s': %s", i.id, err) - return AemVersionUnknown + return instance.AemVersionUnknown } return version } diff --git a/pkg/instance/constants.go b/pkg/instance/constants.go index a64d1166..8bccd743 100644 --- a/pkg/instance/constants.go +++ b/pkg/instance/constants.go @@ -4,6 +4,19 @@ import ( _ "embed" ) +const ( + IDDelimiter = "_" + URLLocalAuthor = "http://localhost:4502" + URLLocalPublish = "http://localhost:4503" + PasswordDefault = "admin" + UserDefault = "admin" + LocationLocal = "local" + LocationRemote = "remote" + RoleAuthorPortSuffix = "02" + ClassifierDefault = "" + AemVersionUnknown = "unknown" +) + type ProcessingMode string const ( @@ -18,3 +31,13 @@ func ProcessingModes() []string { //go:embed resource/cbp.exe var CbpExecutable []byte + +//go:embed resource/oak-run/set-password.groovy +var OakRunSetPassword string + +type Role string + +const ( + RoleAuthor Role = "author" + RolePublish Role = "publish" +) diff --git a/pkg/instance/resource/cbp.exe b/pkg/instance/resource/cbp.exe index 82e9eb27..384b5a6c 100644 Binary files a/pkg/instance/resource/cbp.exe and b/pkg/instance/resource/cbp.exe differ diff --git a/pkg/instance/resource/oak-run/set-password.groovy b/pkg/instance/resource/oak-run/set-password.groovy new file mode 100644 index 00000000..c9550f35 --- /dev/null +++ b/pkg/instance/resource/oak-run/set-password.groovy @@ -0,0 +1,32 @@ +import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil +import org.apache.jackrabbit.oak.spi.commit.CommitInfo +import org.apache.jackrabbit.oak.spi.commit.EmptyHook + +class Global { + static userNode = null; +} + +void findUserNode(ub) { + if (ub.hasProperty("rep:principalName")) { + if ("rep:principalName = {{.User}}".equals(ub.getProperty("rep:principalName").toString())) { + Global.userNode = ub; + } + } + ub.childNodeNames.each { it -> + if (Global.userNode == null) { + findUserNode(ub.getChildNode(it)); + } + } +} + +ub = session.store.root.builder(); +findUserNode(ub.getChildNode("home").getChildNode("users")); + +if (Global.userNode) { + println("Found user node: " + Global.userNode.toString()); + Global.userNode.setProperty("rep:password", PasswordUtil.buildPasswordHash("{{.Password}}")); + session.store.merge(ub, EmptyHook.INSTANCE, CommitInfo.EMPTY); + println("Updated user node: " + Global.userNode.toString()); +} else { + println("Could not find user node!"); +} diff --git a/pkg/instance_manager.go b/pkg/instance_manager.go index 5dc9c42b..13be92d4 100644 --- a/pkg/instance_manager.go +++ b/pkg/instance_manager.go @@ -188,12 +188,12 @@ func (im *InstanceManager) CheckOnce(instances []Instance, checks []Checker) (bo } func (im *InstanceManager) NewLocalAuthor() Instance { - i, _ := im.NewByURL(URLLocalAuthor) + i, _ := im.NewByURL(instance.URLLocalAuthor) return *i } func (im *InstanceManager) NewLocalPublish() Instance { - i, _ := im.NewByURL(URLLocalPublish) + i, _ := im.NewByURL(instance.URLLocalPublish) return *i } @@ -216,7 +216,7 @@ func (im *InstanceManager) NewByURL(url string) (*Instance, error) { if len(classifier) > 0 { parts = append(parts, classifier) } - id := strings.Join(parts, IDDelimiter) + id := strings.Join(parts, instance.IDDelimiter) return im.New(id, url, user, password), nil } diff --git a/pkg/local_instance.go b/pkg/local_instance.go index 7eeca932..79f1547d 100644 --- a/pkg/local_instance.go +++ b/pkg/local_instance.go @@ -2,6 +2,7 @@ package pkg import ( "fmt" + "github.com/wttech/aemc/pkg/common/cryptox" "github.com/wttech/aemc/pkg/common/execx" "github.com/wttech/aemc/pkg/common/filex" "github.com/wttech/aemc/pkg/common/netx" @@ -40,6 +41,8 @@ const ( LocalInstanceScriptStop = "stop" LocalInstanceScriptStatus = "status" LocalInstanceBackupExtension = "aemb.tar.zst" + LocalInstanceUser = "admin" + LocalInstanceWorkDirName = "aem-compose" ) func (li LocalInstance) Instance() *Instance { @@ -47,12 +50,11 @@ func (li LocalInstance) Instance() *Instance { } func NewLocal(i *Instance) *LocalInstance { - return &LocalInstance{ - instance: i, - Version: "1", - JvmOpts: []string{"-server", "-Xmx1024m", "-Djava.awt.headless=true"}, - RunModes: []string{}, - } + li := &LocalInstance{instance: i} + li.Version = "1" + li.JvmOpts = []string{"-server", "-Xmx1024m", "-Djava.awt.headless=true"} + li.RunModes = []string{} + return li } func (li LocalInstance) State() LocalInstanceState { @@ -71,7 +73,7 @@ func (li LocalInstance) Opts() *LocalOpts { func (li LocalInstance) Name() string { id := li.instance.IDInfo() if id.Classifier != "" { - return string(id.Role) + IDDelimiter + id.Classifier + return string(id.Role) + instance.IDDelimiter + id.Classifier } return string(id.Role) } @@ -81,7 +83,7 @@ func (li LocalInstance) Dir() string { } func (li LocalInstance) WorkDir() string { - return fmt.Sprintf("%s/%s", li.Dir(), "aem-compose") + return fmt.Sprintf("%s/%s", li.Dir(), LocalInstanceWorkDirName) } func (li LocalInstance) LockDir() string { @@ -140,13 +142,13 @@ func (li LocalInstance) Create() error { } func (li LocalInstance) createLock() osx.Lock[localInstanceCreateLock] { - return osx.NewLock(fmt.Sprintf("%s/create.yml", li.LockDir()), localInstanceCreateLock{ - Created: time.Now(), + return osx.NewLock(fmt.Sprintf("%s/create.yml", li.LockDir()), func() localInstanceCreateLock { + return localInstanceCreateLock{Created: time.Now()} }) } type localInstanceCreateLock struct { - Created time.Time + Created time.Time `yaml:"created"` } func (li LocalInstance) unpackJarFile() error { @@ -199,7 +201,7 @@ func (li LocalInstance) correctFiles() error { ) content = strings.ReplaceAll(content, // force instance to be launched in background (it is windowed by default) "start \"CQ\" cmd.exe /C java %CQ_JVM_OPTS% -jar %CurrDirName%\\%CQ_JARFILE% %START_OPTS%", - "aem-compose\\bin\\cbp.exe cmd.exe /C \"java %CQ_JVM_OPTS% -jar %CurrDirName%\\%CQ_JARFILE% %START_OPTS% 1> %CurrDirName%\\logs\\stdout.log 2>&1\"", + LocalInstanceWorkDirName+"\\bin\\cbp.exe cmd.exe /C \"java %CQ_JVM_OPTS% -jar %CurrDirName%\\%CQ_JARFILE% %START_OPTS% 1> %CurrDirName%\\logs\\stdout.log 2>&1\"", ) content = strings.ReplaceAll(content, // introduce CQ_START_OPTS (not available by default) "set START_OPTS=start -c %CurrDirName% -i launchpad", @@ -214,10 +216,17 @@ func (li LocalInstance) IsCreated() bool { return li.createLock().IsLocked() } +func (li LocalInstance) IsInitialized() bool { + return li.startLock().IsLocked() +} + func (li LocalInstance) Start() error { if !li.IsCreated() { return fmt.Errorf("cannot start instance '%s' as it is not created", li.instance.ID()) } + if err := li.updateAuth(); err != nil { + return err + } log.Infof("starting instance '%s' ", li.instance.ID()) if err := li.checkPortsOpen(); err != nil { return err @@ -227,6 +236,9 @@ func (li LocalInstance) Start() error { if err := cmd.Run(); err != nil { return fmt.Errorf("cannot execute start script for instance '%s': %w", li.instance.ID(), err) } + if err := li.awaitAuth(); err != nil { + return err + } if err := li.startLock().Lock(); err != nil { return err } @@ -244,6 +256,23 @@ func (li LocalInstance) StartAndAwait() error { return nil } +func (li LocalInstance) updateAuth() error { + if !li.IsInitialized() { + return nil + } + lock := li.startLock() + data, err := lock.DataLocked() + if err != nil { + return err + } + if data.Password != lock.DataCurrent().Password { + if err := li.Opts().OakRun.SetPassword(li.Dir(), LocalInstanceUser, li.instance.password); err != nil { + return err + } + } + return nil +} + func (li LocalInstance) checkPortsOpen() error { host := li.instance.http.Hostname() ports := []string{ @@ -260,19 +289,23 @@ func (li LocalInstance) checkPortsOpen() error { } func (li LocalInstance) startLock() osx.Lock[localInstanceStartLock] { - return osx.NewLock(fmt.Sprintf("%s/start.yml", li.LockDir()), localInstanceStartLock{ - Version: li.Version, - HTTPPort: li.instance.HTTP().Port(), - RunModes: li.RunModesString(), - JVMOpts: li.JVMOptsString(), + return osx.NewLock(fmt.Sprintf("%s/start.yml", li.LockDir()), func() localInstanceStartLock { + return localInstanceStartLock{ + Version: li.Version, + HTTPPort: li.instance.HTTP().Port(), + RunModes: strings.Join(li.RunModes, ","), + JVMOpts: strings.Join(li.JvmOpts, " "), + Password: cryptox.HashString(li.instance.password), + } }) } type localInstanceStartLock struct { - Version string - JVMOpts string - RunModes string - HTTPPort string + Version string `yaml:"version"` + JVMOpts string `yaml:"jvm_opts"` + RunModes string `yaml:"run_modes"` + HTTPPort string `yaml:"http_port"` + Password string `yaml:"password"` } func (li LocalInstance) Stop() error { @@ -285,9 +318,6 @@ func (li LocalInstance) Stop() error { if err := cmd.Run(); err != nil { return fmt.Errorf("cannot execute stop script for instance '%s': %w", li.instance.ID(), err) } - if err := li.startLock().Unlock(); err != nil { - return err - } log.Infof("stopped instance '%s' ", li.instance.ID()) return nil } @@ -388,6 +418,18 @@ func (li LocalInstance) AwaitNotRunning() error { return li.Await("not running", func() bool { return !li.IsRunning() }, time.Minute*10) } +// awaitAuth waits for a custom password to be in use (initially the default one is used instead) +func (li LocalInstance) awaitAuth() error { + if li.IsInitialized() || li.instance.password == instance.PasswordDefault { + return nil + } + // TODO 'local_author | auth not ready (1/588=2%): xtg' + return li.Await("auth ready", func() bool { + _, err := li.instance.osgi.bundleManager.List() + return err == nil + }, time.Minute*10) +} + type LocalStatus int const ( @@ -494,7 +536,12 @@ func (li LocalInstance) RunModesString() string { } func (li LocalInstance) JVMOptsString() string { - result := li.JvmOpts + result := append([]string{}, li.JvmOpts...) + + // at the first boot admin password could be customized via property, at the next boot only via Oak Run + if !li.IsInitialized() { + result = append(result, fmt.Sprintf("-Dadmin.password=%s", li.instance.password)) + } sort.Strings(result) return strings.Join(result, " ") } diff --git a/pkg/local_instance_manager.go b/pkg/local_instance_manager.go index c87de0f3..302610dd 100644 --- a/pkg/local_instance_manager.go +++ b/pkg/local_instance_manager.go @@ -7,6 +7,7 @@ import ( "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/wttech/aemc/pkg/cfg" + "github.com/wttech/aemc/pkg/common" "github.com/wttech/aemc/pkg/common/fmtx" "github.com/wttech/aemc/pkg/common/pathx" "github.com/wttech/aemc/pkg/common/timex" @@ -17,11 +18,11 @@ import ( ) const ( - UnpackDir = "aem/home/data/instance" - BackupDir = "aem/home/data/backup" - LibDir = "aem/home/lib" - DistFile = LibDir + "/aem-sdk-quickstart.jar" - LicenseFile = LibDir + "/" + LicenseFilename + UnpackDir = common.VarDir + "/instance" + BackupDir = common.VarDir + "/backup" + + DistFile = common.LibDir + "/aem-sdk-quickstart.jar" + LicenseFile = common.LibDir + "/" + LicenseFilename LicenseFilename = "license.properties" ) @@ -29,8 +30,10 @@ type LocalOpts struct { manager *InstanceManager UnpackDir string + ToolDir string BackupDir string JavaOpts *java.Opts + OakRun *OakRun Quickstart *Quickstart Sdk *Sdk } @@ -39,17 +42,14 @@ func (im *InstanceManager) NewLocalOpts(manager *InstanceManager) *LocalOpts { result := &LocalOpts{ manager: manager, - UnpackDir: UnpackDir, - BackupDir: BackupDir, - JavaOpts: im.aem.javaOpts, - Quickstart: &Quickstart{ - DistFile: DistFile, - LicenseFile: LicenseFile, - }, - } - result.Sdk = &Sdk{ - localOpts: result, + UnpackDir: UnpackDir, + BackupDir: BackupDir, + ToolDir: common.ToolDir, + JavaOpts: im.aem.javaOpts, + Quickstart: NewQuickstart(), } + result.Sdk = NewSdk(result) + result.OakRun = NewOakRun(result) return result } @@ -97,6 +97,13 @@ func IsSdkFile(path string) bool { return pathx.Ext(path) == "zip" } +func NewQuickstart() *Quickstart { + return &Quickstart{ + DistFile: DistFile, + LicenseFile: LicenseFile, + } +} + type Quickstart struct { DistFile string LicenseFile string @@ -153,6 +160,9 @@ func (im *InstanceManager) Create(instances []Instance) ([]Instance, error) { return created, err } } + + im.LocalOpts.OakRun.Prepare() + for _, i := range instances { if !i.local.IsCreated() { err := i.local.Create() @@ -213,14 +223,14 @@ func (im *InstanceManager) Start(instances []Instance) ([]Instance, error) { } } + var awaited []Instance if im.CheckOpts.AwaitStrict { - if err := im.AwaitStarted(started); err != nil { - return started, err - } + awaited = started } else { - if err := im.AwaitStarted(instances); err != nil { - return instances, err - } + awaited = instances + } + if err := im.AwaitStarted(awaited); err != nil { + return nil, err } return started, nil @@ -254,16 +264,19 @@ func (im *InstanceManager) Stop(instances []Instance) ([]Instance, error) { } } + var awaited []Instance if im.CheckOpts.AwaitStrict { - if err := im.AwaitStopped(stopped); err != nil { - return stopped, err - } - im.Clean(stopped) + awaited = stopped } else { - if err := im.AwaitStopped(instances); err != nil { - return instances, err - } - im.Clean(instances) + awaited = instances + } + if err := im.AwaitStopped(awaited); err != nil { + return nil, err + } + + _, err = im.Clean(stopped) + if err != nil { + return nil, err } return stopped, nil @@ -447,6 +460,9 @@ func (im *InstanceManager) configureLocalOpts(config *cfg.Config) { if len(opts.UnpackDir) > 0 { im.LocalOpts.UnpackDir = opts.UnpackDir } + if len(opts.ToolDir) > 0 { + im.LocalOpts.ToolDir = opts.ToolDir + } if len(opts.BackupDir) > 0 { im.LocalOpts.BackupDir = opts.BackupDir } @@ -456,4 +472,10 @@ func (im *InstanceManager) configureLocalOpts(config *cfg.Config) { if len(opts.Quickstart.LicenseFile) > 0 { im.LocalOpts.Quickstart.LicenseFile = opts.Quickstart.LicenseFile } + if len(opts.OakRun.DownloadURL) > 0 { + im.LocalOpts.OakRun.DownloadURL = opts.OakRun.DownloadURL + } + if len(opts.OakRun.StorePath) > 0 { + im.LocalOpts.OakRun.StorePath = opts.OakRun.StorePath + } } diff --git a/pkg/oak_run.go b/pkg/oak_run.go new file mode 100644 index 00000000..10006589 --- /dev/null +++ b/pkg/oak_run.go @@ -0,0 +1,116 @@ +package pkg + +import ( + "fmt" + log "github.com/sirupsen/logrus" + "github.com/wttech/aemc/pkg/common/httpx" + "github.com/wttech/aemc/pkg/common/osx" + "github.com/wttech/aemc/pkg/common/pathx" + "github.com/wttech/aemc/pkg/common/tplx" + "github.com/wttech/aemc/pkg/instance" + "os/exec" + "path/filepath" +) + +const ( + OakRunToolDirName = "oak-run" +) + +func NewOakRun(localOpts *LocalOpts) *OakRun { + return &OakRun{ + localOpts: localOpts, + + DownloadURL: "https://repo1.maven.org/maven2/org/apache/jackrabbit/oak-run/1.44.0/oak-run-1.44.0.jar", + StorePath: "crx-quickstart/repository/segmentstore", + } +} + +type OakRun struct { + localOpts *LocalOpts + + DownloadURL string + StorePath string +} + +type OakRunLock struct { + DownloadURL string `yaml:"download_url"` +} + +func (or OakRun) Dir() string { + return or.localOpts.ToolDir + "/" + OakRunToolDirName +} + +func (or OakRun) lock() osx.Lock[OakRunLock] { + return osx.NewLock(or.Dir()+"/lock/create.yml", func() OakRunLock { return OakRunLock{DownloadURL: or.DownloadURL} }) +} + +func (or OakRun) Prepare() error { + lock := or.lock() + + upToDate, err := lock.IsUpToDate() + if err != nil { + return err + } + if upToDate { + log.Debugf("existing instance Oak Run '%s' is up-to-date", lock.DataCurrent().DownloadURL) + return nil + } + log.Infof("preparing new instance Oak Run '%s'", lock.DataCurrent().DownloadURL) + err = or.prepare() + if err != nil { + return err + } + err = lock.Lock() + if err != nil { + return err + } + log.Infof("prepared new instance OakRun '%s'", lock.DataCurrent().DownloadURL) + + return nil +} + +func (or OakRun) JarFile() string { + return pathx.Abs(fmt.Sprintf("%s/%s", or.Dir(), filepath.Base(or.DownloadURL))) +} + +func (or OakRun) prepare() error { + jarFile := or.JarFile() + log.Infof("downloading Oak Run JAR from URL '%s' to file '%s'", or.DownloadURL, jarFile) + if err := httpx.DownloadOnce(or.DownloadURL, jarFile); err != nil { + return err + } + log.Infof("downloaded Oak Run JAR from URL '%s' to file '%s'", or.DownloadURL, jarFile) + return nil +} + +func (or OakRun) SetPassword(instanceDir string, user string, password string) error { + log.Infof("password setting for user '%s' on instance at dir '%s'", user, instanceDir) + + scriptFile := fmt.Sprintf("%s/%s/tmp/oak-run/set-password.groovy", instanceDir, LocalInstanceWorkDirName) + if err := tplx.RenderFile(scriptFile, instance.OakRunSetPassword, map[string]any{"User": user, "Password": password}); err != nil { + return err + } + defer func() { + pathx.DeleteIfExists(scriptFile) + }() + if err := or.RunScript(instanceDir, scriptFile); err != nil { + return err + } + log.Infof("password set for user '%s' on instance at dir '%s'", user, instanceDir) + return nil +} + +func (or OakRun) RunScript(instanceDir string, scriptFile string) error { + storeDir := fmt.Sprintf("%s/%s", instanceDir, or.StorePath) + // TODO https://issues.apache.org/jira/browse/OAK-5961 (handle JAnsi problem) + cmd := exec.Command("java", + "-Djava.io.tmpdir=aem/home/tmp", + "-jar", or.JarFile(), + "console", storeDir, "--read-write", fmt.Sprintf(":load %s", scriptFile), + ) + or.localOpts.manager.aem.CommandOutput(cmd) + if err := cmd.Run(); err != nil { + return fmt.Errorf("cannot run Oak Run script '%s': %w", scriptFile, err) + } + return nil +} diff --git a/pkg/package_manager.go b/pkg/package_manager.go index b6f4d961..ca03ff99 100644 --- a/pkg/package_manager.go +++ b/pkg/package_manager.go @@ -271,15 +271,17 @@ func (pm *PackageManager) Deploy(localPath string) error { func (pm *PackageManager) deployLock(file string, checksum string) osx.Lock[packageDeployLock] { name := filepath.Base(file) - return osx.NewLock(fmt.Sprintf("%s/package/deploy/%s.yml", pm.instance.local.LockDir(), name), packageDeployLock{ - Deployed: time.Now(), - Checksum: checksum, + return osx.NewLock(fmt.Sprintf("%s/package/deploy/%s.yml", pm.instance.local.LockDir(), name), func() packageDeployLock { + return packageDeployLock{ + Deployed: time.Now(), + Checksum: checksum, + } }) } type packageDeployLock struct { - Deployed time.Time - Checksum string + Deployed time.Time `yaml:"deployed"` + Checksum string `yaml:"checksum"` } func (pm *PackageManager) Uninstall(remotePath string) error { diff --git a/pkg/sdk.go b/pkg/sdk.go index cb92dfb4..81341fb3 100644 --- a/pkg/sdk.go +++ b/pkg/sdk.go @@ -10,28 +10,32 @@ import ( "path/filepath" ) +const ( + SdkToolDirName = "sdk" +) + +func NewSdk(localOpts *LocalOpts) *Sdk { + return &Sdk{localOpts: localOpts} +} + type Sdk struct { localOpts *LocalOpts } func (s Sdk) Dir() string { - return s.localOpts.UnpackDir + "/sdk" + return s.localOpts.ToolDir + "/" + SdkToolDirName } -func (s Sdk) lockFile() string { - return s.Dir() + "/lock/create.yml" +type SdkLock struct { + Version string `yaml:"version"` } func (s Sdk) lock(zipFile string) osx.Lock[SdkLock] { - return osx.NewLock(s.Dir()+"/lock/create.yml", SdkLock{ - Version: pathx.NameWithoutExt(zipFile), + return osx.NewLock(s.Dir()+"/lock/create.yml", func() SdkLock { + return SdkLock{Version: pathx.NameWithoutExt(zipFile)} }) } -type SdkLock struct { - Version string -} - func (s Sdk) Prepare(zipFile string) error { lock := s.lock(zipFile) diff --git a/pkg/status.go b/pkg/status.go index c45989f0..0dedf2bc 100644 --- a/pkg/status.go +++ b/pkg/status.go @@ -5,6 +5,7 @@ import ( "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/wttech/aemc/pkg/common/fmtx" + "github.com/wttech/aemc/pkg/instance" "io" "regexp" "strings" @@ -67,11 +68,11 @@ func (sm Status) TimeLocation() (*time.Location, error) { func (sm Status) AemVersion() (string, error) { response, err := sm.instance.http.Request().Get(SystemProductInfoPath) if err != nil { - return AemVersionUnknown, fmt.Errorf("cannot read system product info on instance '%s'", sm.instance.id) + return instance.AemVersionUnknown, fmt.Errorf("cannot read system product info on instance '%s'", sm.instance.id) } bytes, err := io.ReadAll(response.RawBody()) if err != nil { - return AemVersionUnknown, fmt.Errorf("cannot read system product info on instance '%s': %w", sm.instance.id, err) + return instance.AemVersionUnknown, fmt.Errorf("cannot read system product info on instance '%s': %w", sm.instance.id, err) } lines := string(bytes) for _, line := range strings.Split(lines, "\n") { @@ -80,5 +81,5 @@ func (sm Status) AemVersion() (string, error) { return matches[1], nil } } - return AemVersionUnknown, nil + return instance.AemVersionUnknown, nil } diff --git a/pkg/cfg/aem.yml b/project/aem/default/etc/aem.yml old mode 100644 new mode 100755 similarity index 88% rename from pkg/cfg/aem.yml rename to project/aem/default/etc/aem.yml index f5a390d8..119a8339 --- a/pkg/cfg/aem.yml +++ b/project/aem/default/etc/aem.yml @@ -11,11 +11,13 @@ instance: user: admin password: admin run_modes: [ local ] + jvm_opts: -Djava.io.tmpdir=aem/home/tmp -Duser.timezone=UTC -Duser.language=en-EN local_publish: http_url: http://127.0.0.1:4503 user: admin password: admin run_modes: [ local ] + jvm_opts: -Djava.io.tmpdir=aem/home/tmp -Duser.timezone=UTC -Duser.language=en-EN # Filters for defined filter: @@ -71,9 +73,14 @@ instance: # Managed locally (set up automatically) local: # Current runtime dir (Sling launchpad, JCR repository) - unpack_dir: "aem/home/data/instance" + unpack_dir: "aem/home/var/instance" # Archived runtime dir (AEM backup files '*.aemb.zst') - backup_dir: "aem/home/data/backup" + backup_dir: "aem/home/var/backup" + + # Oak Run tool options (offline instance management) + oakrun: + download_url: "https://repo1.maven.org/maven2/org/apache/jackrabbit/oak-run/1.44.0/oak-run-1.44.0.jar" + store_path: "crx-quickstart/repository/segmentstore" # Source files quickstart: @@ -144,4 +151,4 @@ input: output: format: text - file: aem/home/aem.log + file: aem/home/var/log/aem.log diff --git a/project/init.sh b/project/init.sh index 432f90bc..43633772 100644 --- a/project/init.sh +++ b/project/init.sh @@ -4,12 +4,13 @@ VERSION=${AEMC_VERSION:-"0.11.2"} SOURCE_URL="https://raw.githubusercontent.com/wttech/aemc/v${VERSION}/project" AEM_WRAPPER="aemw" + AEM_DIR="aem" SCRIPT_DIR="${AEM_DIR}/script" HOME_DIR="${AEM_DIR}/home" +DEFAULT_DIR="${AEM_DIR}/default" +DEFAULT_CONFIG_DIR="${DEFAULT_DIR}/etc" LIB_DIR="${HOME_DIR}/lib" -CONFIG_FILE="${HOME_DIR}/aem.yml" -SETUP_FILE="${SCRIPT_DIR}/setup.sh" if [ -f "$AEM_WRAPPER" ]; then echo "The project contains already AEM Compose!" @@ -19,7 +20,9 @@ fi echo "Downloading AEM Compose Files" echo "" -mkdir -p "$SCRIPT_DIR" "$HOME_DIR" +mkdir -p "${SCRIPT_DIR}" "${HOME_DIR}" "${DEFAULT_CONFIG_DIR}" "${LIB_DIR}" + +curl -s "${SOURCE_URL}/${DEFAULT_CONFIG_DIR}/aem.yml" -o "${DEFAULT_CONFIG_DIR}/aem.yml" curl -s "${SOURCE_URL}/${SCRIPT_DIR}/deploy.sh" -o "${SCRIPT_DIR}/deploy.sh" curl -s "${SOURCE_URL}/${SCRIPT_DIR}/destroy.sh" -o "${SCRIPT_DIR}/destroy.sh" curl -s "${SOURCE_URL}/${SCRIPT_DIR}/down.sh" -o "${SCRIPT_DIR}/down.sh" @@ -30,37 +33,13 @@ curl -s "${SOURCE_URL}/${SCRIPT_DIR}/up.sh" -o "${SCRIPT_DIR}/up.sh" curl -s "${SOURCE_URL}/${AEM_DIR}/api.sh" -o "${AEM_DIR}/api.sh" curl -s "${SOURCE_URL}/${AEM_WRAPPER}" -o "${AEM_WRAPPER}" -echo "Downloading & Running AEM Compose CLI" +echo "Downloading & Testing AEM Compose CLI" echo "" chmod +x "${AEM_WRAPPER}" sh ${AEM_WRAPPER} version -echo "Scaffolding AEM Compose configuration file" -echo "" - -./${AEM_WRAPPER} config init - -echo "Creating AEM Compose directories" -echo "" - -mkdir -p "$LIB_DIR" - -echo "Initialized AEM Compose" -echo "" - -echo "The next step is providing AEM files (JAR or SDK ZIP, license) to directory '${LIB_DIR}'" -echo "Alternatively, instruct the tool where these files are located by adjusting properties: 'dist_file', 'license_file' in configuration file '${CONFIG_FILE}'" -echo "Later on, remember to customise AEM instance setup in provisioning file '${SETUP_FILE}' for service pack installation, application build, etc." -echo "To avoid problems with IDE performance, make sure to exclude from indexing the directory '${HOME_DIR}'" -echo "Finally, use control scripts to manage AEM instances:" -echo "" - -echo "sh aemw [setup|resetup|up|down|restart]" - -echo "" -echo "It is also possible to run individual AEM Compose CLI commands separately." -echo "Discover available commands by running:" +echo "Success! Now initialize AEM Compose by running the command:" echo "" -echo "sh aemw --help" +echo "sh ${AEM_WRAPPER} init"