diff --git a/cmd/aem/instance.go b/cmd/aem/instance.go index 5c1cf37b..10bf97b1 100644 --- a/cmd/aem/instance.go +++ b/cmd/aem/instance.go @@ -24,6 +24,7 @@ func (c *CLI) instanceCmd() *cobra.Command { cmd.AddCommand(c.instanceAwaitCmd()) cmd.AddCommand(c.instanceBackupCmd()) cmd.AddCommand(c.instanceImportCmd()) + cmd.AddCommand(c.instanceUpgradeCmd()) return cmd } @@ -142,6 +143,32 @@ func (c *CLI) instanceCreateCmd() *cobra.Command { } } +func (c *CLI) instanceUpgradeCmd() *cobra.Command { + return &cobra.Command{ + Use: "upgrade", + Short: "Upgrades AEM instance(s) if needed", + Aliases: []string{"update"}, + Run: func(cmd *cobra.Command, args []string) { + localInstances, err := c.aem.InstanceManager().SomeLocals() + if err != nil { + c.Error(err) + return + } + upgradedInstances, err := c.aem.InstanceManager().Upgrade(localInstances) + if err != nil { + c.Error(err) + return + } + c.SetOutput("upgraded", upgradedInstances) + if len(upgradedInstances) > 0 { + c.Changed(fmt.Sprintf("upgraded instance(s) (%d)", len(upgradedInstances))) + } else { + c.Ok("no instance(s) to upgrade") + } + }, + } +} + func (c *CLI) instanceStartCmd() *cobra.Command { return &cobra.Command{ Use: "start", diff --git a/local.env b/local.env index 8efb421c..1d0e8537 100755 --- a/local.env +++ b/local.env @@ -8,4 +8,4 @@ AEM_AUTHOR_DEBUG_ADDR=127.0.0.1:14502 AEM_PUBLISH_USER=admin AEM_PUBLISH_PASSWORD=admin AEM_PUBLISH_HTTP_URL=http://localhost:4503 -AEM_PUBLISH_DEBUG_ADDR=127.0.0.1:14503 +AEM_PUBLISH_DEBUG_ADDR=127.0.0.1:14503 \ No newline at end of file diff --git a/pkg/local_instance.go b/pkg/local_instance.go index 09cc606e..f56efdd2 100644 --- a/pkg/local_instance.go +++ b/pkg/local_instance.go @@ -2,6 +2,15 @@ package pkg import ( "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "github.com/magiconair/properties" "github.com/wttech/aemc/pkg/common" "github.com/wttech/aemc/pkg/common/cryptox" @@ -12,14 +21,6 @@ import ( "github.com/wttech/aemc/pkg/common/stringsx" "github.com/wttech/aemc/pkg/common/timex" "github.com/wttech/aemc/pkg/instance" - "os" - "os/exec" - "path/filepath" - "regexp" - "sort" - "strconv" - "strings" - "time" "github.com/samber/lo" log "github.com/sirupsen/logrus" @@ -50,15 +51,16 @@ type LocalInstanceState struct { } const ( - LocalInstanceScriptStart = "start" - LocalInstanceScriptStop = "stop" - LocalInstanceScriptStatus = "status" - LocalInstanceBackupExtension = "aemb.tar.zst" - LocalInstanceUser = "admin" - LocalInstanceWorkDirName = common.AppId - LocalInstanceNameCommon = "common" - LocalInstanceSecretsDir = "conf/secret" - LocalInstanceVersionDefault = "1" + LocalInstanceScriptStart = "start" + LocalInstanceScriptStop = "stop" + LocalInstanceScriptStatus = "status" + LocalInstanceScriptQuickstart = "quickstart" + LocalInstanceBackupExtension = "aemb.tar.zst" + LocalInstanceUser = "admin" + LocalInstanceWorkDirName = common.AppId + LocalInstanceNameCommon = "common" + LocalInstanceSecretsDir = "conf/secret" + LocalInstanceVersionDefault = "1" ) func (li LocalInstance) Instance() *Instance { @@ -193,12 +195,24 @@ func (li LocalInstance) CheckRecreationNeeded() error { return err } if !state.UpToDate { - return fmt.Errorf("%s > outdated and need to be recreated as distribution JAR changed from '%s' to '%s'", li.instance.IDColor(), state.Locked.JarName, state.Current.JarName) + return fmt.Errorf("%s > outdated and need to be upgraded as distribution JAR changed from '%s' to '%s'; consider using upgrade command", li.instance.IDColor(), state.Locked.JarName, state.Current.JarName) } } return nil } +func (li LocalInstance) IsUpgradeNeeded() (bool, error) { + createLock := li.createLock() + if createLock.IsLocked() { + state, err := createLock.State() + if err != nil { + return false, err + } + return !state.UpToDate, nil + } + return false, nil +} + func (li LocalInstance) CheckPassword() error { if !LocalInstancePasswordRegex.MatchString(li.instance.password) { return fmt.Errorf("%s > password does not match regex '%s'", li.instance.IDColor(), LocalInstancePasswordRegex) @@ -241,6 +255,40 @@ func (li LocalInstance) Create() error { return nil } +// Upgrade performs in-place upgrade of the AEM instance. +// See: https://experienceleague.adobe.com/en/docs/experience-manager-65/content/implementing/deploying/upgrading/in-place-upgrade +func (li LocalInstance) Upgrade() error { + if !li.IsCreated() { + return fmt.Errorf("%s > cannot upgrade as it is not created", li.instance.IDColor()) + } + if li.IsRunning() { + return fmt.Errorf("%s > cannot upgrade as it is running", li.instance.IDColor()) + } + log.Infof("%s > upgrading", li.instance.IDColor()) + + // Reset init lock to force re-initialization of sling.properties and other configs on next start. + // This is needed because unpack overwrites sling.properties with default values from the new JAR + if err := li.initLock().Unlock(); err != nil { + return err + } + // Remove old bin scripts so that unpack can replace them with new versions + if err := li.cleanupBinScripts(); err != nil { + return err + } + if err := li.unpackJarFile(); err != nil { + return err + } + if err := li.copyLicenseFile(); err != nil { + return err + } + if err := li.adapt(); err != nil { + return err + } + + log.Infof("%s > upgraded", li.instance.IDColor()) + return nil +} + func (li LocalInstance) Import() error { log.Infof("%s > importing", li.instance.IDColor()) @@ -294,6 +342,68 @@ func (li LocalInstance) unpackJarFile() error { if !pathx.Exists(startScript) { return fmt.Errorf("%s > unpacking files went wrong as e.g start script does not exist '%s'", li.instance.IDColor(), startScript) } + if err := li.cleanupOutdatedAppJars(); err != nil { + return err + } + return nil +} + +// cleanupOutdatedAppJars removes old JAR files from crx-quickstart/app after upgrade. +// When upgrading AEM, the new quickstart JAR unpacks a new app JAR but leaves the old one. +// This causes conflicts, so we keep only the newest JAR file. +func (li LocalInstance) cleanupOutdatedAppJars() error { + appDir := filepath.Join(li.QuickstartDir(), "app") + jarFiles, err := filepath.Glob(filepath.Join(appDir, "*.jar")) + if err != nil { + return fmt.Errorf("%s > error searching for JAR files in app dir: %w", li.instance.IDColor(), err) + } + if len(jarFiles) <= 1 { + return nil + } + type jarInfo struct { + path string + modTime time.Time + } + var jars []jarInfo + for _, path := range jarFiles { + info, err := os.Stat(path) + if err == nil { + jars = append(jars, jarInfo{path, info.ModTime()}) + } + } + sort.Slice(jars, func(i, j int) bool { + return jars[i].modTime.After(jars[j].modTime) + }) + for _, ji := range jars[1:] { + log.Infof("%s > removing outdated JAR from app dir: %s", li.instance.IDColor(), ji.path) + if err := os.Remove(ji.path); err != nil { + return fmt.Errorf("%s > cannot remove outdated JAR '%s': %w", li.instance.IDColor(), ji.path, err) + } + } + return nil +} + +// cleanupBinScripts removes old bin scripts before upgrade so that unpack can replace them with new versions. +// Without this, AEM unpack detects modified files and creates .NEW_ copies instead of overwriting. +func (li LocalInstance) cleanupBinScripts() error { + binDir := filepath.Join(li.QuickstartDir(), "bin") + if !pathx.Exists(binDir) { + return nil + } + scripts := []string{ + LocalInstanceScriptStart, LocalInstanceScriptStart + ".bat", + LocalInstanceScriptStop, LocalInstanceScriptStop + ".bat", + LocalInstanceScriptStatus, LocalInstanceScriptStatus + ".bat", + LocalInstanceScriptQuickstart, LocalInstanceScriptQuickstart + ".bat", + } + for _, script := range scripts { + scriptPath := filepath.Join(binDir, script) + if pathx.Exists(scriptPath) { + if err := os.Remove(scriptPath); err != nil { + return fmt.Errorf("%s > cannot remove old bin script '%s': %w", li.instance.IDColor(), scriptPath, err) + } + } + } return nil } diff --git a/pkg/local_instance_manager.go b/pkg/local_instance_manager.go index 5f99fe83..9dfe3aab 100644 --- a/pkg/local_instance_manager.go +++ b/pkg/local_instance_manager.go @@ -3,15 +3,16 @@ package pkg import ( "bytes" "fmt" + "os" + "strings" + "time" + "github.com/dustin/go-humanize" "github.com/samber/lo" log "github.com/sirupsen/logrus" "github.com/wttech/aemc/pkg/common/fmtx" "github.com/wttech/aemc/pkg/common/pathx" "github.com/wttech/aemc/pkg/common/timex" - "os" - "strings" - "time" ) type LocalOpts struct { @@ -70,9 +71,12 @@ func (o *LocalOpts) Initialize() error { if _, err := o.manager.aem.vendorManager.oakRun.PrepareWithChanged(); err != nil { return err } - // post-validation phase + return nil +} + +func (o *LocalOpts) CheckRecreationNeeded() error { for _, instance := range o.manager.Locals() { - if err := instance.Local().CheckRecreationNeeded(); err != nil { // depends on SDK prepare + if err := instance.Local().CheckRecreationNeeded(); err != nil { return err } } @@ -129,11 +133,18 @@ func (im *InstanceManager) CreateAll() ([]Instance, error) { return im.Create(im.Locals()) } +func (im *InstanceManager) UpgradeAll() ([]Instance, error) { + return im.Upgrade(im.Locals()) +} + func (im *InstanceManager) Create(instances []Instance) ([]Instance, error) { created := []Instance{} if err := im.LocalOpts.Initialize(); err != nil { return created, err } + if err := im.LocalOpts.CheckRecreationNeeded(); err != nil { + return created, err + } log.Info(InstancesMsg(instances, "creating")) for _, i := range instances { if !i.local.IsCreated() { @@ -147,6 +158,31 @@ func (im *InstanceManager) Create(instances []Instance) ([]Instance, error) { return created, nil } +func (im *InstanceManager) Upgrade(instances []Instance) ([]Instance, error) { + upgraded := []Instance{} + if err := im.LocalOpts.Initialize(); err != nil { + return upgraded, err + } + log.Info(InstancesMsg(instances, "upgrading")) + for _, i := range instances { + if !i.local.IsCreated() { + return nil, fmt.Errorf("instance not yet created: %s", i.IDColor()) + } + upgradeNeeded, err := i.local.IsUpgradeNeeded() + if err != nil { + return nil, err + } + if upgradeNeeded { + err := i.local.Upgrade() + if err != nil { + return nil, err + } + upgraded = append(upgraded, i) + } + } + return upgraded, nil +} + func (im *InstanceManager) Import(instances []Instance) ([]Instance, error) { imported := []Instance{} log.Info(InstancesMsg(instances, "importing")) @@ -209,6 +245,9 @@ func (im *InstanceManager) Start(instances []Instance) ([]Instance, error) { if err := im.LocalOpts.Initialize(); err != nil { return []Instance{}, err } + if err := im.LocalOpts.CheckRecreationNeeded(); err != nil { + return []Instance{}, err + } if !im.LocalOpts.ServiceMode { log.Info(InstancesMsg(instances, "checking started & out-of-date"))