├── .editorconfig ├── .goreleaser.yaml ├── .java-version ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd └── aem │ ├── auth.go │ ├── cli.go │ ├── config.go │ ├── content.go │ ├── crypto.go │ ├── file.go │ ├── gts.go │ ├── instance.go │ ├── instance_backup.go │ ├── main.go │ ├── oak.go │ ├── osgi.go │ ├── package.go │ ├── project.go │ ├── repl.go │ ├── repo.go │ ├── root.go │ ├── ssl.go │ ├── user.go │ ├── vendor.go │ └── version.go ├── docs ├── adapto-to-video.png ├── apache-license-badge.svg ├── cli-demo-short.gif ├── cli-help-home.png ├── cli-help-instance.png ├── cli-help-pkg.png ├── cli-main.png ├── logo-only.svg ├── logo-with-text.png └── wtt-logo.png ├── examples └── docker │ ├── .dockerignore │ ├── README.md │ ├── author.Dockerfile │ ├── build-all.sh │ ├── build.sh │ ├── docker-compose.yml │ ├── publish.Dockerfile │ ├── rebuild.sh │ ├── run-all.sh │ ├── src │ ├── aem │ │ └── default │ │ │ └── etc │ │ │ └── aem.yml │ ├── aemw │ └── local.env │ └── test.sh ├── gh-md-toc ├── go.mod ├── go.sum ├── pkg ├── auth.go ├── base.go ├── cfg │ ├── config.go │ └── defaults.go ├── check.go ├── common │ ├── certx │ │ └── certx.go │ ├── constants.go │ ├── cryptox │ │ └── cryptox.go │ ├── execx │ │ └── execx.go │ ├── filex │ │ ├── archive.go │ │ └── filex.go │ ├── fmtx │ │ ├── serialization.go │ │ ├── tbl.go │ │ └── tbl_test.go │ ├── httpx │ │ └── httpx.go │ ├── intsx │ │ └── intsx.go │ ├── langx │ │ └── langx.go │ ├── lox │ │ └── lox.go │ ├── mapsx │ │ └── mapsx.go │ ├── netx │ │ └── netx.go │ ├── osx │ │ ├── lock.go │ │ └── osx.go │ ├── pathx │ │ └── pathx.go │ ├── stringsx │ │ └── stringsx.go │ ├── timex │ │ └── timex.go │ └── tplx │ │ └── tplx.go ├── content │ ├── editor.go │ └── zipper.go ├── content_manager.go ├── crypto.go ├── facade.go ├── gts │ ├── gts_certificate.go │ └── gts_status.go ├── gts_manager.go ├── http.go ├── http_int_test.go ├── instance.go ├── instance │ ├── constants.go │ ├── resource │ │ ├── cbpow.exe │ │ └── oak-run │ │ │ └── set-password.groovy │ ├── utils.go │ └── utils_test.go ├── instance_int_test.go ├── instance_manager.go ├── instance_manager_check.go ├── instance_test.go ├── java_manager.go ├── keystore │ ├── private_key.go │ └── status.go ├── keystore_manager.go ├── local_instance.go ├── local_instance_manager.go ├── oak.go ├── oak │ ├── constants.go │ └── index.go ├── oak_index.go ├── oak_index_manager.go ├── oak_run.go ├── osgi.go ├── osgi │ ├── bundle.go │ ├── component.go │ ├── config.go │ ├── event.go │ └── manifest.go ├── osgi_bundle.go ├── osgi_bundle_manager.go ├── osgi_component.go ├── osgi_component_manager.go ├── osgi_config.go ├── osgi_config_manager.go ├── osgi_event.go ├── osgi_event_manager.go ├── osgi_int_test.go ├── package.go ├── package_manager.go ├── pkg │ ├── api.go │ ├── common.go │ ├── constants.go │ ├── pid.go │ ├── vault.go │ └── vault │ │ ├── META-INF │ │ ├── MANIFEST.MF │ │ └── vault │ │ │ ├── config.xml │ │ │ ├── definition │ │ │ ├── $.content.xml │ │ │ └── thumbnail.png │ │ │ ├── filter.xml │ │ │ ├── nodetypes.cnd │ │ │ └── properties.xml │ │ └── jcr_root │ │ └── $.content.xml ├── project.go ├── project │ ├── app_classic │ │ ├── Taskfile.yml │ │ ├── aem │ │ │ └── default │ │ │ │ └── etc │ │ │ │ └── aem.yml │ │ ├── dispatcher │ │ │ ├── Dockerfile │ │ │ ├── compose.yaml │ │ │ ├── docker │ │ │ │ ├── httpd-foreground │ │ │ │ └── src │ │ │ │ │ ├── conf.d │ │ │ │ │ ├── custom.conf │ │ │ │ │ ├── proxy │ │ │ │ │ │ └── mock.proxy │ │ │ │ │ ├── rewrites │ │ │ │ │ │ └── xforwarded_forcessl_rewrite.rules │ │ │ │ │ └── variables │ │ │ │ │ │ └── default.vars │ │ │ │ │ └── conf.dispatcher.d │ │ │ │ │ └── cache │ │ │ │ │ ├── ams_author_invalidate_allowed.any │ │ │ │ │ └── ams_publish_invalidate_allowed.any │ │ │ └── test.sh │ │ └── local.env │ ├── app_cloud │ │ ├── Taskfile.yml │ │ ├── aem │ │ │ └── default │ │ │ │ └── etc │ │ │ │ └── aem.yml │ │ ├── dispatcher │ │ │ └── compose.yaml │ │ └── local.env │ ├── common │ │ ├── $.mvn │ │ │ └── wrapper │ │ │ │ ├── maven-wrapper.jar │ │ │ │ └── maven-wrapper.properties │ │ ├── aemw │ │ ├── mvnw │ │ ├── mvnw.cmd │ │ └── taskw │ ├── constants.go │ ├── files.go │ └── instance │ │ ├── Taskfile.yml │ │ ├── aem │ │ └── default │ │ │ └── etc │ │ │ └── aem.yml │ │ └── local.env ├── repl.go ├── repl_agent.go ├── replication │ └── const.go ├── repo.go ├── repo │ └── api.go ├── repo_int_test.go ├── repo_node.go ├── sdk.go ├── sling.go ├── sling │ └── html_response.go ├── sling_installer.go ├── sling_jmx.go ├── ssl.go ├── status.go ├── user │ └── status.go ├── user_manager.go ├── vendor_manager.go ├── workflow.go └── workflow_manager.go ├── project-install.sh ├── release-snapshot.sh ├── release.sh ├── revive.toml └── test ├── content_manager_test.go ├── filter_file_test.go ├── filter_root_test.go ├── resources ├── exclude_patterns.xml ├── exclude_patterns_update.xml ├── filter.xml ├── filter_roots.xml ├── filter_roots_update.xml ├── main_content │ ├── META-INF │ │ ├── MANIFEST.MF │ │ └── vault │ │ │ ├── config.xml │ │ │ ├── definition │ │ │ └── $.content.xml │ │ │ ├── filter.xml │ │ │ ├── nodetypes.cnd │ │ │ └── properties.xml │ └── jcr_root │ │ ├── apps │ │ └── mysite │ │ │ └── components │ │ │ └── helloworld │ │ │ ├── $.content.xml │ │ │ ├── $_cq_dialog.xml │ │ │ ├── $_cq_editConfig.xml │ │ │ ├── $_cq_template.xml │ │ │ └── helloworld.html │ │ ├── conf │ │ └── mysite │ │ │ ├── $.content.xml │ │ │ ├── $_sling_configs │ │ │ ├── $.content.xml │ │ │ └── com.mysite.pdfviewer.PdfViewerCaConfig │ │ │ │ └── $.content.xml │ │ │ └── settings │ │ │ └── wcm │ │ │ ├── $.content.xml │ │ │ ├── policies │ │ │ ├── $.content.xml │ │ │ └── $_rep_policy.xml │ │ │ └── template-types │ │ │ ├── $.content.xml │ │ │ └── page │ │ │ └── $.content.xml │ │ ├── content │ │ └── mysite │ │ │ ├── $.content.xml │ │ │ └── us │ │ │ ├── $.content.xml │ │ │ └── en │ │ │ └── $.content.xml │ │ └── var │ │ └── workflow │ │ └── models │ │ └── mysite │ │ ├── $.content.xml │ │ └── asset_processing.xml ├── new_content │ └── jcr_root │ │ ├── apps │ │ └── mysite │ │ │ └── components │ │ │ └── helloworld │ │ │ ├── $.content.xml │ │ │ ├── $_cq_dialog.xml │ │ │ ├── $_cq_editConfig.xml │ │ │ ├── $_cq_template.xml │ │ │ ├── $_cq_template │ │ │ └── $.content.xml │ │ │ └── helloworld.html │ │ ├── conf │ │ └── mysite │ │ │ ├── $.content.xml │ │ │ ├── $_sling_configs │ │ │ ├── $.content.xml │ │ │ └── com.mysite.pdfviewer.PdfViewerCaConfig │ │ │ │ └── $.content.xml │ │ │ └── settings │ │ │ └── wcm │ │ │ ├── $.content.xml │ │ │ ├── policies │ │ │ ├── $.content.xml │ │ │ └── $_rep_policy.xml │ │ │ └── template-types │ │ │ ├── $.content.xml │ │ │ └── page │ │ │ └── $.content.xml │ │ ├── content │ │ └── mysite │ │ │ ├── $.content.xml │ │ │ └── us │ │ │ ├── $.content.xml │ │ │ └── en │ │ │ └── $.content.xml │ │ └── var │ │ └── workflow │ │ └── models │ │ └── mysite │ │ ├── $.content.xml │ │ └── asset_processing.xml └── repo │ └── jcr_root │ ├── apps │ └── mysite │ │ └── components │ │ └── helloworld │ │ ├── $_cq_editConfig.xml │ │ ├── $_cq_template │ │ └── $.content.xml │ │ └── helloworld.html │ ├── conf │ └── mysite │ │ └── $_sling_configs │ │ ├── $.content.xml │ │ └── com.mysite.pdfviewer.PdfViewerCaConfig │ │ └── $.content.xml │ └── content │ └── mysite │ └── us │ └── en │ └── $.content.xml └── sync_file_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | # Make sure to check the documentation at https://goreleaser.com 4 | before: 5 | hooks: 6 | - go install github.com/mgechev/revive@v1.3.3 7 | - go mod tidy 8 | 9 | builds: 10 | - id: aemc-cli 11 | binary: aem 12 | main: './cmd/aem' 13 | tags: 14 | - aem 15 | - cli 16 | - provision 17 | - config_mgmt 18 | - timetzdata 19 | env: 20 | - CGO_ENABLED=0 21 | goos: 22 | - linux 23 | - windows 24 | - darwin 25 | 26 | ldflags: 27 | - "-s -w -X main.appVersion={{ .Version }} -X main.appCommit={{ .Commit }} -X main.appCommitDate={{.CommitDate}}" 28 | 29 | dockers: 30 | - id: aemc-cli 31 | image_templates: 32 | - "ghcr.io/wttech/aemc-cli:{{ .Tag }}" 33 | - "ghcr.io/wttech/aemc-cli:latest" 34 | ids: 35 | - aemc-cli 36 | 37 | archives: 38 | - id: aemc-cli 39 | builds: 40 | - aemc-cli 41 | name_template: "aemc-cli_{{ .Os }}_{{ .Arch }}" 42 | format_overrides: 43 | - goos: windows 44 | format: zip 45 | 46 | checksum: 47 | name_template: 'checksums.txt' 48 | 49 | snapshot: 50 | version_template: "{{ incpatch .Version }}-next" 51 | 52 | changelog: 53 | sort: asc 54 | filters: 55 | exclude: 56 | - '^docs:' 57 | - '^test:' 58 | -------------------------------------------------------------------------------- /.java-version: -------------------------------------------------------------------------------- 1 | 11 2 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | ![AEM Compose Logo](https://github.com/wttech/aemc-ansible/raw/main/docs/logo-with-text.png) 2 | [![WTT Logo](https://github.com/wttech/aemc-ansible/raw/main/docs/wtt-logo.png)](https://www.wundermanthompson.com/service/technology) 3 | 4 | [![Apache License, Version 2.0, January 2004](https://github.com/wttech/aemc-ansible/raw/main/docs/apache-license-badge.svg)](http://www.apache.org/licenses/) 5 | 6 | **AEM Compose** 7 | 8 | Universal tool to manage AEM instances everywhere! 9 | 10 | # Developer setup 11 | 12 | ## Prerequisites 13 | 14 | 1. Install Go: , 15 | 2. Set up shell, append lines *~/.zshrc* with content below then restart IDE/terminals, 16 | 17 | ```shell 18 | export GOPATH="$HOME/go" 19 | export PATH="$GOPATH/bin:$PATH" 20 | ``` 21 | 22 | 3. Setup IDE: 23 | - IntelliJ IDEA 24 | - Install [Go Plugin](https://plugins.jetbrains.com/plugin/9568-go) 25 | - Remember to "Enable Go Modules" in settings to fix syntax highlighting and autocompletion. 26 | 27 | ## Building & OS-wide installation 28 | 29 | Ensure having installed [Go](https://go.dev/dl/) then: 30 | 31 | ### Manual installation (recommended) 32 | 33 | Use this method to develop comfortably the tool. 34 | 35 | 1. Clone repository: `git clone git@github.com:wttech/aemc.git` 36 | 2. Enter cloned directory and run command: `make`* 37 | 38 | *When using Git Bash on Windows, you will first need to add `make` to your Git Bash installation: 39 | 1. Go to [ezwinports](https://sourceforge.net/projects/ezwinports/files/). 40 | 2. Download `make-x.x.x-without-guile-w32-bin.zip` (get the newest version without guile). 41 | 3. Extract zip. 42 | 4. Copy the contents to your `Git\mingw64\` merging the folders, but do NOT overwrite/replace any existing files. 43 | 44 | ### Go installation 45 | 46 | Use this method to check particular commit/version of the tool. 47 | 48 | - latest released version: `go install github.com/wttech/aemc/cmd/aem@latest`, 49 | - specific released version: `go install github.com/wttech/aemc/cmd/aem@v1.1.9`, 50 | - recently committed version: `go install github.com/wttech/aemc/cmd/aem@main`, 51 | 52 | After installing AEM CLI by one of above methods now instruct the [wrapper script](pkg/project/common/aemw) to use it by running the following command: 53 | 54 | ```shell 55 | export AEM_CLI_VERSION=installed 56 | ``` 57 | 58 | To start using again version defined in wrapper file, simply unset the environment variable: 59 | 60 | ```shell 61 | unset AEM_CLI_VERSION 62 | ``` 63 | 64 | ## Releasing 65 | 66 | Simply run script: 67 | 68 | ```shell 69 | sh release.sh 70 | ``` 71 | 72 | It will: 73 | 74 | * bump version is source files automatically, 75 | * commit changes, 76 | * push release tag that will initiate [release workflow](.github/workflows/release-perform.yml). 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | LABEL org.opencontainers.image.description="AEM Compose CLI - Universal tool to manage AEM instances everywhere!" 4 | LABEL org.opencontainers.image.source="https://github.com/wttech/aemc" 5 | LABEL org.opencontainers.image.vendor="Wunderman Thompson Technology" 6 | LABEL org.opencontainers.image.authors="krystian.panek@wundermanthompson.com" 7 | LABEL org.opencontainers.image.licenses="Apache-2.0" 8 | 9 | ENTRYPOINT ["/aem"] 10 | COPY aem / 11 | WORKDIR /project 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .GIT_COMMIT=$(shell git rev-parse HEAD) 2 | .GIT_COMMIT_DATE=$(shell git log -1 --date=format:'%Y-%m-%dT%H:%M:%S' --format=%cd) 3 | .GIT_VERSION=$(shell git describe --tags 2>/dev/null || echo "$(.GIT_COMMIT)") 4 | .LD_FLAGS=$(shell echo "-s -w -X main.appVersion=${.GIT_VERSION} -X main.appCommit=${.GIT_COMMIT} -X main.appCommitDate=${.GIT_COMMIT_DATE}") 5 | .TAGS=timetzdata 6 | 7 | all: deps test vet fmt lint install 8 | 9 | deps: 10 | go install github.com/mgechev/revive@v1.3.3 11 | go mod tidy 12 | 13 | test: 14 | go test ./... 15 | 16 | int_test: 17 | go test -tags=int_test ./... 18 | 19 | vet: 20 | go vet ./... 21 | 22 | fmt: 23 | gofmt -l -w . 24 | 25 | lint: 26 | revive -config revive.toml -formatter friendly ./... 27 | 28 | build: 29 | go build -tags "${.TAGS}" --ldflags "${.LD_FLAGS}" -o bin/aem ./cmd/aem 30 | 31 | install: 32 | go install -tags "${.TAGS}" --ldflags "${.LD_FLAGS}" ./cmd/aem 33 | 34 | other_build: 35 | GOARCH=amd64 GOOS=darwin go build -tags "${.TAGS}" --ldflags "${.LD_FLAGS}" -o bin/aem.darwin ./cmd/aem 36 | GOARCH=amd64 GOOS=linux go build -tags "${.TAGS}" --ldflags "${.LD_FLAGS}" -o bin/aem.linux ./cmd/aem 37 | GOARCH=amd64 GOOS=windows go build -tags "${.TAGS}" --ldflags "${.LD_FLAGS}" -o bin/aem.exe ./cmd/aem 38 | 39 | clean: 40 | go clean 41 | rm -fr bin 42 | -------------------------------------------------------------------------------- /cmd/aem/auth.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/spf13/cobra" 4 | 5 | func (c *CLI) authCmd() *cobra.Command { 6 | cmd := &cobra.Command{ 7 | Use: "auth", 8 | Short: "Auth management", 9 | } 10 | cmd.AddCommand(c.userCmd()) 11 | return cmd 12 | } 13 | -------------------------------------------------------------------------------- /cmd/aem/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "github.com/wttech/aemc/pkg/cfg" 7 | "github.com/wttech/aemc/pkg/common/tplx" 8 | ) 9 | 10 | func (c *CLI) configCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "config", 13 | Aliases: []string{"cfg"}, 14 | Short: "Manages AEMC configuration", 15 | } 16 | cmd.AddCommand(c.configInitCmd()) 17 | cmd.AddCommand(c.configExportCmd()) 18 | cmd.AddCommand(c.configValueCmd()) 19 | cmd.AddCommand(c.configValuesCmd()) 20 | return cmd 21 | } 22 | 23 | func (c *CLI) configInitCmd() *cobra.Command { 24 | cmd := &cobra.Command{ 25 | Use: "initialize", 26 | Aliases: []string{"init"}, 27 | Short: "Initialize configuration", 28 | Run: func(cmd *cobra.Command, args []string) { 29 | changed, err := c.aem.Config().InitializeWithChanged() 30 | if err != nil { 31 | c.Error(err) 32 | return 33 | } 34 | c.SetOutput("path", cfg.File()) 35 | if changed { 36 | c.Changed("config initialized") 37 | } else { 38 | c.Ok("config already initialized") 39 | } 40 | }, 41 | } 42 | return cmd 43 | } 44 | 45 | func (c *CLI) configExportCmd() *cobra.Command { 46 | cmd := &cobra.Command{ 47 | Use: "export", 48 | Aliases: []string{"save"}, 49 | Short: "Exports current configuration", 50 | Run: func(cmd *cobra.Command, args []string) { 51 | file, _ := cmd.Flags().GetString("file") 52 | if err := c.aem.Config().Export(file); err != nil { 53 | c.Error(err) 54 | return 55 | } 56 | c.SetOutput("file", file) 57 | c.Changed("config exported") 58 | }, 59 | } 60 | cmd.Flags().StringP("file", "f", "", "Target file path") 61 | _ = cmd.MarkFlagRequired("file") 62 | return cmd 63 | } 64 | 65 | func (c *CLI) configValuesCmd() *cobra.Command { 66 | cmd := &cobra.Command{ 67 | Use: "values", 68 | Aliases: []string{"get-all"}, 69 | Short: "Read all configuration values", 70 | Run: func(cmd *cobra.Command, args []string) { 71 | c.SetOutput("file", cfg.FileEffective()) 72 | c.SetOutput("values", c.aem.Config().Values().AllSettings()) 73 | c.Ok("config values read") 74 | }, 75 | } 76 | return cmd 77 | } 78 | 79 | func (c *CLI) configValueCmd() *cobra.Command { 80 | cmd := &cobra.Command{ 81 | Use: "value", 82 | Short: "Read configuration value", 83 | Aliases: []string{"get"}, 84 | Run: func(cmd *cobra.Command, args []string) { 85 | key, _ := cmd.Flags().GetString("key") 86 | template, _ := cmd.Flags().GetString("template") 87 | if key == "" && template == "" { 88 | c.Fail("flag 'key' or 'template' need to be specified") 89 | return 90 | } 91 | var ( 92 | value string 93 | err error 94 | ) 95 | if key != "" { 96 | value, err = tplx.RenderKey(key, c.aem.Config().Values().AllSettings()) 97 | if err != nil { 98 | c.Error(fmt.Errorf("cannot read config value using key '%s': %w", key, err)) 99 | return 100 | } 101 | } else { 102 | value, err = tplx.RenderString(template, c.aem.Config().Values().AllSettings()) 103 | if err != nil { 104 | c.Error(fmt.Errorf("cannot read config value using template '%s': %w", template, err)) 105 | return 106 | } 107 | } 108 | c.SetOutput("value", value) 109 | c.Ok("config value read") 110 | }, 111 | } 112 | cmd.Flags().StringP("key", "k", "", "Value key") 113 | cmd.Flags().StringP("template", "t", "", "Value template") 114 | cmd.MarkFlagsMutuallyExclusive("key", "template") 115 | return cmd 116 | } 117 | -------------------------------------------------------------------------------- /cmd/aem/crypto.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/wttech/aemc/pkg" 6 | "github.com/wttech/aemc/pkg/common/mapsx" 7 | ) 8 | 9 | func (c *CLI) cryptoCmd() *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "crypto", 12 | Short: "Manages Crypto Support", 13 | } 14 | cmd.AddCommand(c.cryptoSetupCmd()) 15 | cmd.AddCommand(c.cryptoProtectCmd()) 16 | return cmd 17 | } 18 | 19 | func (c *CLI) cryptoSetupCmd() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "setup", 22 | Aliases: []string{"configure"}, 23 | Short: "Setup keys", 24 | Run: func(cmd *cobra.Command, args []string) { 25 | instances, err := c.aem.InstanceManager().Some() 26 | if err != nil { 27 | c.Error(err) 28 | return 29 | } 30 | 31 | hmacFile, _ := cmd.Flags().GetString("hmac-file") 32 | masterFile, _ := cmd.Flags().GetString("master-file") 33 | 34 | configured, err := pkg.InstanceProcess(c.aem, instances, func(instance pkg.Instance) (map[string]any, error) { 35 | changed, err := instance.Crypto().Setup(hmacFile, masterFile) 36 | if err != nil { 37 | return nil, err 38 | } 39 | return map[string]any{ 40 | OutputChanged: changed, 41 | OutputInstance: instance, 42 | }, nil 43 | }) 44 | if err != nil { 45 | c.Error(err) 46 | return 47 | } 48 | c.SetOutput("configured", configured) 49 | 50 | if mapsx.SomeHas(configured, OutputChanged, true) { 51 | if err := c.aem.InstanceManager().AwaitStarted(InstancesChanged(configured)); err != nil { 52 | c.Error(err) 53 | return 54 | } 55 | c.Changed("Crypto set up") 56 | } else { 57 | c.Ok("Crypto already set up (up-to-date)") 58 | } 59 | }, 60 | } 61 | libDir := c.config.Values().GetString("base.lib_dir") 62 | cmd.Flags().String("hmac-file", libDir+"/crypto/data/hmac", "Path to file 'hmac'") 63 | cmd.Flags().String("master-file", libDir+"/crypto/data/master", "Path to file 'master'") 64 | return cmd 65 | } 66 | 67 | func (c *CLI) cryptoProtectCmd() *cobra.Command { 68 | cmd := &cobra.Command{ 69 | Use: "protect", 70 | Aliases: []string{"encrypt"}, 71 | Short: "Protect value", 72 | Run: func(cmd *cobra.Command, args []string) { 73 | instance, err := c.aem.InstanceManager().One() 74 | if err != nil { 75 | c.Error(err) 76 | return 77 | } 78 | plainValue, _ := cmd.Flags().GetString("value") 79 | protectedValue, err := instance.Crypto().Protect(plainValue) 80 | if err != nil { 81 | c.Error(err) 82 | return 83 | } 84 | c.SetOutput("value", protectedValue) 85 | c.Ok("value protected by Crypto") 86 | }, 87 | } 88 | 89 | cmd.Flags().StringP("value", "v", "", "Value to protect") 90 | _ = cmd.MarkFlagRequired("value") 91 | 92 | return cmd 93 | } 94 | -------------------------------------------------------------------------------- /cmd/aem/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/wttech/aemc/pkg/common/osx" 5 | ) 6 | 7 | func main() { 8 | osx.EnvVarsLoad() 9 | 10 | cli := NewCLI() 11 | cli.MustExec() 12 | } 13 | -------------------------------------------------------------------------------- /cmd/aem/project.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "github.com/wttech/aemc/pkg/project" 7 | "strings" 8 | ) 9 | 10 | func (c *CLI) projectCmd() *cobra.Command { 11 | cmd := &cobra.Command{ 12 | Use: "project", 13 | Short: "Manage project files", 14 | Aliases: []string{"prj"}, 15 | } 16 | cmd.AddCommand(c.projectInitCmd()) 17 | cmd.AddCommand(c.projectScaffoldCmd()) 18 | 19 | return cmd 20 | } 21 | 22 | const projectKindFlag = "project-kind" 23 | 24 | func (c *CLI) projectScaffoldCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "scaffold", 27 | Aliases: []string{"setup"}, 28 | Short: "Scaffold required files in the project", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | kindName, _ := cmd.Flags().GetString(projectKindFlag) 31 | kind, err := c.aem.Project().KindDetermine(kindName) 32 | if err != nil { 33 | c.Error(err) 34 | return 35 | } 36 | if kind == project.KindUnknown { 37 | c.Fail(fmt.Sprintf("project kind cannot be determined; specify it with flag '--%s=[%s]'", projectKindFlag, strings.Join(project.KindStrings(), "|"))) 38 | return 39 | } 40 | 41 | changed, err := c.aem.Project().ScaffoldWithChanged(kind) 42 | if err != nil { 43 | c.Error(err) 44 | return 45 | } 46 | 47 | c.SetOutput("gettingStarted", c.aem.Project().ScaffoldGettingStarted()) 48 | 49 | if changed { 50 | c.Changed("project files scaffolded") 51 | } else { 52 | c.Ok("project files already scaffolded") 53 | } 54 | }, 55 | } 56 | cmd.Flags().String(projectKindFlag, project.KindAuto, fmt.Sprintf("Type of AEM to work with (%s)", strings.Join(project.KindStrings(), "|"))) 57 | return cmd 58 | } 59 | 60 | func (c *CLI) projectInitCmd() *cobra.Command { 61 | cmd := &cobra.Command{ 62 | Use: "init", 63 | Aliases: []string{"initialize"}, 64 | Short: "Prepare vendor tools for the project", 65 | Run: func(cmd *cobra.Command, args []string) { 66 | if !c.aem.Project().IsScaffolded() { 67 | c.Fail(fmt.Sprintf("project need to be scaffolded before running initialization")) 68 | return 69 | } 70 | 71 | c.SetOutput("gettingStarted", c.aem.Project().InitGettingStartedError()) 72 | 73 | changed, err := c.aem.Project().InitWithChanged() 74 | if err != nil { 75 | c.Error(err) 76 | return 77 | } 78 | 79 | c.SetOutput("gettingStarted", c.aem.Project().InitGettingStartedSuccess()) 80 | 81 | if changed { 82 | c.Changed("project initialized") 83 | } else { 84 | c.Ok("project already initialized") 85 | } 86 | }, 87 | } 88 | return cmd 89 | } 90 | -------------------------------------------------------------------------------- /cmd/aem/ssl.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/wttech/aemc/pkg" 6 | "github.com/wttech/aemc/pkg/common/mapsx" 7 | ) 8 | 9 | func (c *CLI) sslCmd() *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "ssl", 12 | Short: "Manages SSL/HTTPS Support", 13 | } 14 | cmd.AddCommand(c.sslSetupCmd()) 15 | return cmd 16 | } 17 | 18 | func (c *CLI) sslSetupCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "setup", 21 | Aliases: []string{"configure"}, 22 | Short: "Setup SSL", 23 | Run: func(cmd *cobra.Command, args []string) { 24 | instances, err := c.aem.InstanceManager().Some() 25 | if err != nil { 26 | c.Error(err) 27 | return 28 | } 29 | 30 | keyStorePassword, _ := cmd.Flags().GetString("keystore-password") 31 | trustStorePassword, _ := cmd.Flags().GetString("truststore-password") 32 | certificateFile, _ := cmd.Flags().GetString("certificate-file") 33 | privateKeyFile, _ := cmd.Flags().GetString("private-key-file") 34 | httpsHostname, _ := cmd.Flags().GetString("https-hostname") 35 | httpsPort, _ := cmd.Flags().GetString("https-port") 36 | 37 | configured, err := pkg.InstanceProcess(c.aem, instances, func(instance pkg.Instance) (map[string]any, error) { 38 | changed, err := instance.SSL().Setup(keyStorePassword, trustStorePassword, certificateFile, privateKeyFile, httpsHostname, httpsPort) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return map[string]any{ 43 | OutputChanged: changed, 44 | OutputInstance: instance, 45 | }, nil 46 | }) 47 | if err != nil { 48 | c.Error(err) 49 | return 50 | } 51 | c.SetOutput("configured", configured) 52 | 53 | if mapsx.SomeHas(configured, OutputChanged, true) { 54 | c.Changed("SSL set up") 55 | } else { 56 | c.Ok("SSL already set up (up-to-date)") 57 | } 58 | }, 59 | } 60 | cmd.Flags().String("keystore-password", "", "Keystore password") 61 | cmd.Flags().String("truststore-password", "", "Truststore password") 62 | cmd.Flags().String("certificate-file", "", "Certificate file (PEM format)") 63 | cmd.Flags().String("private-key-file", "", "Private key file (DER or PEM format)") 64 | cmd.Flags().String("https-hostname", "", "HTTPS hostname") 65 | cmd.Flags().String("https-port", "", "HTTPS port") 66 | _ = cmd.MarkFlagRequired("keystore-password") 67 | _ = cmd.MarkFlagRequired("truststore-password") 68 | _ = cmd.MarkFlagRequired("certificate-file") 69 | _ = cmd.MarkFlagRequired("private-key-file") 70 | _ = cmd.MarkFlagRequired("https-hostname") 71 | _ = cmd.MarkFlagRequired("https-port") 72 | return cmd 73 | } 74 | -------------------------------------------------------------------------------- /cmd/aem/vendor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | "os" 7 | ) 8 | 9 | func (c *CLI) vendorCmd() *cobra.Command { 10 | cmd := &cobra.Command{ 11 | Use: "vendor", 12 | Short: "Supportive tools management", 13 | Aliases: []string{"ven"}, 14 | } 15 | cmd.AddCommand(c.vendorListCmd()) 16 | cmd.AddCommand(c.vendorPrepareCmd()) 17 | 18 | return cmd 19 | } 20 | 21 | func (c *CLI) vendorListCmd() *cobra.Command { 22 | cmd := &cobra.Command{ 23 | Use: "list", 24 | Short: "List vendor tools available", 25 | Aliases: []string{"ls"}, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | verbose, _ := cmd.Flags().GetBool("verbose") 28 | 29 | javaHome, err := c.aem.VendorManager().JavaManager().FindHomeDir() 30 | if err != nil { 31 | javaHome = os.Getenv("JAVA_HOME") 32 | if verbose { 33 | log.Warnf("java home not available: %s", err) 34 | } 35 | } 36 | c.SetOutput("javaHome", javaHome) 37 | 38 | javaExecutable, err := c.aem.VendorManager().JavaManager().Executable() 39 | if err != nil { 40 | if verbose { 41 | log.Warnf("java executable not available: %s", err) 42 | } 43 | } 44 | c.SetOutput("javaExecutable", javaExecutable) 45 | 46 | oakRunJar := c.aem.VendorManager().OakRun().JarFile() 47 | c.setOutput("oakRunJar", oakRunJar) 48 | 49 | c.Ok("vendor tools listed") 50 | }, 51 | } 52 | cmd.Flags().BoolP("verbose", "v", false, "Log errors") 53 | return cmd 54 | } 55 | 56 | func (c *CLI) vendorPrepareCmd() *cobra.Command { 57 | cmd := &cobra.Command{ 58 | Use: "prepare", 59 | Short: "Prepare vendor tools", 60 | Aliases: []string{"prep", "download", "dw"}, 61 | Run: func(cmd *cobra.Command, args []string) { 62 | changed, err := c.aem.VendorManager().PrepareWithChanged(false) 63 | if err != nil { 64 | c.Error(err) 65 | return 66 | } 67 | 68 | if changed { 69 | c.Changed("vendor tools prepared") 70 | } else { 71 | c.Ok("vendor tools already prepared") 72 | } 73 | }, 74 | } 75 | return cmd 76 | } 77 | -------------------------------------------------------------------------------- /cmd/aem/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | "github.com/wttech/aemc/pkg/common" 7 | ) 8 | 9 | var appVersion = "" 10 | var appCommit = "" 11 | var appCommitDate = "" 12 | 13 | type AppInfo struct { 14 | Version string `yaml:"version" json:"version"` 15 | Commit string `yaml:"commit" json:"commit"` 16 | CommitDate string `yaml:"commit_date" json:"commitDate"` 17 | } 18 | 19 | func NewAppInfo() AppInfo { 20 | return AppInfo{ 21 | Version: appVersion, 22 | Commit: appCommit, 23 | CommitDate: appCommitDate, 24 | } 25 | } 26 | 27 | func (a AppInfo) String() string { 28 | return fmt.Sprintf("%s %s (commit %s on %s)", common.AppName, a.Version, a.Commit, a.CommitDate) 29 | } 30 | 31 | func (c *CLI) versionCmd() *cobra.Command { 32 | return &cobra.Command{ 33 | Use: "version", 34 | Short: "Print application details including version", 35 | Run: func(cmd *cobra.Command, args []string) { 36 | c.SetOutput("app", NewAppInfo()) 37 | c.Ok("application details printed") 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /docs/adapto-to-video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/adapto-to-video.png -------------------------------------------------------------------------------- /docs/apache-license-badge.svg: -------------------------------------------------------------------------------- 1 | LicenseLicenseApache-2.0Apache-2.0 -------------------------------------------------------------------------------- /docs/cli-demo-short.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/cli-demo-short.gif -------------------------------------------------------------------------------- /docs/cli-help-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/cli-help-home.png -------------------------------------------------------------------------------- /docs/cli-help-instance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/cli-help-instance.png -------------------------------------------------------------------------------- /docs/cli-help-pkg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/cli-help-pkg.png -------------------------------------------------------------------------------- /docs/cli-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/cli-main.png -------------------------------------------------------------------------------- /docs/logo-with-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/logo-with-text.png -------------------------------------------------------------------------------- /docs/wtt-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/docs/wtt-logo.png -------------------------------------------------------------------------------- /examples/docker/.dockerignore: -------------------------------------------------------------------------------- 1 | src/aem/home/var/ 2 | src/aem/home/tmp/ 3 | *.log 4 | -------------------------------------------------------------------------------- /examples/docker/README.md: -------------------------------------------------------------------------------- 1 | ![AEM Compose Logo](https://github.com/wttech/aemc-ansible/raw/main/docs/logo-with-text.png) 2 | [![WTT Logo](https://github.com/wttech/aemc-ansible/raw/main/docs/wtt-logo.png)](https://www.wundermanthompson.com/service/technology) 3 | 4 | [![Apache License, Version 2.0, January 2004](https://github.com/wttech/aemc-ansible/raw/main/docs/apache-license-badge.svg)](http://www.apache.org/licenses/) 5 | 6 | # AEM Compose - Docker Example 7 | 8 | Setup and launch AEM instances as Docker containers. 9 | 10 | **Warning!** The purpose of this example is to demonstrate and experiment with AEM running in Docker. However, it is widely known that AEM runtime does not fit well into Docker architecture (e.g is not lightweight and stateless). 11 | 12 | ## Prerequisites 13 | 14 | - Docker 20.x and higher 15 | - AEM source files put into directory *src/aem/home/lib* 16 | - a) AEM SDK ZIP 17 | - b) AEM On-Prem JAR and license file 18 | 19 | # Usage 20 | 21 | 1. Build images using command: 22 | 23 | ```shell 24 | sh build-all.sh 25 | ``` 26 | 2. Run containers using command: 27 | 28 | ```shell 29 | sh run-all.sh 30 | ``` 31 | -------------------------------------------------------------------------------- /examples/docker/author.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/x86_64 rockylinux:8.7 2 | 3 | ADD src /opt/aemc 4 | WORKDIR /opt/aemc 5 | ENTRYPOINT ["/bin/bash"] 6 | 7 | ENV TERM=xterm 8 | ENV AEM_INSTANCE_CONFIG_LOCAL_AUTHOR_ACTIVE=true 9 | ENV AEM_INSTANCE_CONFIG_LOCAL_PUBLISH_ACTIVE=false 10 | 11 | RUN sh aemw instance launch && sh aemw instance down 12 | -------------------------------------------------------------------------------- /examples/docker/build-all.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | sh build.sh author && sh build.sh publish 4 | -------------------------------------------------------------------------------- /examples/docker/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | UNIT=${1:-author} 4 | 5 | docker build --progress=plain --platform linux/x86_64 -t "acme/aem/${UNIT}" -f "${UNIT}.Dockerfile" . 6 | -------------------------------------------------------------------------------- /examples/docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | author: 3 | image: acme/aem/author:latest 4 | ports: [8802:4502, 8812:14502] 5 | volumes: 6 | - ./aem/home/var/instance/author/crx-quickstart:/opt/aem/home/var/instance/author/crx-quickstart 7 | 8 | publish: 9 | image: acme/aem/publish:latest 10 | ports: [8803:4503, 8813:14503] 11 | volumes: 12 | - ./aem/home/var/instance/publish/crx-quickstart:/opt/aem/home/var/instance/publish/crx-quickstart 13 | -------------------------------------------------------------------------------- /examples/docker/publish.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/x86_64 rockylinux:8.7 2 | 3 | ADD src /opt/aemc 4 | WORKDIR /opt/aemc 5 | ENTRYPOINT ["/bin/bash"] 6 | 7 | ENV TERM=xterm 8 | ENV AEM_INSTANCE_CONFIG_LOCAL_AUTHOR_ACTIVE=false 9 | ENV AEM_INSTANCE_CONFIG_LOCAL_PUBLISH_ACTIVE=true 10 | 11 | RUN sh aemw instance launch && sh aemw instance down 12 | -------------------------------------------------------------------------------- /examples/docker/rebuild.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | UNIT=${1:-author} 4 | 5 | docker build --progress=plain --no-cache -t "acme/aem/${UNIT}" -f "${UNIT}.Dockerfile" . 6 | -------------------------------------------------------------------------------- /examples/docker/run-all.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | docker compose up 4 | -------------------------------------------------------------------------------- /examples/docker/src/aemw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | VERSION=${AEM_CLI_VERSION:-"1.1.6"} 4 | 5 | # Define API 6 | # ========== 7 | 8 | # https://github.com/client9/shlib/blob/master/uname_os.sh 9 | detect_os() { 10 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | 12 | # fixed up for https://github.com/client9/shlib/issues/3 13 | case "$os" in 14 | msys*) os="windows" ;; 15 | mingw*) os="windows" ;; 16 | cygwin*) os="windows" ;; 17 | win*) os="windows" ;; # for windows busybox and like # https://frippery.org/busybox/ 18 | esac 19 | 20 | # other fixups here 21 | echo "$os" 22 | } 23 | 24 | # https://github.com/client9/shlib/blob/master/uname_arch.sh 25 | detect_arch() { 26 | arch=$(uname -m) 27 | case $arch in 28 | x86_64) arch="amd64" ;; 29 | x86) arch="386" ;; 30 | i686) arch="386" ;; 31 | i386) arch="386" ;; 32 | aarch64) arch="arm64" ;; 33 | armv5*) arch="armv5" ;; 34 | armv6*) arch="armv6" ;; 35 | armv7*) arch="armv7" ;; 36 | esac 37 | echo ${arch} 38 | } 39 | 40 | # https://github.com/client9/shlib/blob/master/http_download.sh 41 | download_file() { 42 | local_file=$1 43 | source_url=$2 44 | header=$3 45 | if [ -z "$header" ]; then 46 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 47 | else 48 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 49 | fi 50 | if [ "$code" != "200" ]; then 51 | echo "Error! Downloading file from URL '$source_url' received HTTP status '$code'" 52 | return 1 53 | fi 54 | return 0 55 | } 56 | 57 | download_file_once () { 58 | URL=$1 59 | FILE=$2 60 | if [ ! -f "${FILE}" ]; then 61 | mkdir -p "$(dirname "$FILE")" 62 | FILE_TMP="$2.tmp" 63 | download_file "$FILE_TMP" "$URL" 64 | mv "$FILE_TMP" "$FILE" 65 | fi 66 | } 67 | 68 | unarchive_file() { 69 | FILE=$1 70 | DIR=$2 71 | 72 | rm -fr "$DIR" 73 | mkdir -p "$DIR" 74 | if [ "${FILE##*.}" = "zip" ] ; then 75 | unzip "$FILE" -d "$DIR" 76 | else 77 | tar -xf "$FILE" -C "$DIR" 78 | fi 79 | } 80 | 81 | 82 | # Download or use installed tool 83 | # ============================== 84 | 85 | OS=$(detect_os) 86 | ARCH=$(detect_arch) 87 | 88 | AEM_DIR="aem" 89 | HOME_DIR="${AEM_DIR}/home" 90 | DOWNLOAD_DIR="${HOME_DIR}/opt" 91 | 92 | BIN_DOWNLOAD_NAME="aemc-cli" 93 | BIN_ARCHIVE_EXT="tar.gz" 94 | if [ "$OS" = "windows" ] ; then 95 | BIN_ARCHIVE_EXT="zip" 96 | fi 97 | BIN_DOWNLOAD_URL="https://github.com/wttech/aemc/releases/download/v${VERSION}/${BIN_DOWNLOAD_NAME}_${OS}_${ARCH}.${BIN_ARCHIVE_EXT}" 98 | BIN_ROOT="${DOWNLOAD_DIR}/${BIN_DOWNLOAD_NAME}/${VERSION}" 99 | BIN_ARCHIVE_FILE="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}.${BIN_ARCHIVE_EXT}" 100 | BIN_ARCHIVE_DIR="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}" 101 | BIN_NAME="aem" 102 | BIN_EXEC_FILE="${BIN_ARCHIVE_DIR}/${BIN_NAME}" 103 | 104 | if [ "${VERSION}" != "installed" ] ; then 105 | if [ ! -f "${BIN_EXEC_FILE}" ]; then 106 | mkdir -p "${BIN_ARCHIVE_DIR}" 107 | download_file_once "${BIN_DOWNLOAD_URL}" "${BIN_ARCHIVE_FILE}" 108 | unarchive_file "${BIN_ARCHIVE_FILE}" "${BIN_ARCHIVE_DIR}" 109 | chmod +x "${BIN_EXEC_FILE}" 110 | fi 111 | aem() { 112 | "./${BIN_EXEC_FILE}" "$@" 113 | } 114 | fi 115 | 116 | # Execute AEM Compose CLI 117 | # ========================================== 118 | 119 | aem "$@" 120 | -------------------------------------------------------------------------------- /examples/docker/src/local.env: -------------------------------------------------------------------------------- 1 | # Variables shared to both AEM Compose and Task tool 2 | 3 | AEM_AUTHOR_USER=admin 4 | AEM_AUTHOR_PASSWORD=admin 5 | AEM_AUTHOR_HTTP_URL=http://localhost:4502 6 | AEM_AUTHOR_DEBUG_ADDR=0.0.0.0:14502 7 | 8 | AEM_PUBLISH_USER=admin 9 | AEM_PUBLISH_PASSWORD=admin 10 | AEM_PUBLISH_HTTP_URL=http://localhost:4503 11 | AEM_PUBLISH_DEBUG_ADDR=0.0.0.0:14503 12 | -------------------------------------------------------------------------------- /examples/docker/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | UNIT=${1:-author} 4 | 5 | docker run -it --rm \ 6 | --platform linux/x86_64 \ 7 | -v "$(pwd)/src/aem/default:/opt/aemc/aem/default" \ 8 | "acme/aem/${UNIT}" 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wttech/aemc 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/Masterminds/goutils v1.1.1 7 | github.com/Masterminds/sprig v2.22.0+incompatible 8 | github.com/PuerkitoBio/goquery v1.9.1 9 | github.com/antchfx/xmlquery v1.3.18 10 | github.com/cheggaaa/pb/v3 v3.1.4 11 | github.com/codingsince1985/checksum v1.3.0 12 | github.com/dustin/go-humanize v1.0.1 13 | github.com/essentialkaos/go-jar v1.0.6 14 | github.com/fatih/color v1.16.0 15 | github.com/go-resty/resty/v2 v2.11.0 16 | github.com/gobwas/glob v0.2.3 17 | github.com/google/go-cmp v0.6.0 18 | github.com/hashicorp/go-version v1.6.0 19 | github.com/iancoleman/strcase v0.3.0 20 | github.com/jmespath-community/go-jmespath v1.1.1 21 | github.com/joho/godotenv v1.5.1 22 | github.com/magiconair/properties v1.8.7 23 | github.com/mholt/archiver/v3 v3.5.1 24 | github.com/olekukonko/tablewriter v0.0.5 25 | github.com/otiai10/copy v1.14.0 26 | github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0 27 | github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 28 | github.com/samber/lo v1.39.0 29 | github.com/segmentio/textio v1.2.0 30 | github.com/sirupsen/logrus v1.9.3 31 | github.com/spf13/cast v1.6.0 32 | github.com/spf13/cobra v1.8.0 33 | github.com/spf13/viper v1.18.2 34 | github.com/stretchr/testify v1.8.4 35 | golang.org/x/exp v0.0.0-20231226003508-02704c960a9b 36 | golang.org/x/sync v0.5.0 37 | gopkg.in/yaml.v3 v3.0.1 38 | ) 39 | 40 | require ( 41 | github.com/Masterminds/semver v1.5.0 // indirect 42 | github.com/VividCortex/ewma v1.2.0 // indirect 43 | github.com/andybalholm/brotli v1.0.6 // indirect 44 | github.com/andybalholm/cascadia v1.3.2 // indirect 45 | github.com/antchfx/xpath v1.2.5 // indirect 46 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 47 | github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect 48 | github.com/fsnotify/fsnotify v1.7.0 // indirect 49 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 50 | github.com/golang/snappy v0.0.4 // indirect 51 | github.com/google/uuid v1.5.0 // indirect 52 | github.com/hashicorp/hcl v1.0.0 // indirect 53 | github.com/huandu/xstrings v1.4.0 // indirect 54 | github.com/imdario/mergo v0.3.16 // indirect 55 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 56 | github.com/klauspost/compress v1.17.4 // indirect 57 | github.com/klauspost/pgzip v1.2.6 // indirect 58 | github.com/mattn/go-colorable v0.1.13 // indirect 59 | github.com/mattn/go-isatty v0.0.20 // indirect 60 | github.com/mattn/go-runewidth v0.0.15 // indirect 61 | github.com/mitchellh/copystructure v1.2.0 // indirect 62 | github.com/mitchellh/mapstructure v1.5.0 // indirect 63 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 64 | github.com/nwaples/rardecode v1.1.3 // indirect 65 | github.com/pelletier/go-toml/v2 v2.1.1 // indirect 66 | github.com/pierrec/lz4/v4 v4.1.19 // indirect 67 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 68 | github.com/rivo/uniseg v0.4.4 // indirect 69 | github.com/sagikazarmark/locafero v0.4.0 // indirect 70 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 71 | github.com/sourcegraph/conc v0.3.0 // indirect 72 | github.com/spf13/afero v1.11.0 // indirect 73 | github.com/spf13/pflag v1.0.5 // indirect 74 | github.com/subosito/gotenv v1.6.0 // indirect 75 | github.com/ulikunitz/xz v0.5.11 // indirect 76 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 77 | go.uber.org/multierr v1.11.0 // indirect 78 | golang.org/x/crypto v0.21.0 // indirect 79 | golang.org/x/net v0.23.0 // indirect 80 | golang.org/x/sys v0.18.0 // indirect 81 | golang.org/x/text v0.14.0 // indirect 82 | gopkg.in/ini.v1 v1.67.0 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /pkg/auth.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type Auth struct { 4 | instance *Instance 5 | userManager *UserManager 6 | } 7 | 8 | func NewAuth(instance *Instance) *Auth { 9 | auth := &Auth{instance: instance} 10 | auth.userManager = NewUserManager(instance) 11 | 12 | return auth 13 | } 14 | 15 | func (auth *Auth) UserManager() *UserManager { 16 | return auth.userManager 17 | } 18 | -------------------------------------------------------------------------------- /pkg/base.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/wttech/aemc/pkg/common/pathx" 5 | ) 6 | 7 | type BaseOpts struct { 8 | aem *AEM 9 | 10 | LibDir string 11 | TmpDir string 12 | ToolDir string 13 | CacheDir string 14 | } 15 | 16 | func NewBaseOpts(aem *AEM) *BaseOpts { 17 | cv := aem.config.Values() 18 | 19 | return &BaseOpts{ 20 | aem: aem, 21 | 22 | LibDir: cv.GetString("base.lib_dir"), 23 | TmpDir: cv.GetString("base.tmp_dir"), 24 | ToolDir: cv.GetString("base.tool_dir"), 25 | CacheDir: cv.GetString("base.cache_dir"), 26 | } 27 | } 28 | 29 | func (o *BaseOpts) PrepareWithChanged() (bool, error) { 30 | changed := false 31 | dirs := []string{o.LibDir, o.TmpDir, o.ToolDir, o.CacheDir} 32 | for _, dir := range dirs { 33 | dirEnsured, err := pathx.EnsureWithChanged(dir) 34 | changed = changed || dirEnsured 35 | if err != nil { 36 | return changed, err 37 | } 38 | } 39 | return changed, nil 40 | } 41 | 42 | func (o *BaseOpts) HasLibs() bool { 43 | return pathx.Exists(o.LibDir) 44 | } 45 | -------------------------------------------------------------------------------- /pkg/common/certx/certx.go: -------------------------------------------------------------------------------- 1 | package certx 2 | 3 | import ( 4 | "encoding/pem" 5 | "fmt" 6 | "os" 7 | ) 8 | 9 | func CreateTmpDerFileBasedOnPem(block *pem.Block) (*string, func(), error) { 10 | tempDerFile, err := os.CreateTemp("", "tmp-certx-*.der") 11 | 12 | if err != nil { 13 | return nil, nil, fmt.Errorf("failed to create temp file for storing DER certificate: %w", err) 14 | } 15 | 16 | err = writeCertToDer(tempDerFile, block) 17 | if err != nil { 18 | return nil, nil, fmt.Errorf("failed to write DER certificate: %w", err) 19 | } 20 | 21 | tmpFileName := tempDerFile.Name() 22 | 23 | return &tmpFileName, func() { os.Remove(tmpFileName) }, nil 24 | } 25 | 26 | func writeCertToDer(tempDerFile *os.File, pemBlock *pem.Block) error { 27 | if _, err := tempDerFile.Write(pemBlock.Bytes); err != nil { 28 | return err 29 | } 30 | err := tempDerFile.Close() 31 | if err != nil { 32 | return err 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/common/constants.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | const ( 4 | AppId = "aem-compose" 5 | AppName = "AEM Compose" 6 | MainDir = "aem" 7 | HomeDirName = "home" 8 | HomeDir = MainDir + "/" + HomeDirName 9 | VarDirName = "var" 10 | VarDir = HomeDir + "/" + VarDirName 11 | ConfigDirName = "etc" 12 | ConfigDir = HomeDir + "/" + ConfigDirName 13 | LogDirName = "log" 14 | LogDir = VarDir + "/" + LogDirName 15 | LogFile = LogDir + "/aem.log" 16 | CacheDirName = "cache" 17 | CacheDir = VarDir + "/" + CacheDirName 18 | ToolDirName = "opt" 19 | ToolDir = HomeDir + "/" + ToolDirName 20 | LibDirName = "lib" 21 | LibDir = HomeDir + "/" + LibDirName 22 | TmpDirName = "tmp" 23 | TmpDir = HomeDir + "/" + TmpDirName 24 | DefaultDirName = "default" 25 | DefaultDir = MainDir + "/" + DefaultDirName 26 | DispatcherHomeDir = "dispatcher/home" 27 | 28 | QuickstartDistFile = LibDir + "/{aem-sdk,cq-quickstart}-*.{zip,jar}" 29 | QuickstartLicenseFile = LibDir + "/" + QuickstartLicenseFilename 30 | QuickstartLicenseFilename = "license.properties" 31 | ) 32 | 33 | const ( 34 | STDIn = "STDIN" 35 | STDOut = "STDOUT" 36 | OutputValueAll = "ALL" 37 | OutputValueNone = "NONE" 38 | OutputValueOnly = "ONLY" 39 | ) 40 | -------------------------------------------------------------------------------- /pkg/common/cryptox/cryptox.go: -------------------------------------------------------------------------------- 1 | package cryptox 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | log "github.com/sirupsen/logrus" 9 | "io" 10 | ) 11 | 12 | func EncryptString(key []byte, text string) string { 13 | c, err := aes.NewCipher(key) 14 | if err != nil { 15 | log.Fatalf("encryption key/salt is invalid: %s", err) 16 | } 17 | encrypted := make([]byte, len(text)) 18 | c.Encrypt(encrypted, []byte(text)) 19 | return hex.EncodeToString(encrypted) 20 | } 21 | 22 | func DecryptString(key []byte, encrypted string) string { 23 | c, err := aes.NewCipher(key) 24 | if err != nil { 25 | log.Fatalf("decryption key/salt is invalid: %s", err) 26 | } 27 | decoded, _ := hex.DecodeString(encrypted) 28 | dest := make([]byte, len(decoded)) 29 | c.Decrypt(dest, decoded) 30 | s := string(dest[:]) 31 | return s 32 | } 33 | 34 | func HashString(text string) string { 35 | hash := sha256.New() 36 | _, _ = io.WriteString(hash, text) 37 | return fmt.Sprintf("%x", hash.Sum(nil)) 38 | } 39 | 40 | func HashMap(values map[string]string) string { 41 | hash := sha256.New() 42 | for k, v := range values { 43 | _, _ = io.WriteString(hash, k) 44 | _, _ = io.WriteString(hash, v) 45 | } 46 | return fmt.Sprintf("%x", hash.Sum(nil)) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/common/execx/execx.go: -------------------------------------------------------------------------------- 1 | package execx 2 | 3 | import ( 4 | "github.com/wttech/aemc/pkg/common/osx" 5 | "os/exec" 6 | "strings" 7 | ) 8 | 9 | func CommandShell(args []string) *exec.Cmd { 10 | if osx.IsWindows() { 11 | cmdArgs := append([]string{"/C"}, args...) 12 | return exec.Command("cmd", cmdArgs...) 13 | } 14 | return exec.Command("sh", args...) 15 | } 16 | 17 | func CommandString(command string) *exec.Cmd { 18 | return CommandLine(strings.Split(command, " ")) 19 | } 20 | 21 | func CommandLine(command []string) *exec.Cmd { 22 | name := command[0] 23 | args := command[1:] 24 | return exec.Command(name, args...) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/common/filex/archive.go: -------------------------------------------------------------------------------- 1 | package filex 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mholt/archiver/v3" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/pathx" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | func Archive(sourcePath, targetFile string) error { 13 | if !pathx.Exists(sourcePath) { 14 | return fmt.Errorf("cannot archive path '%s' to file '%s' as source path does not exist", sourcePath, targetFile) 15 | } 16 | err := pathx.Ensure(filepath.Dir(targetFile)) 17 | if err != nil { 18 | return err 19 | } 20 | var sourcePaths []string 21 | if pathx.IsDir(sourcePath) { 22 | sourceDirEntries, err := os.ReadDir(sourcePath) 23 | if err != nil { 24 | return fmt.Errorf("cannot read dir '%s' to be archived to file '%s': %w", sourcePath, targetFile, err) 25 | } 26 | sourcePaths = lo.Map(sourceDirEntries, func(e os.DirEntry, _ int) string { 27 | return pathx.Canonical(fmt.Sprintf("%s/%s", sourcePath, e.Name())) 28 | }) 29 | } else { 30 | sourcePaths = []string{sourcePath} 31 | } 32 | err = archiver.Archive(sourcePaths, targetFile) 33 | if err != nil { 34 | return fmt.Errorf("cannot archive dir '%s' to file '%s': %w", sourcePath, targetFile, err) 35 | } 36 | return nil 37 | } 38 | 39 | func ArchiveWithChanged(sourceDir, targetFile string) (bool, error) { 40 | if pathx.Exists(targetFile) { 41 | return false, nil 42 | } 43 | targetTmpFile := filepath.Dir(targetFile) + "/tmp-" + filepath.Base(targetFile) 44 | if err := pathx.DeleteIfExists(targetTmpFile); err != nil { 45 | return false, fmt.Errorf("cannot delete temporary archive file '%s': %w", targetTmpFile, err) 46 | } 47 | if err := Archive(sourceDir, targetTmpFile); err != nil { 48 | return false, err 49 | } 50 | if err := os.Rename(targetTmpFile, targetFile); err != nil { 51 | return false, fmt.Errorf("cannot move temporary archive file '%s' to target one '%s': %w", targetTmpFile, targetFile, err) 52 | } 53 | return true, nil 54 | } 55 | 56 | func Unarchive(sourceFile string, targetDir string) error { 57 | if !pathx.Exists(sourceFile) { 58 | return fmt.Errorf("cannot unarchive file '%s' to dir '%s' as source file does not exist", sourceFile, targetDir) 59 | } 60 | if err := pathx.Ensure(targetDir); err != nil { 61 | return err 62 | } 63 | if err := archiver.Unarchive(sourceFile, targetDir); err != nil { 64 | return fmt.Errorf("cannot unarchive file '%s' to dir '%s': %w", sourceFile, targetDir, err) 65 | } 66 | return nil 67 | } 68 | 69 | func UnarchiveWithChanged(sourceFile, targetDir string) (bool, error) { 70 | if pathx.Exists(targetDir) { 71 | return false, nil 72 | } 73 | targetTmpDir := targetDir + ".tmp" 74 | if err := pathx.DeleteIfExists(targetTmpDir); err != nil { 75 | return false, fmt.Errorf("cannot delete unarchive temporary dir '%s': %w", targetTmpDir, err) 76 | } 77 | if err := Unarchive(sourceFile, targetTmpDir); err != nil { 78 | return false, err 79 | } 80 | if err := os.Rename(targetTmpDir, targetDir); err != nil { 81 | return false, fmt.Errorf("cannot move unarchived temporary dir '%s' to target one '%s': %w", targetTmpDir, targetDir, err) 82 | } 83 | return true, nil 84 | } 85 | -------------------------------------------------------------------------------- /pkg/common/fmtx/serialization.go: -------------------------------------------------------------------------------- 1 | package fmtx 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/wttech/aemc/pkg/common/filex" 8 | "github.com/wttech/aemc/pkg/common/pathx" 9 | "gopkg.in/yaml.v3" 10 | "io" 11 | "os" 12 | ) 13 | 14 | const ( 15 | // Text prints output of the commands as human-readable text 16 | Text string = "text" 17 | 18 | // YML prints output of the commands in YML format 19 | YML string = "yml" 20 | 21 | // JSON prints output of the commands in JSON format 22 | JSON string = "json" 23 | ) 24 | 25 | func UnmarshalDataInFormat(dataFormat string, reader io.ReadCloser, out any) error { 26 | switch dataFormat { 27 | case YML: 28 | return UnmarshalYML(reader, out) 29 | case JSON: 30 | return UnmarshalJSON(reader, out) 31 | default: 32 | return fmt.Errorf("cannot decode data to struct; unsupported data format '%s'", dataFormat) 33 | } 34 | } 35 | 36 | func MarshalDataInFormat(dataFormat string, out any) (string, error) { 37 | switch dataFormat { 38 | case YML: 39 | return MarshalYML(out) 40 | case JSON: 41 | return MarshalJSON(out) 42 | default: 43 | return "", fmt.Errorf("cannot marshal data; unsupported format '%s'", dataFormat) 44 | } 45 | } 46 | 47 | func MarshalJSON(i any) (string, error) { 48 | bytes, err := json.MarshalIndent(i, "", " ") 49 | if err != nil { 50 | return "", fmt.Errorf("cannot convert object '%s' to JSON: %w", i, err) 51 | } 52 | return string(bytes), nil 53 | } 54 | 55 | func UnmarshalJSON(body io.ReadCloser, out any) error { 56 | defer func(body io.ReadCloser) { 57 | if err := body.Close(); err != nil { 58 | log.Debugf("cannot close JSON stream properly: %s", err) 59 | } 60 | }(body) 61 | err := json.NewDecoder(body).Decode(out) 62 | if err != nil { 63 | return fmt.Errorf("cannot decode stream as JSON: %w", err) 64 | } 65 | return nil 66 | } 67 | 68 | func MarshalYML(i any) (string, error) { 69 | bytes, err := yaml.Marshal(i) 70 | if err != nil { 71 | return "", fmt.Errorf("cannot convert object '%s' to YML: %w", i, err) 72 | } 73 | return string(bytes), nil 74 | } 75 | 76 | func UnmarshalYML(body io.ReadCloser, out any) error { 77 | defer func(body io.ReadCloser) { 78 | if err := body.Close(); err != nil { 79 | log.Debugf("cannot close YML stream properly: %s", err) 80 | } 81 | }(body) 82 | err := yaml.NewDecoder(body).Decode(out) 83 | if err != nil { 84 | return fmt.Errorf("cannot decode stream as YML: %w", err) 85 | } 86 | return nil 87 | } 88 | 89 | func MarshalToFile(path string, out any) error { 90 | return MarshalToFileInFormat(pathx.Ext(path), path, out) 91 | } 92 | 93 | func MarshalToFileInFormat(format string, path string, out any) error { 94 | text, err := MarshalDataInFormat(format, out) 95 | if err != nil { 96 | return fmt.Errorf("cannot marshal data for file '%s': %w", path, err) 97 | } 98 | err = filex.WriteString(path, text) 99 | if err != nil { 100 | return err 101 | } 102 | return nil 103 | } 104 | 105 | func UnmarshalFile(path string, out any) error { 106 | return UnmarshalFileInFormat(pathx.Ext(path), path, out) 107 | } 108 | 109 | func UnmarshalFileInFormat(format string, path string, out any) error { 110 | fileDesc, _ := os.Open(path) 111 | defer fileDesc.Close() 112 | err := UnmarshalDataInFormat(format, fileDesc, out) 113 | if err != nil { 114 | return fmt.Errorf("cannot unmarshal data from file '%s' to struct: %w", path, err) 115 | } 116 | return nil 117 | } 118 | 119 | type TextMarshaler interface { 120 | MarshalText() string 121 | } 122 | 123 | func MarshalText(value any) string { 124 | var result string 125 | marshaller, ok := value.(TextMarshaler) 126 | if ok { 127 | result = marshaller.MarshalText() 128 | } else { 129 | result = fmt.Sprintf("%v", value) 130 | } 131 | if len(result) == 0 { 132 | return "" 133 | } 134 | return result 135 | } 136 | -------------------------------------------------------------------------------- /pkg/common/fmtx/tbl.go: -------------------------------------------------------------------------------- 1 | package fmtx 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/olekukonko/tablewriter" 7 | "github.com/samber/lo" 8 | "golang.org/x/exp/maps" 9 | "reflect" 10 | "sort" 11 | "strings" 12 | ) 13 | 14 | func TblProps(props map[string]any) string { 15 | return TblMap("properties", "name", "value", props) 16 | } 17 | 18 | func TblList(caption string, items [][]any) string { 19 | sb := bytes.NewBufferString("\n") 20 | sb.WriteString(fmt.Sprintf("%s\n", caption)) 21 | tbl := tablewriter.NewWriter(sb) 22 | tbl.SetColWidth(TblColWidth) 23 | tbl.SetHeader([]string{}) 24 | tbl.SetBorder(false) 25 | tbl.SetAlignment(tablewriter.ALIGN_LEFT) 26 | for _, item := range items { 27 | tbl.Append([]string{TblValue(item[0]), TblValue(item[1])}) 28 | } 29 | tbl.Render() 30 | sb.WriteString("\n") 31 | return sb.String() 32 | } 33 | 34 | func TblMap(caption, keyLabel, valueLabel string, props map[string]any) string { 35 | sb := bytes.NewBufferString("\n") 36 | sb.WriteString(fmt.Sprintf("%s\n\n", caption)) 37 | tbl := tablewriter.NewWriter(sb) 38 | tbl.SetColWidth(TblColWidth) 39 | tbl.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 40 | tbl.SetHeader([]string{keyLabel, valueLabel}) 41 | tbl.SetBorder(false) 42 | tbl.SetAlignment(tablewriter.ALIGN_LEFT) 43 | keys := maps.Keys(props) 44 | sort.Strings(keys) 45 | for _, key := range keys { 46 | tbl.Append([]string{key, TblValue(props[key])}) 47 | } 48 | tbl.Render() 49 | sb.WriteString("\n") 50 | return sb.String() 51 | } 52 | 53 | func TblRows(caption string, enum bool, header []string, rows []map[string]any) string { 54 | sb := bytes.NewBufferString("\n") 55 | sb.WriteString(fmt.Sprintf("%s\n\n", caption)) 56 | headerNormalized := []string{} 57 | if enum { 58 | headerNormalized = append(headerNormalized, "#") 59 | } 60 | headerNormalized = append(headerNormalized, header...) 61 | rowsNormalized := lo.Map(rows, func(row map[string]any, index int) []string { 62 | rowVals := []string{} 63 | if enum { 64 | rowVals = append(rowVals, TblValue(index+1)) 65 | } 66 | for _, header := range header { 67 | rowVals = append(rowVals, TblValue(row[header])) 68 | } 69 | return rowVals 70 | }) 71 | tbl := tablewriter.NewWriter(sb) 72 | tbl.SetColWidth(TblColWidth) 73 | tbl.SetHeaderAlignment(tablewriter.ALIGN_LEFT) 74 | tbl.SetHeader(headerNormalized) 75 | tbl.SetAlignment(tablewriter.ALIGN_LEFT) 76 | tbl.SetBorder(false) 77 | tbl.AppendBulk(rowsNormalized) 78 | tbl.Render() 79 | sb.WriteString("\n") 80 | return sb.String() 81 | } 82 | 83 | func TblValue(value any) string { 84 | result := "" 85 | if value != nil { 86 | rv := reflect.ValueOf(value) 87 | kind := rv.Type().Kind() 88 | if kind == reflect.Map { 89 | mapValues := map[string]string{} 90 | for _, key := range rv.MapKeys() { 91 | mapValue := rv.MapIndex(key) 92 | mapValues[key.String()] = tblValue(mapValue) 93 | } 94 | keys := maps.Keys(mapValues) 95 | sort.Strings(keys) 96 | result = strings.Join(lo.Map(keys, func(k string, index int) string { 97 | return fmt.Sprintf("%s = %v", k, mapValues[k]) 98 | }), ", ") 99 | } else if kind == reflect.Array || kind == reflect.Slice { 100 | var listValue []string 101 | for i := 0; i < rv.Len(); i++ { 102 | iv := rv.Index(i).Interface() 103 | listValue = append(listValue, tblValue(iv)) 104 | } 105 | result = strings.Join(listValue, ", ") 106 | } else { 107 | result = tblValue(value) 108 | } 109 | } 110 | if len(result) == 0 { 111 | return "" 112 | } 113 | return result 114 | } 115 | 116 | func tblValue(value any) string { 117 | return fmt.Sprintf("%v", value) 118 | } 119 | 120 | const ( 121 | TblColWidth = 120 122 | ) 123 | -------------------------------------------------------------------------------- /pkg/common/fmtx/tbl_test.go: -------------------------------------------------------------------------------- 1 | package fmtx_test 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "github.com/wttech/aemc/pkg/common/fmtx" 6 | "testing" 7 | ) 8 | 9 | func TestTblValue(t *testing.T) { 10 | t.Parallel() 11 | a := assert.New(t) 12 | 13 | a.Equal("", fmtx.TblValue(nil)) 14 | a.Equal("", fmtx.TblValue("")) 15 | a.Equal("true", fmtx.TblValue(true)) 16 | a.Equal("4", fmtx.TblValue(4)) 17 | a.Equal("25", fmtx.TblValue(25.0)) 18 | a.Equal("aaa = 123, hello = world", fmtx.TblValue(map[string]any{"hello": "world", "aaa": 123})) 19 | a.Equal("a, b, 123, 456, false", fmtx.TblValue([]any{"a", "b", 123, "456", false})) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/common/httpx/httpx.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "github.com/cheggaaa/pb/v3" 7 | "github.com/fatih/color" 8 | "github.com/go-resty/resty/v2" 9 | "github.com/wttech/aemc/pkg/common/pathx" 10 | "github.com/wttech/aemc/pkg/common/stringsx" 11 | "io" 12 | "net/http" 13 | "os" 14 | "path/filepath" 15 | ) 16 | 17 | func FileNameFromURL(url string) string { 18 | return stringsx.BeforeLast(stringsx.AfterLast(url, "/"), "?") 19 | } 20 | 21 | type DownloadOpts struct { 22 | Client *resty.Client 23 | URL string 24 | File string 25 | Override bool 26 | AuthToken string 27 | AuthBasicUser string 28 | AuthBasicPassword string 29 | } 30 | 31 | func downloadClient(opts DownloadOpts) *resty.Client { 32 | client := resty.New() 33 | client.DisableWarn = true 34 | client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) 35 | client.SetDoNotParseResponse(true) 36 | if len(opts.AuthBasicUser) > 0 && len(opts.AuthBasicPassword) > 0 { 37 | client.SetBasicAuth(opts.AuthBasicUser, opts.AuthBasicPassword) 38 | } 39 | if len(opts.AuthToken) > 0 { 40 | client.SetAuthToken(opts.AuthToken) 41 | } 42 | return client 43 | } 44 | 45 | func DownloadWithOpts(opts DownloadOpts) error { 46 | if len(opts.URL) == 0 { 47 | return fmt.Errorf("source URL of downloaded file is not specified") 48 | } 49 | if len(opts.File) == 0 { 50 | return fmt.Errorf("destination for downloaded file is not specified") 51 | } 52 | if pathx.Exists(opts.File) && !opts.Override { 53 | return fmt.Errorf("destination for downloaded file already exist") 54 | } 55 | if opts.Client == nil { 56 | opts.Client = downloadClient(opts) 57 | } 58 | fileTmp := opts.File + ".tmp" 59 | if err := pathx.DeleteIfExists(fileTmp); err != nil { 60 | return fmt.Errorf("cannot delete temporary file for downloaded from URL '%s' to '%s': %s", opts.URL, opts.File, err) 61 | } 62 | defer func() { _ = pathx.DeleteIfExists(fileTmp) }() 63 | if err := pathx.Ensure(filepath.Dir(fileTmp)); err != nil { 64 | return err 65 | } 66 | res, err := opts.Client.R().Get(opts.URL) 67 | if err != nil { 68 | return fmt.Errorf("cannot download from URL '%s' to file '%s': %w", opts.URL, opts.File, err) 69 | } 70 | defer res.RawBody().Close() 71 | if res.StatusCode() != http.StatusOK { 72 | return fmt.Errorf("cannot download from URL '%s' to file '%s': %s", opts.URL, opts.File, res.Status()) 73 | } 74 | fhTmp, err := os.Create(fileTmp) 75 | if err != nil { 76 | return fmt.Errorf("cannot download from URL '%s' as file '%s' cannot be written", opts.URL, opts.File) 77 | } 78 | if color.NoColor { 79 | if _, err := io.Copy(fhTmp, res.RawBody()); err != nil { 80 | return fmt.Errorf("cannot download from URL '%s' to file '%s': %w", opts.URL, opts.File, err) 81 | } 82 | } else { 83 | bar := pb.Full.Start64(res.RawResponse.ContentLength) 84 | if _, err := io.Copy(bar.NewProxyWriter(fhTmp), res.RawBody()); err != nil { 85 | return fmt.Errorf("cannot download from URL '%s' to file '%s': %w", opts.URL, opts.File, err) 86 | } 87 | bar.Finish() 88 | } 89 | fhTmp.Close() 90 | err = os.Rename(fileTmp, opts.File) 91 | if err != nil { 92 | return fmt.Errorf("cannot move downloaded file from temporary path '%s' to target one '%s': %s", fileTmp, opts.File, err) 93 | } 94 | return nil 95 | } 96 | 97 | func DownloadWithChanged(opts DownloadOpts) (bool, error) { 98 | if pathx.Exists(opts.File) && !opts.Override { 99 | return false, nil 100 | } 101 | if err := DownloadWithOpts(opts); err != nil { 102 | return false, err 103 | } 104 | return true, nil 105 | } 106 | 107 | func DownloadOnce(url, file string) error { 108 | _, err := DownloadWithChanged(DownloadOpts{URL: url, File: file}) 109 | return err 110 | } 111 | -------------------------------------------------------------------------------- /pkg/common/intsx/intsx.go: -------------------------------------------------------------------------------- 1 | package intsx 2 | 3 | func MaxOf(args ...int) int { 4 | result := args[0] 5 | for _, arg := range args { 6 | if arg > result { 7 | result = arg 8 | } 9 | } 10 | return result 11 | } 12 | -------------------------------------------------------------------------------- /pkg/common/langx/langx.go: -------------------------------------------------------------------------------- 1 | package langx 2 | 3 | type Stack[T any] struct { 4 | values []T 5 | } 6 | 7 | func NewStackWithValue[T any](initialValue T) Stack[T] { 8 | return Stack[T]{values: []T{initialValue}} 9 | } 10 | 11 | func NewStackWithValues[T any](values []T) Stack[T] { 12 | return Stack[T]{values: values} 13 | } 14 | 15 | func EmptyStack[T any]() Stack[T] { 16 | return Stack[T]{values: []T{}} 17 | } 18 | 19 | func (s *Stack[T]) IsEmpty() bool { 20 | return len(s.values) == 0 21 | } 22 | 23 | func (s *Stack[T]) Push(value T) { 24 | s.values = append(s.values, value) 25 | } 26 | 27 | func (s *Stack[T]) Pop() T { 28 | if s.IsEmpty() { 29 | panic("cannot pop value from an empty stack") 30 | } 31 | top := s.values[len(s.values)-1] 32 | s.values = s.values[:len(s.values)-1] 33 | return top 34 | } 35 | -------------------------------------------------------------------------------- /pkg/common/lox/lox.go: -------------------------------------------------------------------------------- 1 | package lox 2 | 3 | import ( 4 | "context" 5 | "golang.org/x/sync/errgroup" 6 | "math/rand" 7 | "time" 8 | ) 9 | 10 | func Map[I any, R any](parallel bool, iterable []I, callback func(instance I) (R, error)) ([]R, error) { 11 | if parallel { 12 | return ParallelMap(iterable, callback) 13 | } 14 | return SerialMap(iterable, callback) 15 | } 16 | 17 | func ParallelMap[I any, R any](iterable []I, callback func(iteratee I) (R, error)) ([]R, error) { 18 | g, _ := errgroup.WithContext(context.Background()) 19 | results := make([]R, len(iterable)) 20 | for i, iteratee := range iterable { 21 | i, iteratee := i, iteratee 22 | g.Go(func() error { 23 | result, err := callback(iteratee) 24 | if err != nil { 25 | return err 26 | } 27 | results[i] = result 28 | return nil 29 | }) 30 | } 31 | err := g.Wait() 32 | return results, err 33 | } 34 | 35 | func SerialMap[I any, R any](iterable []I, callback func(iteratee I) (R, error)) ([]R, error) { 36 | results := make([]R, len(iterable)) 37 | for i, iteratee := range iterable { 38 | result, err := callback(iteratee) 39 | if err != nil { 40 | return nil, err 41 | } 42 | results[i] = result 43 | } 44 | return results, nil 45 | } 46 | 47 | var random = rand.New(rand.NewSource(time.Now().UnixNano())) 48 | 49 | func Random[I any](iterable []I) I { 50 | if len(iterable) == 0 { 51 | panic("cannot get random value from empty slice") 52 | } 53 | return iterable[random.Intn(len(iterable))] 54 | } 55 | -------------------------------------------------------------------------------- /pkg/common/mapsx/mapsx.go: -------------------------------------------------------------------------------- 1 | package mapsx 2 | 3 | import ( 4 | "github.com/google/go-cmp/cmp" 5 | "github.com/samber/lo" 6 | "golang.org/x/exp/maps" 7 | ) 8 | 9 | func Equal(current map[string]any, updated map[string]any) bool { 10 | return EqualIgnoring(current, updated, []string{}) 11 | } 12 | 13 | func EqualIgnoring(current map[string]any, updated map[string]any, ignored []string) bool { 14 | before := maps.Clone(current) 15 | predicted := maps.Clone(current) 16 | maps.Copy(predicted, updated) 17 | for _, name := range ignored { 18 | delete(predicted, name) 19 | delete(before, name) 20 | } 21 | return cmp.Equal(before, predicted) 22 | } 23 | 24 | func Has[T comparable](data map[string]any, key string, value T) bool { 25 | actualValue, ok := data[key] 26 | if ok { 27 | return value == actualValue.(T) 28 | } 29 | return false 30 | } 31 | 32 | func SomeHas[T comparable](dataList []map[string]any, key string, value T) bool { 33 | return lo.SomeBy(dataList, func(data map[string]any) bool { return Has(data, key, value) }) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/common/netx/netx.go: -------------------------------------------------------------------------------- 1 | package netx 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | func IsReachable(host string, port string, timeout time.Duration) (bool, error) { 9 | conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), timeout) 10 | if conn == nil { 11 | return false, nil 12 | } 13 | defer conn.Close() 14 | if err != nil { 15 | return false, err 16 | } 17 | return true, nil 18 | } 19 | -------------------------------------------------------------------------------- /pkg/common/osx/lock.go: -------------------------------------------------------------------------------- 1 | package osx 2 | 3 | import ( 4 | "fmt" 5 | "github.com/google/go-cmp/cmp" 6 | "github.com/wttech/aemc/pkg/common/fmtx" 7 | "github.com/wttech/aemc/pkg/common/pathx" 8 | ) 9 | 10 | type Lock[T comparable] struct { 11 | path string 12 | dataProvider func() (T, error) 13 | } 14 | 15 | type LockState[T comparable] struct { 16 | Current T 17 | Locked T 18 | UpToDate bool 19 | } 20 | 21 | func NewLock[T comparable](path string, dataProvider func() (T, error)) Lock[T] { 22 | return Lock[T]{path, dataProvider} 23 | } 24 | 25 | func (l Lock[T]) Lock() error { 26 | data, err := l.dataProvider() 27 | if err != nil { 28 | return fmt.Errorf("cannot compute data for lock file '%s': %w", l.path, err) 29 | } 30 | if err := fmtx.MarshalToFile(l.path, data); err != nil { 31 | return fmt.Errorf("cannot save lock file '%s': %w", l.path, err) 32 | } 33 | return nil 34 | } 35 | 36 | func (l Lock[T]) Unlock() error { 37 | if err := pathx.DeleteIfExists(l.path); err != nil { 38 | return fmt.Errorf("cannot delete lock file '%s': %w", l.path, err) 39 | } 40 | return nil 41 | } 42 | 43 | func (l Lock[T]) IsLocked() bool { 44 | return pathx.Exists(l.path) 45 | } 46 | 47 | func (l Lock[T]) Current() (T, error) { 48 | return l.dataProvider() 49 | } 50 | 51 | func (l Lock[T]) Locked() (T, error) { 52 | var data T 53 | if !l.IsLocked() { 54 | return data, fmt.Errorf("cannot read lock file '%s' as it does not exist", l.path) 55 | } 56 | if err := fmtx.UnmarshalFile(l.path, &data); err != nil { 57 | return data, fmt.Errorf("cannot read lock file '%s': %w", l.path, err) 58 | } 59 | return data, nil 60 | } 61 | 62 | func (l Lock[T]) State() (LockState[T], error) { 63 | var zero LockState[T] 64 | if !l.IsLocked() { 65 | return zero, nil 66 | } 67 | locked, err := l.Locked() 68 | if err != nil { 69 | return zero, err 70 | } 71 | current, err := l.Current() 72 | if err != nil { 73 | return zero, err 74 | } 75 | return LockState[T]{ 76 | Current: current, 77 | Locked: locked, 78 | UpToDate: cmp.Equal(current, locked), 79 | }, nil 80 | } 81 | -------------------------------------------------------------------------------- /pkg/common/osx/osx.go: -------------------------------------------------------------------------------- 1 | package osx 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "github.com/samber/lo" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/wttech/aemc/pkg/common/pathx" 8 | "github.com/wttech/aemc/pkg/common/stringsx" 9 | "os" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | EnvFileExt = "env" 16 | EnvVar = "AEM_ENV" 17 | EnvLocal = "local" 18 | EnvLocalFile = EnvLocal + "." + EnvFileExt 19 | ) 20 | 21 | func IsWindows() bool { 22 | return runtime.GOOS == "windows" 23 | } 24 | 25 | func IsDarwin() bool { 26 | return runtime.GOOS == "darwin" 27 | } 28 | 29 | func IsLinux() bool { 30 | return !IsWindows() && !IsDarwin() 31 | } 32 | 33 | func EnvVarsLoad() { 34 | name := os.Getenv(EnvVar) 35 | if name == "" { 36 | name = EnvLocal 37 | } 38 | for _, file := range []string{ 39 | name + "." + EnvFileExt, 40 | "." + EnvFileExt + "." + name, 41 | "." + EnvFileExt, 42 | } { 43 | if pathx.Exists(file) { 44 | if err := godotenv.Overload(file); err != nil { 45 | log.Fatalf("cannot load env file '%s': %s", file, err) 46 | } 47 | } 48 | } 49 | } 50 | 51 | func EnvVarsMap() map[string]string { 52 | result := make(map[string]string) 53 | for _, e := range os.Environ() { 54 | if i := strings.Index(e, "="); i >= 0 { 55 | result[e[:i]] = e[i+1:] 56 | } 57 | } 58 | return result 59 | } 60 | 61 | func EnvVarsWithout(names ...string) []string { 62 | var result []string 63 | for _, pair := range os.Environ() { 64 | key := stringsx.Before(pair, "=") 65 | if !lo.Contains(names, key) { 66 | result = append(result, pair) 67 | } 68 | } 69 | return result 70 | } 71 | 72 | func LineSep() string { 73 | if pathx.Sep() == "\\" { 74 | return "\r\n" 75 | } 76 | return "\n" 77 | } 78 | 79 | func PathVarSep() string { 80 | if pathx.Sep() == "\\" { 81 | return ";" 82 | } 83 | return ":" 84 | } 85 | -------------------------------------------------------------------------------- /pkg/common/stringsx/stringsx.go: -------------------------------------------------------------------------------- 1 | package stringsx 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gobwas/glob" 6 | "github.com/iancoleman/strcase" 7 | "github.com/samber/lo" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | func Percent(num, total, decimals int) string { 13 | value := 0.0 14 | 15 | if total != 0 { 16 | value = float64(num) / float64(total) * float64(100) 17 | } 18 | 19 | return fmt.Sprintf("%."+strconv.Itoa(decimals)+"f%%", value) 20 | } 21 | 22 | func PercentExplained(num, total, decimals int) string { 23 | return fmt.Sprintf("%d/%d=%s", num, total, Percent(num, total, decimals)) 24 | } 25 | 26 | func Match(value, pattern string) bool { 27 | return glob.MustCompile(pattern).Match(value) 28 | } 29 | 30 | func MatchSome(value string, patterns []string) bool { 31 | return lo.SomeBy(patterns, func(p string) bool { return Match(value, p) }) 32 | } 33 | 34 | func Between(str string, start string, end string) (result string) { 35 | s := strings.Index(str, start) 36 | if s == -1 { 37 | return 38 | } 39 | s += len(start) 40 | e := strings.Index(str[s:], end) 41 | if e == -1 { 42 | return 43 | } 44 | return str[s : s+e] 45 | } 46 | 47 | func BetweenOrSame(str string, start string, end string) string { 48 | s := strings.Index(str, start) 49 | if s == -1 { 50 | return str 51 | } 52 | s += len(start) 53 | e := strings.Index(str[s:], end) 54 | if e == -1 { 55 | return str 56 | } 57 | return str[s : s+e] 58 | } 59 | 60 | func Before(value string, a string) string { 61 | pos := strings.Index(value, a) 62 | if pos == -1 { 63 | return value 64 | } 65 | return value[0:pos] 66 | } 67 | 68 | func After(value string, a string) string { 69 | pos := strings.Index(value, a) 70 | if pos == -1 { 71 | return value 72 | } 73 | adjustedPos := pos + len(a) 74 | if adjustedPos >= len(value) { 75 | return "" 76 | } 77 | return value[adjustedPos:] 78 | } 79 | 80 | func BeforeLast(value string, a string) string { 81 | pos := strings.LastIndex(value, a) 82 | if pos == -1 { 83 | return value 84 | } 85 | return value[0:pos] 86 | } 87 | 88 | func AfterLast(value string, a string) string { 89 | pos := strings.LastIndex(value, a) 90 | if pos == -1 { 91 | return value 92 | } 93 | adjustedPos := pos + len(a) 94 | if adjustedPos >= len(value) { 95 | return "" 96 | } 97 | return value[adjustedPos:] 98 | } 99 | 100 | func HumanCase(str string) string { 101 | return strings.ReplaceAll(strcase.ToSnake(str), "_", " ") 102 | } 103 | 104 | func AddPrefix(str string, prefix string) string { 105 | if strings.HasPrefix(str, prefix) { 106 | return str 107 | } 108 | return prefix + str 109 | } 110 | -------------------------------------------------------------------------------- /pkg/common/timex/timex.go: -------------------------------------------------------------------------------- 1 | package timex 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | func FileTimestamp(value time.Time) string { 8 | return value.Format("20060102150405") 9 | } 10 | 11 | func FileTimestampForNow() string { 12 | return FileTimestamp(time.Now()) 13 | } 14 | 15 | func Human(time time.Time) string { 16 | return time.Format("2006-01-02 15:04:05") 17 | } 18 | -------------------------------------------------------------------------------- /pkg/common/tplx/tplx.go: -------------------------------------------------------------------------------- 1 | package tplx 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/Masterminds/sprig" 7 | "github.com/wttech/aemc/pkg/common/filex" 8 | "github.com/wttech/aemc/pkg/common/pathx" 9 | "strings" 10 | "text/template" 11 | ) 12 | 13 | var ( 14 | DelimLeft = "[[" 15 | DelimRight = "]]" 16 | ) 17 | 18 | func New(name string) *template.Template { 19 | return template.New(name).Funcs(funcMap) 20 | } 21 | 22 | var funcMap = sprig.TxtFuncMap() 23 | 24 | func init() { 25 | funcMap["canonicalPath"] = func(pathSegments ...string) string { 26 | defer recovery() 27 | return pathx.Canonical(strings.Join(pathSegments, "/")) 28 | } 29 | } 30 | 31 | func recovery() { 32 | recover() 33 | } 34 | 35 | func RenderKey(key string, data any) (string, error) { 36 | return RenderString(DelimLeft+"."+key+DelimRight, data) 37 | } 38 | 39 | func RenderString(tplContent string, data any) (string, error) { 40 | tplParsed, err := New("string-template").Delims(DelimLeft, DelimRight).Parse(tplContent) 41 | if err != nil { 42 | return "", err 43 | } 44 | var tplOutput bytes.Buffer 45 | if err := tplParsed.Execute(&tplOutput, data); err != nil { 46 | return "", err 47 | } 48 | return tplOutput.String(), nil 49 | } 50 | 51 | func RenderFile(file string, content string, data map[string]any) error { 52 | scriptContent, err := RenderString(content, data) 53 | if err != nil { 54 | return err 55 | } 56 | if err := filex.WriteString(file, scriptContent); err != nil { 57 | return fmt.Errorf("cannot render template file '%s': %w", file, err) 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/crypto.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/wttech/aemc/pkg/common/filex" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "github.com/wttech/aemc/pkg/common/pathx" 9 | ) 10 | 11 | const ( 12 | CryptoProtectPath = "/system/console/crypto/.json" 13 | ) 14 | 15 | type Crypto struct { 16 | instance *Instance 17 | 18 | keyBundleSymbolicName string 19 | } 20 | 21 | type CryptoProtectResult struct { 22 | Protected string 23 | } 24 | 25 | func NewCrypto(instance *Instance) *Crypto { 26 | cv := instance.manager.aem.config.Values() 27 | 28 | return &Crypto{ 29 | instance: instance, 30 | 31 | keyBundleSymbolicName: cv.GetString("instance.crypto.key_bundle_symbolic_name"), 32 | } 33 | } 34 | 35 | func (c Crypto) Setup(hmacFile string, masterFile string) (bool, error) { 36 | if !c.instance.IsLocal() { 37 | return false, fmt.Errorf("%s > Crypto keys could only be set on local instance", c.instance.IDColor()) 38 | } 39 | if !pathx.Exists(hmacFile) { 40 | return false, fmt.Errorf("%s > Crypto hmac file '%s' does not exist", c.instance.IDColor(), hmacFile) 41 | } 42 | if !pathx.Exists(masterFile) { 43 | return false, fmt.Errorf("%s > Crypto master file '%s' does not exist", c.instance.IDColor(), masterFile) 44 | } 45 | osgi := c.instance.OSGI() 46 | keyBundle, err := osgi.BundleManager().Find(c.keyBundleSymbolicName) 47 | if err != nil { 48 | return false, err 49 | } 50 | if keyBundle == nil { 51 | return false, fmt.Errorf("%s > cannot find Crypto key bundle using symbolic name '%s'", c.instance.IDColor(), c.keyBundleSymbolicName) 52 | } 53 | keyDir := fmt.Sprintf("%s/data", c.instance.Local().BundleDir(keyBundle.ID)) 54 | hmacTargetFile := fmt.Sprintf("%s/hmac", keyDir) 55 | masterTargetFile := fmt.Sprintf("%s/master", keyDir) 56 | 57 | hmacOk, err := filex.Equals(hmacFile, hmacTargetFile) 58 | if err != nil { 59 | return false, err 60 | } 61 | masterOk, err := filex.Equals(masterFile, masterTargetFile) 62 | if err != nil { 63 | return false, err 64 | } 65 | if hmacOk && masterOk { 66 | log.Debugf("%s > skipping setting Crypto keys (hmac '%s', master '%s') as they are up-to-date", c.instance.IDColor(), hmacFile, masterFile) 67 | return false, nil 68 | } 69 | log.Infof("%s > copying Crypto hmac file from '%s' to '%s'", c.instance.IDColor(), hmacFile, hmacTargetFile) 70 | if err := filex.Copy(hmacFile, hmacTargetFile, true); err != nil { 71 | return false, fmt.Errorf("%s > cannot copy Crypto hmac file from '%s' to '%s': %w", c.instance.IDColor(), hmacFile, hmacTargetFile, err) 72 | } 73 | log.Infof("%s > copying Crypto master file from '%s' to '%s'", c.instance.IDColor(), masterFile, masterTargetFile) 74 | if err := filex.Copy(masterFile, masterTargetFile, true); err != nil { 75 | return false, fmt.Errorf("%s > cannot copy Crypto master file from '%s' to '%s'> %w", c.instance.IDColor(), masterFile, masterTargetFile, err) 76 | } 77 | if err := osgi.Restart(); err != nil { 78 | return false, err 79 | } 80 | return true, nil 81 | } 82 | 83 | func (c Crypto) Protect(value string) (string, error) { 84 | log.Infof("%s > encrypting text using Crypto", c.instance.IDColor()) 85 | response, err := c.instance.http.RequestFormData(map[string]any{"datum": value}).Post(CryptoProtectPath) 86 | 87 | if err != nil { 88 | return "", fmt.Errorf("%s > cannot encrypt text using Crypto: %w", c.instance.IDColor(), err) 89 | } else if response.IsError() { 90 | return "", fmt.Errorf("%s > cannot encrypt text using Crypto: %s", c.instance.IDColor(), response.Status()) 91 | } 92 | 93 | var result CryptoProtectResult 94 | if err = fmtx.UnmarshalJSON(response.RawBody(), &result); err != nil { 95 | return "", fmt.Errorf("%s > cannot parse Crypto response: %w", c.instance.IDColor(), err) 96 | } 97 | 98 | return result.Protected, nil 99 | } 100 | -------------------------------------------------------------------------------- /pkg/facade.go: -------------------------------------------------------------------------------- 1 | // Package pkg provides configuration and AEM facade 2 | package pkg 3 | 4 | import ( 5 | "github.com/wttech/aemc/pkg/cfg" 6 | "io" 7 | "os" 8 | "os/exec" 9 | ) 10 | 11 | // AEM is a facade to access AEM-related API 12 | type AEM struct { 13 | output io.Writer 14 | config *cfg.Config 15 | project *Project 16 | baseOpts *BaseOpts 17 | 18 | vendorManager *VendorManager 19 | instanceManager *InstanceManager 20 | contentManager *ContentManager 21 | } 22 | 23 | func DefaultAEM() *AEM { 24 | return NewAEM(cfg.NewConfig()) 25 | } 26 | 27 | func NewAEM(config *cfg.Config) *AEM { 28 | result := new(AEM) 29 | result.output = os.Stdout 30 | result.config = config 31 | result.project = NewProject(result) 32 | result.baseOpts = NewBaseOpts(result) 33 | result.vendorManager = NewVendorManager(result) 34 | result.instanceManager = NewInstanceManager(result) 35 | result.contentManager = NewContentManager(result) 36 | return result 37 | } 38 | 39 | func (a *AEM) Output() io.Writer { 40 | return a.output 41 | } 42 | 43 | func (a *AEM) SetOutput(output io.Writer) { 44 | a.output = output 45 | } 46 | 47 | func (a *AEM) CommandOutput(cmd *exec.Cmd) { 48 | cmd.Stdout = a.output 49 | cmd.Stderr = a.output 50 | } 51 | 52 | func (a *AEM) Config() *cfg.Config { 53 | return a.config 54 | } 55 | 56 | func (a *AEM) BaseOpts() *BaseOpts { 57 | return a.baseOpts 58 | } 59 | 60 | func (a *AEM) VendorManager() *VendorManager { 61 | return a.vendorManager 62 | } 63 | 64 | func (a *AEM) InstanceManager() *InstanceManager { 65 | return a.instanceManager 66 | } 67 | 68 | func (a *AEM) ContentManager() *ContentManager { 69 | return a.contentManager 70 | } 71 | 72 | func (a *AEM) Project() *Project { 73 | return a.project 74 | } 75 | 76 | func (a *AEM) Detached() bool { 77 | return !a.config.TemplateFileExists() 78 | } 79 | -------------------------------------------------------------------------------- /pkg/gts/gts_certificate.go: -------------------------------------------------------------------------------- 1 | package gts 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "encoding/json" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | AemTimeLayout = "Mon Jan 2 15:04:05 MST 2006" 14 | ) 15 | 16 | type Certificate struct { 17 | Alias string `json:"alias"` 18 | Subject string `json:"subject"` 19 | Issuer string `json:"issuer"` 20 | NotBefore string `json:"notBefore"` 21 | NotAfter string `json:"notAfter"` 22 | SerialNumber json.Number `json:"serialNumber"` 23 | } 24 | 25 | func (c *Certificate) NotBeforeDate() (time.Time, error) { 26 | return time.Parse(AemTimeLayout, c.NotBefore) 27 | } 28 | 29 | func (c *Certificate) NotAfterDate() (time.Time, error) { 30 | return time.Parse(AemTimeLayout, c.NotAfter) 31 | } 32 | 33 | func standardizeSpaces(a string) string { 34 | return strings.Join(strings.Fields(a), "") 35 | } 36 | 37 | func (c *Certificate) IsEqual(certifiacte x509.Certificate) (bool, error) { 38 | notBefore, err := c.NotBeforeDate() 39 | 40 | if err != nil { 41 | return false, err 42 | } 43 | 44 | notAfter, err := c.NotAfterDate() 45 | 46 | if err != nil { 47 | return false, err 48 | } 49 | 50 | return standardizeSpaces(c.Issuer) == standardizeSpaces(certifiacte.Issuer.String()) && 51 | standardizeSpaces(c.Subject) == standardizeSpaces(certifiacte.Subject.String()) && 52 | notBefore.Equal(certifiacte.NotBefore) && 53 | notAfter.Equal(certifiacte.NotAfter) && 54 | c.SerialNumber.String() == certifiacte.SerialNumber.String(), nil 55 | } 56 | func (c *Certificate) MarshalText() string { 57 | bs := bytes.NewBufferString("") 58 | bs.WriteString(fmtx.TblMap("details", "name", "value", map[string]any{ 59 | "alias": c.Alias, 60 | "subject": c.Subject, 61 | "issuer": c.Issuer, 62 | "notBefore": c.NotBefore, 63 | "notAfter": c.NotAfter, 64 | "serialNumber": c.SerialNumber, 65 | })) 66 | return bs.String() 67 | } 68 | -------------------------------------------------------------------------------- /pkg/gts/gts_status.go: -------------------------------------------------------------------------------- 1 | package gts 2 | 3 | import ( 4 | "bytes" 5 | "crypto/x509" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "io" 9 | ) 10 | 11 | type Status struct { 12 | Created bool `json:"exists"` 13 | Certificates []Certificate `json:"aliases"` 14 | } 15 | 16 | func (s *Status) FindCertificate(certificate x509.Certificate) (*Certificate, error) { 17 | for i := range s.Certificates { 18 | isEqualResult, err := s.Certificates[i].IsEqual(certificate) 19 | 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | if isEqualResult { 25 | return &s.Certificates[i], nil 26 | } 27 | } 28 | 29 | return nil, nil 30 | } 31 | 32 | func (s *Status) FindCertificateByAlias(alias string) *Certificate { 33 | for i := range s.Certificates { 34 | if s.Certificates[i].Alias == alias { 35 | return &s.Certificates[i] 36 | } 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func UnmarshalStatus(readCloser io.ReadCloser) (*Status, error) { 43 | // if trust store exist, it doesn't contain property exists 44 | var status = Status{Created: true, Certificates: []Certificate{}} 45 | 46 | if err := fmtx.UnmarshalJSON(readCloser, &status); err != nil { 47 | return nil, err 48 | } 49 | 50 | return &status, nil 51 | } 52 | 53 | func (s *Status) MarshalText() string { 54 | bs := bytes.NewBufferString("") 55 | bs.WriteString(fmtx.TblMap("details", "name", "value", map[string]any{ 56 | "created": s.Created, 57 | })) 58 | bs.WriteString("\n") 59 | bs.WriteString(fmtx.TblRows("certificates", true, []string{"alias"}, lo.Map(s.Certificates, func(c Certificate, _ int) map[string]any { 60 | return map[string]any{"alias": c.Alias} 61 | }))) 62 | return bs.String() 63 | } 64 | -------------------------------------------------------------------------------- /pkg/http.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/base64" 6 | "fmt" 7 | "github.com/go-resty/resty/v2" 8 | nurl "net/url" 9 | "reflect" 10 | "time" 11 | ) 12 | 13 | // HTTP Simplifies making requests to AEM instance. Handles authentication and URI auto-escaping. 14 | type HTTP struct { 15 | instance *Instance 16 | baseURL string 17 | } 18 | 19 | func NewHTTP(instance *Instance, baseURL string) *HTTP { 20 | return &HTTP{instance: instance, baseURL: baseURL} 21 | } 22 | 23 | func (h *HTTP) Client() *resty.Client { 24 | cv := h.instance.manager.aem.config.Values() 25 | client := resty.New() 26 | client.SetBaseURL(h.baseURL) 27 | client.SetBasicAuth(h.instance.User(), h.instance.Password()) 28 | client.SetDoNotParseResponse(true) 29 | client.SetTimeout(cv.GetDuration("instance.http.timeout")) 30 | client.SetDebug(cv.GetBool("instance.http.debug")) 31 | client.SetDisableWarn(cv.GetBool("instance.http.disable_warn")) 32 | if cv.GetBool("instance.http.ignore_ssl_errors") { 33 | client.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}) 34 | } 35 | return client 36 | } 37 | 38 | func (h *HTTP) Request() *resty.Request { 39 | return h.Client().R().SetBasicAuth(h.instance.User(), h.instance.Password()) 40 | } 41 | 42 | func (h *HTTP) RequestWithTimeout(timeout time.Duration) *resty.Request { 43 | client := h.Client() 44 | client.SetTimeout(timeout) 45 | return client.R() 46 | } 47 | 48 | func (h *HTTP) RequestFormData(props map[string]any) *resty.Request { 49 | request := h.Request() 50 | for k, v := range props { 51 | rv := reflect.ValueOf(v) 52 | if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array { 53 | for i := 0; i < rv.Len(); i++ { 54 | request.FormData.Add(k, fmt.Sprintf("%v", rv.Index(i).Interface())) 55 | } 56 | } else { 57 | request.FormData.Add(k, fmt.Sprintf("%v", v)) 58 | } 59 | } 60 | return request 61 | } 62 | 63 | func (h *HTTP) BasicAuthCredentials() string { 64 | auth := h.instance.user + ":" + h.instance.password 65 | 66 | return base64.StdEncoding.EncodeToString([]byte(auth)) 67 | } 68 | 69 | func (h *HTTP) Port() string { 70 | urlConfig, _ := nurl.Parse(h.baseURL) 71 | port := urlConfig.Port() 72 | if port == "" { 73 | if urlConfig.Scheme == "https" { 74 | port = "443" 75 | } else { 76 | port = "80" 77 | } 78 | } 79 | return port 80 | } 81 | 82 | func (h *HTTP) Hostname() string { 83 | urlConfig, _ := nurl.Parse(h.baseURL) 84 | return urlConfig.Hostname() 85 | } 86 | 87 | func (h *HTTP) Address() string { 88 | return fmt.Sprintf("%s:%s", h.Hostname(), h.Port()) 89 | } 90 | 91 | func (h *HTTP) BaseURL() string { 92 | return h.baseURL 93 | } 94 | -------------------------------------------------------------------------------- /pkg/http_int_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package pkg_test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "net/http" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestInstanceHTTPGetRequest(t *testing.T) { 14 | t.Parallel() 15 | a := assert.New(t) 16 | 17 | aem := pkg.DefaultAEM() 18 | instance := aem.InstanceManager().NewLocalAuthor() 19 | response, err := instance.HTTP().Request().Get("/system/console/bundles.json") 20 | 21 | a.Nil(err) 22 | a.Equal(http.StatusOK, response.StatusCode()) 23 | a.Equal("application/json;charset=utf-8", response.Header().Get("Content-Type")) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/instance/constants.go: -------------------------------------------------------------------------------- 1 | package instance 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | const ( 8 | IDDelimiter = "_" 9 | AdHocDelimiter = "=" 10 | URLLocalAuthor = "http://127.0.0.1:4502" 11 | URLLocalPublish = "http://127.0.0.1:4503" 12 | PasswordDefault = "admin" 13 | UserDefault = "admin" 14 | LocationLocal = "local" 15 | LocationRemote = "remote" 16 | RoleAuthorPortSuffix = "02" 17 | AemVersionUnknown = "" 18 | 19 | AttributeLocal = "local" 20 | AttributeRemote = "remote" 21 | AttributeCreated = "created" 22 | AttributeUncreated = "uncreated" 23 | AttributeUpToDate = "up-to-date" 24 | AttributeOutOfDate = "out-of-date" 25 | ) 26 | 27 | const ( 28 | ProcessingAuto = "auto" 29 | ProcessingParallel = "parallel" 30 | ProcessingSerial = "serial" 31 | ) 32 | 33 | func ProcessingModes() []string { 34 | return []string{ProcessingAuto, ProcessingParallel, ProcessingSerial} 35 | } 36 | 37 | // CbpExecutable is a recompiled binary from code at 'https://ritchielawrence.github.io/cmdow' to avoid false-positive antivirus detection 38 | // 39 | //go:embed resource/cbpow.exe 40 | var CbpExecutable []byte 41 | 42 | //go:embed resource/oak-run/set-password.groovy 43 | var OakRunSetPassword string 44 | 45 | type Role string 46 | 47 | const ( 48 | CbpExecutableFilename = "cbpow.exe" 49 | 50 | RoleAuthor Role = "author" 51 | RolePublish Role = "publish" 52 | RoleAdHoc Role = "adhoc" 53 | ) 54 | -------------------------------------------------------------------------------- /pkg/instance/resource/cbpow.exe: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:63a5f1d774eff923233fe78dd25558aec9d0c5a28c444495b72d2e65cea894a8 3 | size 68608 4 | -------------------------------------------------------------------------------- /pkg/instance/resource/oak-run/set-password.groovy: -------------------------------------------------------------------------------- 1 | import org.apache.jackrabbit.oak.spi.security.user.util.PasswordUtil 2 | import org.apache.jackrabbit.oak.spi.commit.CommitInfo 3 | import org.apache.jackrabbit.oak.spi.commit.EmptyHook 4 | 5 | class Global { 6 | static userNode = null; 7 | } 8 | 9 | void findUserNode(ub) { 10 | if (ub.hasProperty("rep:principalName")) { 11 | if ("rep:principalName = [[.User]]".equals(ub.getProperty("rep:principalName").toString())) { 12 | Global.userNode = ub; 13 | } 14 | } 15 | ub.childNodeNames.each { it -> 16 | if (Global.userNode == null) { 17 | findUserNode(ub.getChildNode(it)); 18 | } 19 | } 20 | } 21 | 22 | ub = session.store.root.builder(); 23 | findUserNode(ub.getChildNode("home").getChildNode("users")); 24 | 25 | if (Global.userNode) { 26 | println("Found user node: " + Global.userNode.toString()); 27 | Global.userNode.setProperty("rep:password", PasswordUtil.buildPasswordHash("[[.Password]]")); 28 | session.store.merge(ub, EmptyHook.INSTANCE, CommitInfo.EMPTY); 29 | println("Updated user node: " + Global.userNode.toString()); 30 | } else { 31 | println("Could not find user node!"); 32 | } 33 | -------------------------------------------------------------------------------- /pkg/instance/utils.go: -------------------------------------------------------------------------------- 1 | package instance 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | "github.com/wttech/aemc/pkg/common/stringsx" 6 | "strings" 7 | ) 8 | 9 | func MatchSome(id string, value string, patterns []string) bool { 10 | return lo.SomeBy(patterns, func(p string) bool { return Match(id, value, p) }) 11 | } 12 | 13 | func Match(id string, value string, pattern string) bool { 14 | if !strings.Contains(pattern, ":") { 15 | return stringsx.Match(value, pattern) 16 | } 17 | 18 | parts := strings.Split(pattern, ":") 19 | 20 | instancePattern := parts[0] 21 | instancePatterns := strings.Split(instancePattern, ",") 22 | if !stringsx.MatchSome(id, instancePatterns) { 23 | return false 24 | } 25 | 26 | specificPattern := parts[1] 27 | specificPatterns := strings.Split(specificPattern, ",") 28 | return stringsx.MatchSome(value, specificPatterns) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/instance/utils_test.go: -------------------------------------------------------------------------------- 1 | package instance 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestInstanceMatchers(t *testing.T) { 9 | t.Parallel() 10 | 11 | assert.False(t, Match("local_publish", "com.acme.aem.core.ExampleService", "com.acme.aem.core.OtherService")) 12 | assert.False(t, Match("local_publish", "com.acme.aem.core.OtherService", "com.acme.aem.core.ExampleService")) 13 | assert.True(t, Match("local_publish", "com.acme.aem.core.ExampleService", "com.acme.aem.core.ExampleService")) 14 | assert.True(t, Match("local_publish", "com.acme.aem.core.ExampleService", "local_publish:com.acme.aem.core.ExampleService")) 15 | assert.True(t, Match("local_publish", "com.acme.aem.core.ExampleService", "local_publish:com.acme.aem.core.*Service")) 16 | assert.True(t, Match("local_publish", "com.acme.aem.core.ExampleService", "*_publish*:com.acme.aem.core.*Service")) 17 | assert.True(t, Match("local_publish", "com.acme.aem.core.ExampleService", "local_publish,int_publish_1,int_publish_2:com.acme.aem.core.*Service")) 18 | } 19 | -------------------------------------------------------------------------------- /pkg/instance_int_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package pkg_test 4 | 5 | import ( 6 | "github.com/stretchr/testify/assert" 7 | "github.com/wttech/aemc/pkg" 8 | "testing" 9 | ) 10 | 11 | func TestInstanceTimeLocation(t *testing.T) { 12 | t.Parallel() 13 | a := assert.New(t) 14 | 15 | aem := pkg.DefaultAEM() 16 | instance := aem.InstanceManager().NewLocalAuthor() 17 | instanceLocation := instance.TimeLocation() 18 | 19 | a.NotEmpty(instanceLocation.String()) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/instance_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package pkg_test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestInstanceNewLocalAuthor(t *testing.T) { 13 | t.Parallel() 14 | 15 | aem := pkg.DefaultAEM() 16 | instance := aem.InstanceManager().NewLocalAuthor() 17 | 18 | assert.Equal(t, "http://127.0.0.1:4502", instance.HTTP().BaseURL()) 19 | assert.Equal(t, "admin", instance.User()) 20 | assert.Equal(t, "admin", instance.Password()) 21 | } 22 | 23 | func TestInstanceNewLocalPublish(t *testing.T) { 24 | t.Parallel() 25 | 26 | aem := pkg.DefaultAEM() 27 | instance := aem.InstanceManager().NewLocalPublish() 28 | 29 | assert.Equal(t, "http://127.0.0.1:4503", instance.HTTP().BaseURL()) 30 | assert.Equal(t, "admin", instance.User()) 31 | assert.Equal(t, "admin", instance.Password()) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/keystore/private_key.go: -------------------------------------------------------------------------------- 1 | package keystore 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/wttech/aemc/pkg/common/fmtx" 7 | ) 8 | 9 | type CertificateChain struct { 10 | Subject string `json:"subject"` 11 | Issuer string `json:"issuer"` 12 | NotBefore string `json:"notBefore"` 13 | NotAfter string `json:"notAfter"` 14 | SerialNumber json.Number `json:"serialNumber"` 15 | } 16 | 17 | type PrivateKey struct { 18 | Alias string `json:"alias"` 19 | EntryType string `json:"entryType"` 20 | Algorithm string `json:"algorithm"` 21 | Format string `json:"format"` 22 | Chain []CertificateChain `json:"chain"` 23 | } 24 | 25 | func (c *CertificateChain) MarshalText() string { 26 | bs := bytes.NewBufferString("") 27 | bs.WriteString(fmtx.TblMap("details", "name", "value", map[string]any{ 28 | "subject": c.Subject, 29 | "issuer": c.Issuer, 30 | "notBefore": c.NotBefore, 31 | "notAfter": c.NotAfter, 32 | "serialNumber": c.SerialNumber, 33 | })) 34 | return bs.String() 35 | } 36 | 37 | func (c *PrivateKey) MarshalText() string { 38 | bs := bytes.NewBufferString("") 39 | bs.WriteString(fmtx.TblMap("details", "name", "value", map[string]any{ 40 | "alias": c.Alias, 41 | "entryType": c.EntryType, 42 | "algorithm": c.Algorithm, 43 | "format": c.Format, 44 | "chain": c.Chain, 45 | })) 46 | return bs.String() 47 | } 48 | -------------------------------------------------------------------------------- /pkg/keystore/status.go: -------------------------------------------------------------------------------- 1 | package keystore 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | 7 | "github.com/samber/lo" 8 | "github.com/wttech/aemc/pkg/common/fmtx" 9 | ) 10 | 11 | type Status struct { 12 | Created bool `json:"exists"` 13 | PrivateKeys []PrivateKey `json:"aliases"` 14 | } 15 | 16 | func UnmarshalStatus(readCloser io.ReadCloser) (*Status, error) { 17 | // if key store exist, it doesn't contain property exists 18 | var status = Status{Created: true, PrivateKeys: []PrivateKey{}} 19 | 20 | if err := fmtx.UnmarshalJSON(readCloser, &status); err != nil { 21 | return nil, err 22 | } 23 | 24 | return &status, nil 25 | } 26 | 27 | func (s *Status) MarshalText() string { 28 | bs := bytes.NewBufferString("") 29 | bs.WriteString(fmtx.TblMap("details", "name", "value", map[string]any{ 30 | "created": s.Created, 31 | })) 32 | bs.WriteString("\n") 33 | bs.WriteString(fmtx.TblRows("private keys", true, []string{"alias"}, lo.Map(s.PrivateKeys, func(c PrivateKey, _ int) map[string]any { 34 | return map[string]any{"alias": c.Alias} 35 | }))) 36 | return bs.String() 37 | } 38 | 39 | func (s *Status) HasAlias(privateKeyAlias string) bool { 40 | return lo.ContainsBy(s.PrivateKeys, func(c PrivateKey) bool { 41 | return c.Alias == privateKeyAlias 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/oak.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // OAK Facade for managing OAK repository. 4 | type OAK struct { 5 | instance *Instance 6 | 7 | indexManager *OAKIndexManager 8 | } 9 | 10 | func NewOAK(instance *Instance) *OAK { 11 | return &OAK{ 12 | instance: instance, 13 | 14 | indexManager: NewOAKIndexManager(instance), 15 | } 16 | } 17 | 18 | func (o *OAK) IndexManager() *OAKIndexManager { 19 | return o.indexManager 20 | } 21 | 22 | func (o *OAK) oakRun() *OakRun { 23 | return o.instance.manager.aem.vendorManager.oakRun 24 | } 25 | 26 | func (o *OAK) Compact() error { 27 | return o.instance.manager.aem.vendorManager.oakRun.Compact(o.instance.local.Dir()) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/oak/constants.go: -------------------------------------------------------------------------------- 1 | package oak 2 | 3 | const ( 4 | IndexListJson = "/oak:index.harray.1.json" 5 | IndexPrimaryType = "oak:QueryIndexDefinition" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/oak/index.go: -------------------------------------------------------------------------------- 1 | package oak 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "sort" 9 | "strings" 10 | ) 11 | 12 | type IndexList struct { 13 | List []IndexListItem `json:"__children__"` 14 | } 15 | 16 | type IndexListItem struct { 17 | PrimaryType string `json:"jcr:primaryType,omitempty"` 18 | Name string `json:"__name__"` 19 | Type string `json:"type"` 20 | Async any `json:"async"` /* string or []string */ 21 | Unique bool `json:"unique"` 22 | IncludedPaths any `json:"includedPaths"` /* string or []string */ 23 | ExcludedPaths any `json:"excludedPaths"` /* string or []string */ 24 | QueryPaths any `json:"queryPaths"` /* string or []string */ 25 | Reindex bool `json:"reindex"` 26 | ReindexCount int `json:"reindexCount"` 27 | EvaluatePathRestrictions bool `json:"evaluatePathRestrictions"` 28 | DeclaringNodeTypes []string `json:"declaringNodeTypes"` 29 | PropertyNames []string `json:"propertyNames"` 30 | Tags []string `json:"tags"` 31 | } 32 | 33 | func (il *IndexList) Total() int { 34 | return len(il.List) 35 | } 36 | 37 | func (il IndexList) MarshalText() string { 38 | bs := bytes.NewBufferString("") 39 | bs.WriteString(fmtx.TblMap("stats", "stat", "value", map[string]any{ 40 | "total": il.Total(), 41 | })) 42 | bs.WriteString("\n") 43 | 44 | var indexesSorted []IndexListItem 45 | indexesSorted = append(indexesSorted, il.List...) 46 | sort.SliceStable(indexesSorted, func(i, j int) bool { 47 | return strings.Compare(indexesSorted[i].Name, indexesSorted[j].Name) < 0 48 | }) 49 | 50 | bs.WriteString(fmtx.TblRows("list", false, []string{"name", "type", "async", "reindex", "reindex count", "tags"}, lo.Map(indexesSorted, func(i IndexListItem, _ int) map[string]any { 51 | return map[string]any{ 52 | "name": i.Name, 53 | "type": i.Type, 54 | "async": i.Async, 55 | "reindex": i.Reindex, 56 | "reindex count": i.ReindexCount, 57 | "tags": i.Tags, 58 | } 59 | }))) 60 | return bs.String() 61 | } 62 | 63 | func (i IndexListItem) String() string { 64 | return fmt.Sprintf("index '%s' (reindex: %v)", i.Name, i.Reindex) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/osgi.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "time" 7 | ) 8 | 9 | // OSGi Facade for communicating with OSGi framework. 10 | type OSGi struct { 11 | instance *Instance 12 | 13 | bundleManager *OSGiBundleManager 14 | componentManager *OSGiComponentManager 15 | eventManager *OSGiEventManager 16 | configManager *OSGiConfigManager 17 | 18 | shutdownDelay time.Duration 19 | } 20 | 21 | func NewOSGi(instance *Instance) *OSGi { 22 | cv := instance.manager.aem.config.Values() 23 | 24 | return &OSGi{ 25 | instance: instance, 26 | 27 | bundleManager: NewBundleManager(instance), 28 | componentManager: NewComponentManager(instance), 29 | eventManager: &OSGiEventManager{instance: instance}, 30 | configManager: &OSGiConfigManager{instance: instance}, 31 | 32 | shutdownDelay: cv.GetDuration("instance.osgi.shutdown_delay"), 33 | } 34 | } 35 | 36 | func (o *OSGi) BundleManager() *OSGiBundleManager { 37 | return o.bundleManager 38 | } 39 | 40 | func (o *OSGi) ComponentManager() *OSGiComponentManager { 41 | return o.componentManager 42 | } 43 | 44 | func (o *OSGi) EventManager() *OSGiEventManager { 45 | return o.eventManager 46 | } 47 | 48 | func (o *OSGi) ConfigManager() *OSGiConfigManager { 49 | return o.configManager 50 | } 51 | 52 | func (o *OSGi) Shutdown() error { 53 | return o.shutdown("Stop") 54 | } 55 | 56 | func (o *OSGi) Restart() error { 57 | return o.shutdown("Restart") 58 | } 59 | 60 | const ( 61 | VMStatPath = "/system/console/vmstat" 62 | ) 63 | 64 | func (o *OSGi) shutdown(shutdownType string) error { 65 | log.Infof("%s > triggering OSGi shutdown of type '%s'", o.instance.IDColor(), shutdownType) 66 | response, err := o.instance.http.Request().SetFormData(map[string]string{ 67 | "shutdown_type": shutdownType, 68 | }).Post(VMStatPath) 69 | if err != nil { 70 | return fmt.Errorf("%s > cannot trigger OSGi shutdown of type '%s': %w", o.instance.IDColor(), shutdownType, err) 71 | } else if response.IsError() { 72 | return fmt.Errorf("%s > cannot trigger OSGi shutdown of type '%s': %s", o.instance.IDColor(), shutdownType, response.Status()) 73 | } 74 | time.Sleep(o.shutdownDelay) 75 | log.Infof("%s > triggered OSGi shutdown of type '%s'", o.instance.IDColor(), shutdownType) 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /pkg/osgi/bundle.go: -------------------------------------------------------------------------------- 1 | package osgi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "github.com/wttech/aemc/pkg/common/stringsx" 9 | ) 10 | 11 | type BundleList struct { 12 | Status string `json:"status"` 13 | Numbers []int `json:"s"` 14 | List []BundleListItem `json:"data"` 15 | } 16 | 17 | func (bl *BundleList) Total() int { 18 | return bl.Numbers[0] 19 | } 20 | 21 | func (bl *BundleList) Active() int { 22 | return bl.Numbers[1] 23 | } 24 | 25 | func (bl *BundleList) ActiveFragments() int { 26 | return bl.Numbers[2] 27 | } 28 | 29 | func (bl *BundleList) Resolved() int { 30 | return bl.Numbers[3] 31 | } 32 | 33 | func (bl *BundleList) Installed() int { 34 | return bl.Numbers[4] 35 | } 36 | 37 | func (bl *BundleList) StatusUnknown() bool { 38 | return len(bl.List) == 0 39 | } 40 | 41 | func (bl *BundleList) StatusLabelled() string { 42 | return fmt.Sprintf("%dt|%dba|%dfa|%dbr", bl.Total(), bl.Active(), bl.ActiveFragments(), bl.Resolved()) 43 | } 44 | 45 | func (bl *BundleList) StablePercent() string { 46 | return stringsx.PercentExplained(bl.Total()-(bl.Resolved()+bl.Installed()), bl.Total(), 0) 47 | } 48 | 49 | func (bl *BundleList) FindStable() []BundleListItem { 50 | return lo.Filter(bl.List, func(b BundleListItem, _ int) bool { return b.Stable() }) 51 | } 52 | 53 | func (bl *BundleList) FindUnstable() []BundleListItem { 54 | return lo.Filter(bl.List, func(b BundleListItem, _ int) bool { return !b.Stable() }) 55 | } 56 | 57 | func (bl BundleList) MarshalText() string { 58 | bs := bytes.NewBufferString("") 59 | bs.WriteString(fmtx.TblMap("stats", "stat", "value", map[string]any{ 60 | "total": bl.Total(), 61 | "active": bl.Active(), 62 | "fragments": bl.ActiveFragments(), 63 | "resolved": bl.Resolved(), 64 | })) 65 | bs.WriteString("\n") 66 | bs.WriteString(fmtx.TblRows("list", false, []string{"symbolic name", "state", "category", "version"}, lo.Map(bl.List, func(b BundleListItem, _ int) map[string]any { 67 | return map[string]any{ 68 | "symbolic name": b.SymbolicName, 69 | "state": b.State, 70 | "category": b.Category, 71 | "version": b.Version, 72 | } 73 | }))) 74 | return bs.String() 75 | } 76 | 77 | type BundleListItem struct { 78 | ID int `json:"id"` 79 | Fragment bool `json:"fragment"` 80 | StateRaw int `json:"stateRaw" yaml:"state_raw"` 81 | State string `json:"state"` 82 | Version string `json:"version"` 83 | SymbolicName string `json:"symbolicName" yaml:"symbolic_name"` 84 | Category string `json:"category"` 85 | } 86 | 87 | func (b *BundleListItem) Stable() bool { 88 | if b.Fragment { 89 | return b.StateRaw == int(BundleStateRawResolved) 90 | } 91 | return b.StateRaw == int(BundleStateRawActive) 92 | } 93 | 94 | func (b BundleListItem) String() string { 95 | return fmt.Sprintf("bundle '%s' (state: %s)", b.SymbolicName, b.State) 96 | } 97 | 98 | type BundleStateRaw int 99 | 100 | const ( 101 | BundleStateRawUninstalled BundleStateRaw = 0x00000001 102 | BundleStateRawInstalled BundleStateRaw = 0x00000002 103 | BundleStateRawResolved BundleStateRaw = 0x00000004 104 | BundleStateRawStarting BundleStateRaw = 0x00000008 105 | BundleStateRawStopping BundleStateRaw = 0x00000010 106 | BundleStateRawActive BundleStateRaw = 0x00000020 107 | BundleStateRawUnknown BundleStateRaw = -1 108 | ) 109 | -------------------------------------------------------------------------------- /pkg/osgi/component.go: -------------------------------------------------------------------------------- 1 | package osgi 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | ) 9 | 10 | type ComponentList struct { 11 | Total int `json:"status"` 12 | List []ComponentListItem `json:"data"` 13 | } 14 | 15 | func (cl ComponentList) MarshalText() string { 16 | bs := bytes.NewBufferString("") 17 | bs.WriteString(fmtx.TblMap("stats", "stat", "value", map[string]any{ 18 | "total": cl.Total, 19 | })) 20 | bs.WriteString("\n") 21 | bs.WriteString(fmtx.TblRows("list", false, []string{"id", "pid", "state", "bundle ID"}, lo.Map(cl.List, func(c ComponentListItem, _ int) map[string]any { 22 | return map[string]any{ 23 | "id": c.ID, 24 | "pid": c.PID, 25 | "state": c.State, 26 | "bundle ID": c.BundleID, 27 | } 28 | }))) 29 | return bs.String() 30 | } 31 | 32 | type ComponentListItem struct { 33 | ID string `json:"id"` 34 | PID string `json:"pid"` 35 | BundleID int `json:"bundleId"` 36 | Name string `json:"name"` 37 | StateRaw int `json:"stateRaw" yaml:"state_raw"` 38 | State string `json:"state"` 39 | Configurable string `json:"configurable"` 40 | } 41 | 42 | func (c ComponentListItem) UID() string { 43 | if c.PID != "" { 44 | return c.PID 45 | } 46 | return c.Name 47 | } 48 | 49 | func (c ComponentListItem) Active() bool { 50 | return c.StateRaw == int(ComponentStateRawActive) 51 | } 52 | 53 | func (c ComponentListItem) Satisfied() bool { 54 | return c.StateRaw == int(ComponentStateRawSatisfied) 55 | } 56 | 57 | func (c ComponentListItem) Unsatisfied() bool { 58 | return c.StateRaw == int(ComponentStateRawUnsatisfied) 59 | } 60 | 61 | func (c ComponentListItem) FailedActivation() bool { 62 | return c.StateRaw == int(ComponentStateRawFailedActivation) 63 | } 64 | 65 | func (c ComponentListItem) NoConfig() bool { 66 | return c.State == ComponentStateNoConfig 67 | } 68 | 69 | func (c ComponentListItem) Disabled() bool { 70 | return c.State == ComponentStateDisabled 71 | } 72 | 73 | func (c ComponentListItem) Enabled() bool { 74 | return !c.Disabled() 75 | } 76 | 77 | func (c ComponentListItem) String() string { 78 | return fmt.Sprintf("component '%s' (state: %s)", c.PID, c.State) 79 | } 80 | 81 | type ComponentStateRaw int 82 | 83 | const ( 84 | ComponentStateRawUnsatisfied ComponentStateRaw = 2 85 | ComponentStateRawSatisfied ComponentStateRaw = 4 86 | ComponentStateRawActive ComponentStateRaw = 8 87 | ComponentStateRawFailedActivation ComponentStateRaw = 16 88 | ComponentStateRawUnknown ComponentStateRaw = -1 89 | ) 90 | 91 | const ( 92 | ComponentStateActive = "active" 93 | ComponentStateSatisfied = "satisfied" 94 | ComponentStateUnsatisfiedReference = "unsatisfied (reference)" 95 | ComponentStateNoConfig = "no config" 96 | ComponentStateDisabled = "disabled" 97 | ComponentStateFailedActivation = "failed activation" 98 | ) 99 | -------------------------------------------------------------------------------- /pkg/osgi/config.go: -------------------------------------------------------------------------------- 1 | package osgi 2 | 3 | import ( 4 | "bytes" 5 | "github.com/samber/lo" 6 | "github.com/wttech/aemc/pkg/common" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "strings" 9 | ) 10 | 11 | type ConfigPIDs struct { 12 | PIDs []ConfigPID 13 | } 14 | 15 | type ConfigPID struct { 16 | ID string `json:"id"` 17 | Name string `json:"name"` 18 | HasConfig bool `json:"has_config"` 19 | FPID string `json:"fpid"` 20 | NameHint string `json:"nameHint"` 21 | } 22 | 23 | type ConfigListItem struct { 24 | PID string `json:"pid"` 25 | FPID string `json:"factoryPid"` 26 | Title string `json:"title"` 27 | Description string `json:"description"` 28 | Properties map[string]map[string]any `json:"properties"` 29 | AdditionalProperties string `json:"additionalProperties"` 30 | BundleLocation string `json:"bundle_location"` 31 | ServiceLocation string `json:"service_location"` 32 | } 33 | 34 | func (c ConfigListItem) PropertyValues() map[string]any { 35 | var result = map[string]any{} 36 | for k, def := range c.Properties { 37 | value, ok := def["value"] 38 | if ok { 39 | result[k] = value 40 | continue 41 | } 42 | values, ok := def["values"] 43 | if ok { 44 | result[k] = values 45 | } 46 | } 47 | return result 48 | } 49 | 50 | func (c ConfigListItem) Alias() string { 51 | for _, prop := range strings.Split(c.AdditionalProperties, ",") { 52 | if strings.HasPrefix(prop, ConfigAliasPropPrefix) { 53 | return prop[len(ConfigAliasPropPrefix):] 54 | } 55 | } 56 | return "" 57 | } 58 | 59 | type ConfigList struct { 60 | List []ConfigListItem 61 | } 62 | 63 | func (cl ConfigList) MarshalText() string { 64 | bs := bytes.NewBufferString("") 65 | bs.WriteString(fmtx.TblRows("list", true, []string{"pid"}, lo.Map(cl.List, func(c ConfigListItem, _ int) map[string]any { 66 | return map[string]any{"pid": c.PID} 67 | }))) 68 | return bs.String() 69 | } 70 | 71 | const ( 72 | ConfigPIDPlaceholder = "[Temporary PID replaced by real PID upon save]" // https://github.com/apache/felix-dev/blob/master/webconsole/src/main/java/org/apache/felix/webconsole/internal/configuration/ConfigurationUtil.java#L36 73 | ConfigAliasSeparator = "~" 74 | ConfigAliasPropPrefix = "alias" + ConfigAliasSeparator 75 | ConfigAliasPropValue = common.AppId 76 | ) 77 | -------------------------------------------------------------------------------- /pkg/osgi/event.go: -------------------------------------------------------------------------------- 1 | package osgi 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | "github.com/wttech/aemc/pkg/common/stringsx" 6 | "strings" 7 | ) 8 | 9 | type EventList struct { 10 | Status string `json:"status"` 11 | List []Event `json:"data"` 12 | } 13 | 14 | // Event represents single OSGi event 15 | type Event struct { 16 | ID string `json:"id"` 17 | Topic string `json:"topic"` 18 | Received int64 `json:"received"` 19 | Category string `json:"category"` 20 | Info string `json:"info"` 21 | } 22 | 23 | func (el *EventList) StatusUnknown() bool { 24 | return len(el.List) == 0 25 | } 26 | 27 | func (e Event) Service() string { 28 | return stringsx.Between(e.Info, ", objectClass=", ", bundle=") 29 | } 30 | 31 | func (e Event) Details() string { 32 | return detailsUnwrap(e.detailsDetermine()) 33 | } 34 | 35 | func (e Event) detailsDetermine() string { 36 | service := e.Service() 37 | if len(service) > 0 { 38 | return service 39 | } 40 | if len(e.Info) > 0 { 41 | return e.Info 42 | } 43 | return e.Topic 44 | } 45 | 46 | func detailsUnwrap(details string) string { 47 | partsLine := stringsx.BetweenOrSame(details, "[", "]") 48 | parts := lo.Map(strings.Split(partsLine, ","), func(s string, _ int) string { return strings.TrimSpace(s) }) 49 | if len(parts) > 0 { 50 | return parts[0] 51 | } 52 | return "" 53 | } 54 | -------------------------------------------------------------------------------- /pkg/osgi/manifest.go: -------------------------------------------------------------------------------- 1 | package osgi 2 | 3 | import ( 4 | "fmt" 5 | "github.com/essentialkaos/go-jar" 6 | ) 7 | 8 | func ReadBundleManifest(localPath string) (*BundleManifest, error) { 9 | manifest, err := jar.ReadFile(localPath) 10 | if err != nil { 11 | return nil, fmt.Errorf("cannot read OSGi bundle manifest from file '%s'", localPath) 12 | } 13 | return &BundleManifest{SymbolicName: manifest[AttributeSymbolicName], Version: manifest[AttributeVersion]}, nil 14 | } 15 | 16 | type BundleManifest struct { 17 | SymbolicName string 18 | Version string 19 | } 20 | 21 | const ( 22 | AttributeSymbolicName = "Bundle-SymbolicName" 23 | AttributeVersion = "Bundle-Version" 24 | ) 25 | -------------------------------------------------------------------------------- /pkg/osgi_component_manager.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/samber/lo" 6 | log "github.com/sirupsen/logrus" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "github.com/wttech/aemc/pkg/osgi" 9 | ) 10 | 11 | type OSGiComponentManager struct { 12 | instance *Instance 13 | } 14 | 15 | func NewComponentManager(instance *Instance) *OSGiComponentManager { 16 | return &OSGiComponentManager{instance: instance} 17 | } 18 | 19 | func (cm *OSGiComponentManager) ByPID(pid string) OSGiComponent { 20 | return OSGiComponent{manager: cm, pid: pid} 21 | } 22 | 23 | func (cm *OSGiComponentManager) Find(pid string) (*osgi.ComponentListItem, error) { 24 | components, err := cm.List() 25 | if err != nil { 26 | return nil, fmt.Errorf("%s > cannot find component '%s'", cm.instance.IDColor(), pid) 27 | } 28 | item, found := lo.Find(components.List, func(c osgi.ComponentListItem) bool { return pid == c.UID() }) 29 | if found { 30 | return &item, nil 31 | } 32 | return nil, nil 33 | } 34 | 35 | func (cm *OSGiComponentManager) List() (*osgi.ComponentList, error) { 36 | resp, err := cm.instance.http.Request().Get(ComponentsPathJson) 37 | if err != nil { 38 | return nil, fmt.Errorf("%s > cannot request component list: %w", cm.instance.IDColor(), err) 39 | } 40 | if resp.IsError() { 41 | return nil, fmt.Errorf("%s > cannot request component list: %s", cm.instance.IDColor(), resp.Status()) 42 | } 43 | var res osgi.ComponentList 44 | if err = fmtx.UnmarshalJSON(resp.RawBody(), &res); err != nil { 45 | return nil, fmt.Errorf("%s > cannot parse component list: %w", cm.instance.IDColor(), err) 46 | } 47 | return &res, nil 48 | } 49 | 50 | func (cm *OSGiComponentManager) Enable(pid string) error { 51 | log.Infof("%s > enabling component '%s'", cm.instance.IDColor(), pid) 52 | response, err := cm.instance.http.Request(). 53 | SetFormData(map[string]string{"action": "enable"}). 54 | Post(fmt.Sprintf("%s/%s", ComponentsPath, pid)) 55 | if err != nil { 56 | return fmt.Errorf("%s > cannot enable component '%s': %w", cm.instance.IDColor(), pid, err) 57 | } else if response.IsError() { 58 | return fmt.Errorf("%s > cannot enable component '%s': %s", cm.instance.IDColor(), pid, response.Status()) 59 | } 60 | log.Infof("%s > enabled component '%s'", cm.instance.IDColor(), pid) 61 | return nil 62 | } 63 | 64 | func (cm *OSGiComponentManager) Disable(pid string) error { 65 | log.Infof("%s > disabling component '%s'", cm.instance.IDColor(), pid) 66 | response, err := cm.instance.http.Request(). 67 | SetFormData(map[string]string{"action": "disable"}). 68 | Post(fmt.Sprintf("%s/%s", ComponentsPath, pid)) 69 | if err != nil { 70 | return fmt.Errorf("%s > cannot disable component '%s': %w", cm.instance.IDColor(), pid, err) 71 | } else if response.IsError() { 72 | return fmt.Errorf("%s > cannot disable component '%s': %s", cm.instance.IDColor(), pid, response.Status()) 73 | } 74 | log.Infof("%s > disabled component '%s'", cm.instance.IDColor(), pid) 75 | return nil 76 | } 77 | 78 | const ( 79 | ComponentsPath = "/system/console/components" 80 | ComponentsPathJson = ComponentsPath + ".json" 81 | ) 82 | -------------------------------------------------------------------------------- /pkg/osgi_event.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | -------------------------------------------------------------------------------- /pkg/osgi_event_manager.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wttech/aemc/pkg/common/fmtx" 6 | "github.com/wttech/aemc/pkg/osgi" 7 | ) 8 | 9 | const ( 10 | EventsPath = "/system/console/events" 11 | EventsPathJson = EventsPath + ".json" 12 | ) 13 | 14 | type OSGiEventManager struct { 15 | instance *Instance 16 | } 17 | 18 | func (em *OSGiEventManager) List() (*osgi.EventList, error) { 19 | resp, err := em.instance.http.Request().Get(EventsPathJson) 20 | if err != nil { 21 | return nil, fmt.Errorf("%s > cannot request event list: %w", em.instance.IDColor(), err) 22 | } else if resp.IsError() { 23 | return nil, fmt.Errorf("%s > cannot request event list: %s", em.instance.IDColor(), resp.Status()) 24 | } 25 | var res = new(osgi.EventList) 26 | if err = fmtx.UnmarshalJSON(resp.RawBody(), res); err != nil { 27 | return nil, fmt.Errorf("%s > cannot parse event list response: %w", em.instance.IDColor(), err) 28 | } 29 | return res, nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/osgi_int_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package pkg_test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestOSGiBundleList(t *testing.T) { 13 | t.Parallel() 14 | a := assert.New(t) 15 | 16 | aem := pkg.DefaultAEM() 17 | instance := aem.InstanceManager().NewLocalAuthor() 18 | 19 | response, err := instance.OSGI().BundleManager().List() 20 | a.Nil(err, "cannot read bundle list properly") 21 | a.NotEmpty(response.List, "bundle list should not be empty") 22 | a.NotEmpty(response.Status, "bundle status message should not be empty") 23 | 24 | a.False(response.StatusUnknown(), "bundle status should not be unknown") 25 | } 26 | 27 | func TestOSGiEventList(t *testing.T) { 28 | t.Parallel() 29 | a := assert.New(t) 30 | 31 | aem := pkg.DefaultAEM() 32 | instance := aem.InstanceManager().NewLocalAuthor() 33 | 34 | response, err := instance.OSGI().EventManager().List() 35 | a.Nil(err, "cannot read event list properly") 36 | a.NotEmpty(response.List, "event list should not be empty") 37 | 38 | a.False(response.StatusUnknown(), "event status should not be unknown") 39 | } 40 | -------------------------------------------------------------------------------- /pkg/pkg/api.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/dustin/go-humanize" 6 | "github.com/samber/lo" 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | "github.com/wttech/aemc/pkg/common/intsx" 9 | ) 10 | 11 | type List struct { 12 | List []ListItem `json:"results"` 13 | Total int `json:"total"` 14 | } 15 | 16 | type ListItem struct { 17 | PID string `json:"pid"` 18 | Name string `json:"name"` 19 | Path string `json:"path"` 20 | DownloadName string `json:"downloadName" yaml:"download_name"` 21 | Group string `json:"group"` 22 | Version string `json:"version"` 23 | Size int `json:"size"` 24 | 25 | Created int `json:"created"` 26 | LastWrapped int `json:"lastWrapped" yaml:"last_wrapped"` 27 | LastUnwrapped int `json:"lastUnwrapped" yaml:"last_unwrapped"` 28 | LastModified int `json:"lastModified" yaml:"last_modified"` 29 | LastUnpacked int `json:"lastUnpacked" yaml:"last_unpacked"` 30 | Resolved bool `json:"resolved"` 31 | } 32 | 33 | func (pi *ListItem) Built() bool { 34 | return pi.LastWrapped > 0 35 | } 36 | 37 | func (pi *ListItem) Installed() bool { 38 | return pi.LastUnpacked > 0 39 | } 40 | 41 | func (pi *ListItem) LastTouched() int { 42 | return intsx.MaxOf(0, pi.Created, pi.LastModified, pi.LastWrapped) 43 | } 44 | 45 | func (pl List) String() string { 46 | return fmt.Sprintf("bundle list (total: %d)", pl.Total) 47 | } 48 | 49 | func (pl List) MarshalText() string { 50 | return fmtx.TblRows("list", false, []string{"group", "name", "version", "size", "installed", "built"}, lo.Map(pl.List, func(item ListItem, _ int) map[string]any { 51 | return map[string]any{ 52 | "group": item.Group, 53 | "name": item.Name, 54 | "version": item.Version, 55 | "size": humanize.Bytes(uint64(item.Size)), 56 | "installed": item.Installed(), // TODO date or 'not yet' 57 | "built": item.Built(), // TODO date or 'not yet' 58 | } 59 | })) 60 | } 61 | 62 | type CommandResult struct { 63 | Success bool `json:"success"` 64 | Message string `json:"msg"` 65 | Path string `json:"path"` 66 | } 67 | -------------------------------------------------------------------------------- /pkg/pkg/common.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const ( 4 | MetaPath = "META-INF" 5 | VltDir = "vault" 6 | VltPath = MetaPath + "/" + VltDir 7 | VltProperties = VltPath + "/properties.xml" 8 | ) 9 | -------------------------------------------------------------------------------- /pkg/pkg/constants.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const ( 4 | InstallSuccess = "" 5 | InstallSuccessWithErrors = " 17 | 18 | 22 | 23 | 26 | 27 | 28 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /pkg/pkg/vault/META-INF/vault/definition/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /pkg/pkg/vault/META-INF/vault/definition/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/pkg/pkg/vault/META-INF/vault/definition/thumbnail.png -------------------------------------------------------------------------------- /pkg/pkg/vault/META-INF/vault/filter.xml: -------------------------------------------------------------------------------- 1 | 2 | [[if .FilterRootExcludes]] 3 | [[range .FilterRootExcludes]] 4 | [[end]] 5 | [[else]][[range .FilterRoots]] 6 | [[end]][[end]] 7 | 8 | -------------------------------------------------------------------------------- /pkg/pkg/vault/META-INF/vault/nodetypes.cnd: -------------------------------------------------------------------------------- 1 | <'rep'='internal'> 2 | 3 | [rep:RepoAccessControllable] 4 | mixin 5 | + rep:repoPolicy (rep:Policy) protected ignore 6 | -------------------------------------------------------------------------------- /pkg/pkg/vault/META-INF/vault/properties.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [[.Group]] 5 | [[.Name]][[if (ne .Version "")]] 6 | [[.Version]][[end]] 7 | AEM Compose 8 | merge 9 | 10 | -------------------------------------------------------------------------------- /pkg/pkg/vault/jcr_root/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 rockylinux:8 2 | ENV DISPATCHER_VERSION=4.3.6 3 | ENV DISP_ID docker 4 | 5 | # Install required OS packages 6 | RUN yum -y install httpd wget mod_ssl 7 | 8 | # Download & install dispatcher module 9 | RUN mkdir -p /tmp/dispatcher && cd /tmp/dispatcher && \ 10 | wget https://download.macromedia.com/dispatcher/download/dispatcher-apache2.4-linux-x86_64-${DISPATCHER_VERSION}.tar.gz -O dispatcher.tar.gz && \ 11 | tar xzf dispatcher.tar.gz && \ 12 | cp dispatcher-apache2.4-${DISPATCHER_VERSION}.so /usr/lib64/httpd/modules/mod_dispatcher.so && \ 13 | chmod 0755 /usr/lib64/httpd/modules/mod_dispatcher.so 14 | 15 | # Project-specific source code 16 | COPY home/src /etc/httpd 17 | 18 | # Local environment-specific variables 19 | COPY docker/src/conf.d/custom.conf /etc/httpd/conf.d/custom.conf 20 | COPY docker/src/conf.d/variables/default.vars /etc/httpd/conf.d/variables/default.vars 21 | 22 | # Allow invalidating from any host 23 | COPY docker/src/conf.dispatcher.d/cache/ams_author_invalidate_allowed.any /etc/httpd/conf.dispatcher.d/cache/ams_author_invalidate_allowed.any 24 | COPY docker/src/conf.dispatcher.d/cache/ams_publish_invalidate_allowed.any /etc/httpd/conf.dispatcher.d/cache/ams_publish_invalidate_allowed.any 25 | 26 | # Deactivate mod-ssl dependencies 27 | COPY docker/src/conf.d/proxy/mock.proxy /etc/httpd/conf.d/proxy/mock.proxy 28 | COPY docker/src/conf.d/rewrites/xforwarded_forcessl_rewrite.rules /etc/httpd/conf.d/rewrites/xforwarded_forcessl_rewrite.rules 29 | 30 | # Initialize doc roots 31 | RUN mkdir -p /var/www/cache/author /var/www/cache/publish /mnt/var/www/default && \ 32 | chown apache:apache -R /var/www /mnt/var/www 33 | 34 | # Fix for 'SSLCertificateFile: file '/etc/pki/tls/certs/localhost.crt' does not exist or is empty' 35 | RUN /usr/libexec/httpd-ssl-gencerts 36 | 37 | # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop 38 | STOPSIGNAL SIGWINCH 39 | 40 | COPY docker/httpd-foreground /bin/httpd-foreground 41 | EXPOSE 80 42 | CMD ["/bin/httpd-foreground"] 43 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dispatcher: 3 | container_name: dispatcher 4 | image: acme/aem-ams/dispatcher-publish:latest 5 | platform: linux/amd64 6 | ports: 7 | - "80:80" 8 | volumes: 9 | - ./home/docker/httpd/logs:/etc/httpd/logs 10 | - ./home/docker/httpd/cache:/var/www/cache 11 | sysctls: 12 | # Fixes: "Permission denied: AH00072: make_sock: could not bind to address [::]:80" 13 | # See: https://documentation.suse.com/smart/container/html/rootless-podman/index.html#rootless-podman-configure-port-below-1024 14 | net.ipv4.ip_unprivileged_port_start: 0 15 | extra_hosts: 16 | # Fixes: "Sleeping for 5s to wait until port 4503 on host.docker.internal is available" 17 | # See: https://stackoverflow.com/questions/79098571/podman-container-cannot-connect-to-windows-host 18 | - "${EXTRA_HOST}" 19 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/httpd-foreground: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Apache gets grumpy about PID files pre-existing 5 | rm -f /usr/local/apache2/logs/httpd.pid 6 | 7 | exec httpd -DFOREGROUND "$@" 8 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.d/custom.conf: -------------------------------------------------------------------------------- 1 | Include conf.d/variables/default.vars 2 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.d/proxy/mock.proxy: -------------------------------------------------------------------------------- 1 | # intentionally empty 2 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.d/rewrites/xforwarded_forcessl_rewrite.rules: -------------------------------------------------------------------------------- 1 | # This ruleset forces https in the end users browser 2 | #RewriteCond %{HTTP:X-Forwarded-Proto} !https 3 | #RewriteCond %{REQUEST_URI} !^/dispatcher/invalidate.cache 4 | #RewriteRule (.*) https://%{SERVER_NAME}%{REQUEST_URI} [L,R=301] 5 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.d/variables/default.vars: -------------------------------------------------------------------------------- 1 | Define DISP_LOG_LEVEL Warn 2 | Define REWRITE_LOG_LEVEL Warn 3 | Define EXPIRATION_TIME A2592000 4 | Define CRX_FILTER deny 5 | Define FORWARDED_HOST_SETTING Off 6 | Define AUTHOR_DOCROOT /var/www/cache/author 7 | Define AUTHOR_DEFAULT_HOSTNAME author.aem.local 8 | Define AUTHOR_IP host.docker.internal 9 | Define AUTHOR_PORT 4502 10 | Define PUBLISH_DOCROOT /var/www/cache/publish 11 | Define PUBLISH_DEFAULT_HOSTNAME publish.aem.local 12 | Define PUBLISH_IP host.docker.internal 13 | Define PUBLISH_PORT 4503 14 | -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.dispatcher.d/cache/ams_author_invalidate_allowed.any: -------------------------------------------------------------------------------- 1 | # This is where you'd put an entry for each publisher or author that you want to allow to invalidate the cache on the dispatcher 2 | /0 { 3 | /glob "*.*.*.*" 4 | /type "allow" 5 | } -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/docker/src/conf.dispatcher.d/cache/ams_publish_invalidate_allowed.any: -------------------------------------------------------------------------------- 1 | # This is where you'd put an entry for each publisher or author that you want to allow to invalidate the cache on the dispatcher 2 | /0 { 3 | /glob "*.*.*.*" 4 | /type "allow" 5 | } -------------------------------------------------------------------------------- /pkg/project/app_classic/dispatcher/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Use this script to test built image by running commands in container shell 4 | 5 | docker run --rm -it --entrypoint bash acme/aem-ams/dispatcher-publish:latest 6 | -------------------------------------------------------------------------------- /pkg/project/app_classic/local.env: -------------------------------------------------------------------------------- 1 | # Variables shared to both AEM Compose and Task tool 2 | 3 | AEM_AUTHOR_USER=admin 4 | AEM_AUTHOR_PASSWORD=admin 5 | AEM_AUTHOR_HTTP_URL=http://localhost:4502 6 | AEM_AUTHOR_DEBUG_ADDR=0.0.0.0:14502 7 | 8 | AEM_PUBLISH_USER=admin 9 | AEM_PUBLISH_PASSWORD=admin 10 | AEM_PUBLISH_HTTP_URL=http://localhost:4503 11 | AEM_PUBLISH_DEBUG_ADDR=0.0.0.0:14503 12 | 13 | AEM_DISPATCHER_IP=127.0.0.1 14 | AEM_DISPATCHER_HTTP_URL=http://${AEM_DISPATCHER_IP} 15 | AEM_DISPATCHER_DOMAIN=publish.aem.local 16 | AEM_DISPATCHER_DOMAINS=${AEM_DISPATCHER_DOMAIN} author.aem.local 17 | 18 | # Docker/Podman switch 19 | 20 | CONTAINER_COMMAND=podman 21 | CONTAINER_COMPOSE_COMMAND=podman compose 22 | 23 | PODMAN_COMPOSE_WARNING_LOGS=0 24 | -------------------------------------------------------------------------------- /pkg/project/app_cloud/dispatcher/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | dispatcher: 3 | container_name: dispatcher 4 | image: adobe/aem-ethos/dispatcher-publish:latest 5 | ports: 6 | - "80:80" 7 | environment: 8 | - AEM_HOST=host.docker.internal 9 | - AEM_IP=*.*.*.* 10 | - AEM_PORT=4503 11 | - DISP_LOG_LEVEL=Warn 12 | - REWRITE_LOG_LEVEL=Warn 13 | volumes: 14 | # Use project-specific dispatcher config 15 | - ./src:/mnt/dev/src:ro 16 | - ./home/sdk/lib:/usr/lib/dispatcher-sdk:ro 17 | - ./home/sdk/lib/import_sdk_config.sh:/docker_entrypoint.d/zzz-import-sdk-config.sh:ro 18 | # Enable invalidation by any client 19 | - ./home/sdk/lib/overwrite_cache_invalidation.sh:/docker_entrypoint.d/zzz-overwrite_cache_invalidation.sh:ro 20 | # Enable previewing logs and caches directly on host 21 | - ./home/sdk/logs:/var/log/apache2 22 | - ./home/sdk/cache:/mnt/var/www 23 | sysctls: 24 | # Fixes: "Permission denied: AH00072: make_sock: could not bind to address [::]:80" 25 | # See: https://documentation.suse.com/smart/container/html/rootless-podman/index.html#rootless-podman-configure-port-below-1024 26 | net.ipv4.ip_unprivileged_port_start: 0 27 | extra_hosts: 28 | # Fixes: "Sleeping for 5s to wait until port 4503 on host.docker.internal is available" 29 | # See: https://stackoverflow.com/questions/79098571/podman-container-cannot-connect-to-windows-host 30 | - "${EXTRA_HOST}" 31 | -------------------------------------------------------------------------------- /pkg/project/app_cloud/local.env: -------------------------------------------------------------------------------- 1 | # Variables shared to both AEM Compose and Task tool 2 | 3 | AEM_AUTHOR_USER=admin 4 | AEM_AUTHOR_PASSWORD=admin 5 | AEM_AUTHOR_HTTP_URL=http://localhost:4502 6 | AEM_AUTHOR_DEBUG_ADDR=0.0.0.0:14502 7 | 8 | AEM_PUBLISH_USER=admin 9 | AEM_PUBLISH_PASSWORD=admin 10 | AEM_PUBLISH_HTTP_URL=http://localhost:4503 11 | AEM_PUBLISH_DEBUG_ADDR=0.0.0.0:14503 12 | 13 | AEM_DISPATCHER_IP=127.0.0.1 14 | AEM_DISPATCHER_HTTP_URL=http://${AEM_DISPATCHER_IP} 15 | AEM_DISPATCHER_DOMAIN=publish 16 | AEM_DISPATCHER_DOMAINS=${AEM_DISPATCHER_DOMAIN} 17 | 18 | # Docker/Podman switch 19 | 20 | CONTAINER_COMMAND=podman 21 | CONTAINER_COMPOSE_COMMAND=podman compose 22 | 23 | PODMAN_COMPOSE_WARNING_LOGS=0 24 | -------------------------------------------------------------------------------- /pkg/project/common/$.mvn/wrapper/maven-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wttech/aemc/8c3e6dff6e5d4dec2e748ca77ec97ad867fc1c10/pkg/project/common/$.mvn/wrapper/maven-wrapper.jar -------------------------------------------------------------------------------- /pkg/project/common/$.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip 18 | wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar 19 | -------------------------------------------------------------------------------- /pkg/project/common/aemw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | VERSION=${AEM_CLI_VERSION:-"2.0.8"} 4 | 5 | # Define API 6 | # ========== 7 | 8 | # https://github.com/client9/shlib/blob/master/uname_os.sh 9 | detect_os() { 10 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | 12 | # fixed up for https://github.com/client9/shlib/issues/3 13 | case "$os" in 14 | msys*) os="windows" ;; 15 | mingw*) os="windows" ;; 16 | cygwin*) os="windows" ;; 17 | win*) os="windows" ;; # for windows busybox and like # https://frippery.org/busybox/ 18 | esac 19 | 20 | # other fixups here 21 | echo "$os" 22 | } 23 | 24 | # https://github.com/client9/shlib/blob/master/uname_arch.sh 25 | detect_arch() { 26 | arch=$(uname -m) 27 | case $arch in 28 | x86_64) arch="amd64" ;; 29 | x86) arch="386" ;; 30 | i686) arch="386" ;; 31 | i386) arch="386" ;; 32 | aarch64) arch="arm64" ;; 33 | armv5*) arch="armv5" ;; 34 | armv6*) arch="armv6" ;; 35 | armv7*) arch="armv7" ;; 36 | esac 37 | echo ${arch} 38 | } 39 | 40 | # https://github.com/client9/shlib/blob/master/http_download.sh 41 | download_file() { 42 | local_file=$1 43 | source_url=$2 44 | header=$3 45 | if [ -z "$header" ]; then 46 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 47 | else 48 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 49 | fi 50 | if [ "$code" != "200" ]; then 51 | echo "Error! Downloading file from URL '$source_url' received HTTP status '$code'" 52 | return 1 53 | fi 54 | return 0 55 | } 56 | 57 | download_file_once () { 58 | URL=$1 59 | FILE=$2 60 | if [ ! -f "${FILE}" ]; then 61 | mkdir -p "$(dirname "$FILE")" 62 | FILE_TMP="$2.tmp" 63 | download_file "$FILE_TMP" "$URL" 64 | mv "$FILE_TMP" "$FILE" 65 | fi 66 | } 67 | 68 | unarchive_file() { 69 | FILE=$1 70 | DIR=$2 71 | 72 | rm -fr "$DIR" 73 | mkdir -p "$DIR" 74 | if [ "${FILE##*.}" = "zip" ] ; then 75 | unzip "$FILE" -d "$DIR" 76 | else 77 | tar -xf "$FILE" -C "$DIR" 78 | fi 79 | } 80 | 81 | 82 | # Download or use installed tool 83 | # ============================== 84 | 85 | OS=$(detect_os) 86 | ARCH=$(detect_arch) 87 | 88 | AEM_DIR="aem" 89 | HOME_DIR="${AEM_DIR}/home" 90 | DOWNLOAD_DIR="${HOME_DIR}/opt" 91 | 92 | BIN_DOWNLOAD_NAME="aemc-cli" 93 | BIN_ARCHIVE_EXT="tar.gz" 94 | if [ "$OS" = "windows" ] ; then 95 | BIN_ARCHIVE_EXT="zip" 96 | fi 97 | BIN_DOWNLOAD_URL="https://github.com/wttech/aemc/releases/download/v${VERSION}/${BIN_DOWNLOAD_NAME}_${OS}_${ARCH}.${BIN_ARCHIVE_EXT}" 98 | BIN_ROOT="${DOWNLOAD_DIR}/${BIN_DOWNLOAD_NAME}/${VERSION}" 99 | BIN_ARCHIVE_FILE="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}.${BIN_ARCHIVE_EXT}" 100 | BIN_ARCHIVE_DIR="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}" 101 | BIN_NAME="aem" 102 | BIN_EXEC_FILE="${BIN_ARCHIVE_DIR}/${BIN_NAME}" 103 | 104 | if [ "${VERSION}" != "installed" ] ; then 105 | if [ ! -f "${BIN_EXEC_FILE}" ]; then 106 | mkdir -p "${BIN_ARCHIVE_DIR}" 107 | download_file_once "${BIN_DOWNLOAD_URL}" "${BIN_ARCHIVE_FILE}" 108 | unarchive_file "${BIN_ARCHIVE_FILE}" "${BIN_ARCHIVE_DIR}" 109 | chmod +x "${BIN_EXEC_FILE}" 110 | fi 111 | aem() { 112 | "./${BIN_EXEC_FILE}" "$@" 113 | } 114 | fi 115 | 116 | # Prevent OS or shell-specific glitches 117 | # ===================================== 118 | 119 | # https://stackoverflow.com/questions/7250130/how-to-stop-mingw-and-msys-from-mangling-path-names-given-at-the-command-line 120 | export MSYS_NO_PATHCONV=1 121 | export MSYS2_ARG_CONV_EXCL="*" 122 | export MSYS2_ENV_CONV_EXCL="*" 123 | 124 | # Execute AEM Compose CLI 125 | # ======================= 126 | 127 | aem "$@" 128 | -------------------------------------------------------------------------------- /pkg/project/common/taskw: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | VERSION=${TASK_VERSION:-"3.40.0"} 4 | 5 | # Define API 6 | # ========== 7 | 8 | # https://github.com/client9/shlib/blob/master/uname_os.sh 9 | detect_os() { 10 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | 12 | # fixed up for https://git hub.com/client9/shlib/issues/3 13 | case "$os" in 14 | msys*) os="windows" ;; 15 | mingw*) os="windows" ;; 16 | cygwin*) os="windows" ;; 17 | win*) os="windows" ;; # for windows busybox and like # https://frippery.org/busybox/ 18 | esac 19 | 20 | # other fixups here 21 | echo "$os" 22 | } 23 | 24 | # https://github.com/client9/shlib/blob/master/uname_arch.sh 25 | detect_arch() { 26 | arch=$(uname -m) 27 | case $arch in 28 | x86_64) arch="amd64" ;; 29 | x86) arch="386" ;; 30 | i686) arch="386" ;; 31 | i386) arch="386" ;; 32 | aarch64) arch="arm64" ;; 33 | armv5*) arch="armv5" ;; 34 | armv6*) arch="armv6" ;; 35 | armv7*) arch="armv7" ;; 36 | esac 37 | echo ${arch} 38 | } 39 | 40 | # https://github.com/client9/shlib/blob/master/http_download.sh 41 | download_file() { 42 | local_file=$1 43 | source_url=$2 44 | header=$3 45 | if [ -z "$header" ]; then 46 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 47 | else 48 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 49 | fi 50 | if [ "$code" != "200" ]; then 51 | echo "Error! Downloading file from URL '$source_url' received HTTP status '$code'" 52 | return 1 53 | fi 54 | return 0 55 | } 56 | 57 | download_file_once () { 58 | URL=$1 59 | FILE=$2 60 | if [ ! -f "${FILE}" ]; then 61 | mkdir -p "$(dirname "$FILE")" 62 | FILE_TMP="$2.tmp" 63 | download_file "$FILE_TMP" "$URL" 64 | mv "$FILE_TMP" "$FILE" 65 | fi 66 | } 67 | 68 | unarchive_file() { 69 | FILE=$1 70 | DIR=$2 71 | 72 | rm -fr "$DIR" 73 | mkdir -p "$DIR" 74 | if [ "${FILE##*.}" = "zip" ] ; then 75 | unzip "$FILE" -d "$DIR" 76 | else 77 | tar -xf "$FILE" -C "$DIR" 78 | fi 79 | } 80 | 81 | 82 | # Download tool 83 | # ============= 84 | 85 | OS=$(detect_os) 86 | ARCH=$(detect_arch) 87 | 88 | AEM_DIR="aem" 89 | HOME_DIR="${AEM_DIR}/home" 90 | DOWNLOAD_DIR="${HOME_DIR}/opt" 91 | 92 | BIN_DOWNLOAD_NAME="task" 93 | BIN_ARCHIVE_EXT="tar.gz" 94 | if [ "$OS" = "windows" ] ; then 95 | BIN_ARCHIVE_EXT="zip" 96 | fi 97 | BIN_DOWNLOAD_URL="https://github.com/go-task/task/releases/download/v${VERSION}/${BIN_DOWNLOAD_NAME}_${OS}_${ARCH}.${BIN_ARCHIVE_EXT}" 98 | BIN_ROOT="${DOWNLOAD_DIR}/${BIN_DOWNLOAD_NAME}/${VERSION}" 99 | BIN_ARCHIVE_FILE="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}.${BIN_ARCHIVE_EXT}" 100 | BIN_ARCHIVE_DIR="${BIN_ROOT}/${BIN_DOWNLOAD_NAME}" 101 | BIN_NAME="task" 102 | BIN_EXEC_FILE="${BIN_ARCHIVE_DIR}/${BIN_NAME}" 103 | 104 | if [ ! -f "${BIN_EXEC_FILE}" ]; then 105 | mkdir -p "${BIN_ARCHIVE_DIR}" 106 | download_file_once "${BIN_DOWNLOAD_URL}" "${BIN_ARCHIVE_FILE}" 107 | unarchive_file "${BIN_ARCHIVE_FILE}" "${BIN_ARCHIVE_DIR}" 108 | chmod +x "${BIN_EXEC_FILE}" 109 | fi 110 | 111 | # Prevent OS or shell-specific glitches 112 | # ===================================== 113 | 114 | # https://stackoverflow.com/questions/7250130/how-to-stop-mingw-and-msys-from-mangling-path-names-given-at-the-command-line 115 | export MSYS_NO_PATHCONV=1 116 | export MSYS2_ARG_CONV_EXCL="*" 117 | export MSYS2_ENV_CONV_EXCL="*" 118 | 119 | # Execute Task Tool 120 | # ================= 121 | 122 | # https://taskfile.dev/api/#env 123 | export TASK_COLOR_GREEN=35 124 | 125 | # https://taskfile.dev/experiments/env-precedence 126 | export TASK_X_ENV_PRECEDENCE=1 127 | 128 | "./${BIN_EXEC_FILE}" "$@" 129 | -------------------------------------------------------------------------------- /pkg/project/constants.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "fmt" 5 | "github.com/samber/lo" 6 | ) 7 | 8 | type Kind string 9 | 10 | const ( 11 | KindAuto = "auto" 12 | KindInstance = "instance" 13 | KindAppClassic = "app_classic" 14 | KindAppCloud = "app_cloud" 15 | KindUnknown = "unknown" 16 | 17 | KindPropName = "aemVersion" 18 | KindPropCloudValue = "cloud" 19 | KindPropClassicPrefix = "6." 20 | 21 | GitIgnoreFile = ".gitignore" 22 | PropFile = "archetype.properties" 23 | PackagePropName = "package" 24 | ) 25 | 26 | func Kinds() []Kind { 27 | return []Kind{KindInstance, KindAppCloud, KindAppClassic} 28 | } 29 | 30 | func KindStrings() []string { 31 | return lo.Map(Kinds(), func(k Kind, _ int) string { return string(k) }) 32 | } 33 | 34 | func KindOf(name string) (Kind, error) { 35 | if name == KindAuto { 36 | return KindAuto, nil 37 | } else if name == KindInstance { 38 | return KindInstance, nil 39 | } else if name == KindAppCloud { 40 | return KindAppCloud, nil 41 | } else if name == KindAppClassic { 42 | return KindAppClassic, nil 43 | } 44 | return "", fmt.Errorf("project kind '%s' is not supported", name) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/project/files.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed common 8 | var CommonFiles embed.FS 9 | 10 | //go:embed instance 11 | var InstanceFiles embed.FS 12 | 13 | //go:embed app_classic 14 | var AppClassicFiles embed.FS 15 | 16 | //go:embed app_cloud 17 | var AppCloudFiles embed.FS 18 | -------------------------------------------------------------------------------- /pkg/project/instance/Taskfile.yml: -------------------------------------------------------------------------------- 1 | # Task tool documentation: 2 | # 1) Basics: https://taskfile.dev/usage 3 | # 2) Naming conventions: https://taskfile.dev/styleguide 4 | 5 | version: '3' 6 | 7 | env: 8 | AEM_ENV: '{{.AEM_ENV | default "local"}}' 9 | AEM_INSTANCE_PROCESSING_MODE: auto 10 | AEM_OUTPUT_VALUE: NONE 11 | JAVA_HOME: 12 | sh: sh aemw vendor list -V javaHome 13 | 14 | dotenv: 15 | - '.env' # VCS-ignored, user-specific 16 | - '.env.{{.AEM_ENV}}' # VCS-ignored, env-specific 17 | - '{{.AEM_ENV}}.env' # VCS-tracked, env-specific 18 | 19 | tasks: 20 | init: 21 | desc: initialize project 22 | cmds: 23 | - sh aemw project init -V ALL 24 | 25 | setup: 26 | desc: start and provision AEM instances 27 | cmds: 28 | - task: start 29 | - task: provision 30 | - task: check 31 | 32 | resetup: 33 | desc: destroy then setup again AEM instances 34 | cmds: 35 | - task: destroy 36 | - task: setup 37 | 38 | start: 39 | desc: start AEM instances 40 | aliases: [ up ] 41 | cmd: sh aemw instance launch 42 | 43 | stop: 44 | desc: stop AEM instances 45 | aliases: [ down ] 46 | cmd: sh aemw instance stop 47 | 48 | restart: 49 | desc: restart AEM instances 50 | cmds: 51 | - task: stop 52 | - task: start 53 | 54 | destroy: 55 | desc: destroy AEM instances 56 | prompt: This will permanently delete all configured AEM instances and their data. Continue? 57 | deps: [ stop ] 58 | cmd: sh aemw instance destroy 59 | 60 | status: 61 | desc: check status of AEM instances 62 | env: 63 | AEM_OUTPUT_VALUE: ALL 64 | cmd: sh aemw instance status 65 | 66 | tail: 67 | desc: tail logs of AEM instances 68 | cmd: tail -f aem/home/var/instance/*/crx-quickstart/logs/{stdout,error}.log 69 | 70 | tail:author: 71 | desc: tail logs of AEM author instance 72 | cmd: tail -f aem/home/var/instance/author/crx-quickstart/logs/{stdout,error}.log 73 | 74 | tail:publish: 75 | desc: tail logs of AEM publish instance 76 | cmd: tail -f aem/home/var/instance/publish/crx-quickstart/logs/{stdout,error}.log 77 | 78 | provision: 79 | desc: provision AEM instances by installing packages and applying configurations 80 | aliases: [ configure ] 81 | cmds: 82 | - task: provision:repl-agent-publish 83 | - task: provision:crx 84 | 85 | provision:repl-agent-publish: 86 | desc: configure replication agent on AEM author instance 87 | internal: true 88 | cmd: | 89 | PROPS=" 90 | enabled: true 91 | transportUri: {{.AEM_PUBLISH_HTTP_URL}}/bin/receive?sling:authRequestLogin=1 92 | transportUser: {{.AEM_PUBLISH_USER}} 93 | transportPassword: {{.AEM_PUBLISH_PASSWORD}} 94 | userId: admin 95 | " 96 | echo "$PROPS" | sh aemw repl agent setup -A --location "author" --name "publish" 97 | 98 | provision:crx: 99 | desc: enable CRX/DE on AEM instances 100 | internal: true 101 | cmd: 'sh aemw osgi config save --pid "org.apache.sling.jcr.davex.impl.servlets.SlingDavExServlet" --input-string "alias: /crx/server"' 102 | 103 | check: 104 | deps: [ author:check, publish:check ] 105 | 106 | author:check: 107 | desc: check health of AEM author instance 108 | cmds: 109 | - curl -s -u "{{.AEM_AUTHOR_USER}}:{{.AEM_AUTHOR_PASSWORD}}" "{{.AEM_AUTHOR_HTTP_URL}}/libs/granite/core/content/login.html" | grep -q "QUICKSTART_HOMEPAGE" 110 | - curl -s -u "{{.AEM_AUTHOR_USER}}:{{.AEM_AUTHOR_PASSWORD}}" "{{.AEM_AUTHOR_HTTP_URL}}/etc/replication/agents.author/publish.test.html" | grep -q "Replication (TEST) of /content successful" 111 | 112 | publish:check: 113 | desc: check health of AEM publish instance 114 | cmd: curl -s -u "{{.AEM_PUBLISH_USER}}:{{.AEM_PUBLISH_PASSWORD}}" "{{.AEM_PUBLISH_HTTP_URL}}/libs/granite/core/content/login.html" | grep -q "QUICKSTART_HOMEPAGE" 115 | -------------------------------------------------------------------------------- /pkg/project/instance/local.env: -------------------------------------------------------------------------------- 1 | # Variables shared to both AEM Compose and Task tool 2 | 3 | AEM_AUTHOR_USER=admin 4 | AEM_AUTHOR_PASSWORD=admin 5 | AEM_AUTHOR_HTTP_URL=http://localhost:4502 6 | AEM_AUTHOR_DEBUG_ADDR=0.0.0.0:14502 7 | 8 | AEM_PUBLISH_USER=admin 9 | AEM_PUBLISH_PASSWORD=admin 10 | AEM_PUBLISH_HTTP_URL=http://localhost:4503 11 | AEM_PUBLISH_DEBUG_ADDR=0.0.0.0:14503 12 | -------------------------------------------------------------------------------- /pkg/repl.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/cast" 7 | "github.com/wttech/aemc/pkg/replication" 8 | "github.com/wttech/aemc/pkg/sling" 9 | "io" 10 | ) 11 | 12 | type Replication struct { 13 | instance *Instance 14 | 15 | bundleSymbolicName string 16 | } 17 | 18 | func NewReplication(instance *Instance) *Replication { 19 | cv := instance.manager.aem.config.Values() 20 | 21 | return &Replication{ 22 | instance: instance, 23 | 24 | bundleSymbolicName: cv.GetString("instance.replication.bundle_symbolic_name"), 25 | } 26 | } 27 | 28 | func (r Replication) Agent(location, name string) ReplAgent { 29 | return r.instance.Repo().ReplAgent(location, name) 30 | } 31 | 32 | func (r Replication) Bundle() OSGiBundle { 33 | return r.instance.OSGI().BundleManager().New(r.bundleSymbolicName) 34 | } 35 | 36 | func (r Replication) Activate(path string) error { 37 | log.Infof("%s > activating path '%s'", r.instance.IDColor(), path) 38 | if err := r.replicate("activate", path); err != nil { 39 | return err 40 | } 41 | log.Infof("%s > activated path '%s'", r.instance.IDColor(), path) 42 | return nil 43 | } 44 | 45 | func (r Replication) Deactivate(path string) error { 46 | log.Infof("%s > deactivating path '%s'", r.instance.IDColor(), path) 47 | if err := r.replicate("deactivate", path); err != nil { 48 | return err 49 | } 50 | log.Infof("%s > deactivated path '%s'", r.instance.IDColor(), path) 51 | return nil 52 | } 53 | 54 | func (r Replication) replicate(cmd string, path string) error { 55 | response, err := r.instance.http.Request(). 56 | SetFormData(map[string]string{ 57 | "cmd": cmd, 58 | "path": path, 59 | }). 60 | Post(replication.ReplicateJsonPath) 61 | if err != nil { 62 | return fmt.Errorf("%s > cannot do replication command '%s' for path '%s': %w", r.instance.IDColor(), cmd, path, err) 63 | } else if response.IsError() { 64 | return fmt.Errorf("%s > cannot do replication command '%s' for path '%s': %s", r.instance.IDColor(), cmd, path, response.Status()) 65 | } 66 | htmlBytes, err := io.ReadAll(response.RawBody()) 67 | if err != nil { 68 | return fmt.Errorf("%s > cannot read replication command '%s' response for path '%s': %w", r.instance.IDColor(), cmd, path, err) 69 | } 70 | htmlData, err := sling.HtmlData(string(htmlBytes)) 71 | if err != nil { 72 | return fmt.Errorf("%s > cannot parse replication command '%s' response for path '%s': %w", r.instance.IDColor(), cmd, path, err) 73 | } 74 | if htmlData.IsError() { 75 | return fmt.Errorf("%s > cannot do replication command '%s' for path '%s': %s", r.instance.IDColor(), cmd, path, htmlData.Message) 76 | } 77 | return nil 78 | } 79 | 80 | func (r Replication) ActivateTree(opts replication.ActivateTreeOpts) error { 81 | log.Infof("%s > activating tree at path '%s'", r.instance.IDColor(), opts.StartPath) 82 | 83 | cmd := "activate" 84 | if opts.DryRun { 85 | cmd = "dryrun" 86 | } 87 | response, err := r.instance.http.Request(). 88 | SetFormData(map[string]string{ 89 | "cmd": cmd, 90 | "path": opts.StartPath, 91 | "onlymodified": cast.ToString(opts.OnlyModified), 92 | "reactivate": cast.ToString(opts.OnlyActivated), 93 | "ignoredeactivated": cast.ToString(opts.IgnoreDeactivated), 94 | "__charset__": "UTF-8", 95 | }). 96 | Post(replication.ActivateTreePath) 97 | if err != nil { 98 | return fmt.Errorf("%s > cannot activate tree at path '%s': %w", r.instance.IDColor(), opts.StartPath, err) 99 | } else if response.IsError() { 100 | return fmt.Errorf("%s > cannot activate tree at path: %s: %s", r.instance.IDColor(), opts.StartPath, response.Status()) 101 | } 102 | log.Infof("%s > activated tree at path '%s'", r.instance.IDColor(), opts.StartPath) 103 | return nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/repl_agent.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/wttech/aemc/pkg/common" 8 | ) 9 | 10 | type ReplAgent struct { 11 | page RepoNode 12 | } 13 | 14 | func NewReplAgent(repo Repo, location string, name string) ReplAgent { 15 | page := NewNode(repo, fmt.Sprintf("/etc/replication/agents.%s/%s", location, name)) 16 | return ReplAgent{page: page} 17 | } 18 | 19 | func (ra ReplAgent) Name() string { 20 | return ra.page.Name() 21 | } 22 | 23 | func (ra ReplAgent) Instance() Instance { 24 | return *ra.page.repo.instance 25 | } 26 | 27 | func (ra ReplAgent) Setup(props map[string]any) (bool, error) { 28 | props["provisioner"] = common.AppId // enforce at least one-time provisioning due to 'transportPassword' property 29 | 30 | changed := false 31 | pageState, err := ra.page.State() 32 | if err != nil { 33 | return false, fmt.Errorf("%s > cannot read replication agent '%s': %w", ra.Instance().IDColor(), ra.page.Path(), err) 34 | } 35 | if !pageState.Exists { 36 | err = ra.page.Save(map[string]any{ 37 | "jcr:primaryType": "cq:Page", 38 | }) 39 | if err != nil { 40 | return false, fmt.Errorf("%s > cannot setup replication agent '%s': %w", ra.Instance().IDColor(), ra.page.Path(), err) 41 | } 42 | changed = true 43 | } 44 | pageContent := ra.page.Content() 45 | pageContentState, err := pageContent.State() 46 | if err != nil { 47 | return changed, fmt.Errorf("%s > cannot read replication agent '%s' exist: %w", ra.Instance().IDColor(), pageContent.Path(), err) 48 | } 49 | if !pageContentState.Exists { 50 | defaultProps := map[string]any{ 51 | "jcr:primaryType": "nt:unstructured", 52 | "jcr:title": strings.ToTitle(ra.Name()), 53 | "sling:resourceType": "cq/replication/components/agent", 54 | "cq:template": "/libs/cq/replication/templates/agent", 55 | } 56 | for k, v := range defaultProps { 57 | if _, exists := props[k]; !exists { 58 | props[k] = v 59 | } 60 | } 61 | err = pageContent.Save(props) 62 | if err != nil { 63 | return changed, fmt.Errorf("%s > cannot create replication agent '%s': %w", ra.Instance().IDColor(), pageContent.Path(), err) 64 | } 65 | changed = true 66 | } else { 67 | changed, err = pageContent.SaveWithChanged(props) // TODO react when transportPassword changes externally, now is ignored 68 | if err != nil { 69 | return changed, fmt.Errorf("%s > cannot update replication agent '%s': %w", ra.Instance().IDColor(), pageContent.Path(), err) 70 | } 71 | } 72 | 73 | return changed, nil 74 | } 75 | 76 | func (ra ReplAgent) Delete() (bool, error) { 77 | pageState, err := ra.page.State() 78 | if err != nil { 79 | return false, fmt.Errorf("%s > cannot read replication agent '%s': %w", ra.Instance().IDColor(), ra.page.Path(), err) 80 | } 81 | if !pageState.Exists { 82 | return false, nil 83 | } 84 | err = ra.page.Delete() 85 | if err != nil { 86 | return false, fmt.Errorf("%s > cannot delete replication agent '%s': %w", ra.Instance().IDColor(), ra.page.Path(), err) 87 | } 88 | return true, nil 89 | } 90 | 91 | func (ra ReplAgent) MarshalJSON() ([]byte, error) { 92 | return ra.page.Content().MarshalJSON() 93 | } 94 | 95 | func (ra ReplAgent) MarshalYAML() (interface{}, error) { 96 | return ra.page.Content().MarshalYAML() 97 | } 98 | 99 | func (ra ReplAgent) MarshalText() string { 100 | return ra.page.Content().MarshalText() 101 | } 102 | -------------------------------------------------------------------------------- /pkg/replication/const.go: -------------------------------------------------------------------------------- 1 | package replication 2 | 3 | const ( 4 | ReplicateJsonPath = "/bin/replicate.json" 5 | ActivateTreePath = "/libs/replication/treeactivation.html" 6 | ) 7 | 8 | type ActivateTreeOpts struct { 9 | StartPath string 10 | DryRun bool 11 | OnlyModified bool 12 | OnlyActivated bool 13 | IgnoreDeactivated bool 14 | } 15 | -------------------------------------------------------------------------------- /pkg/repo/api.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "fmt" 4 | 5 | type RepoResult struct { 6 | Title string `json:"title"` 7 | Path string `json:"path"` 8 | StatusCode int `json:"status.code"` 9 | StatusMessage string `json:"status.message"` 10 | Error RepoError `json:"error"` 11 | } 12 | 13 | type RepoError struct { 14 | Class string `json:"class"` 15 | Message string `json:"message"` 16 | } 17 | 18 | func (rr RepoResult) IsSuccess() bool { 19 | return len(rr.Error.Class) == 0 && len(rr.Error.Message) == 0 20 | } 21 | 22 | func (rr RepoResult) IsError() bool { 23 | return !rr.IsSuccess() 24 | } 25 | 26 | func (rr RepoResult) ErrorMessage() string { 27 | return fmt.Sprintf("%s [%d]; %s", rr.Title, rr.StatusCode, rr.StatusMessage) 28 | } 29 | -------------------------------------------------------------------------------- /pkg/repo_int_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package pkg_test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRepoSaveThenRead(t *testing.T) { 13 | t.Parallel() 14 | a := assert.New(t) 15 | 16 | aem := pkg.DefaultAEM() 17 | instance := aem.InstanceManager().NewLocalAuthor() 18 | err := instance.Repo().Save("/var/repo_int_test_saveThenRead", map[string]any{ 19 | "integer": 123, 20 | "float": 12.5, 21 | "bool": true, 22 | "strings": []string{"a", "b", "c"}, 23 | "string": "hello world", 24 | }) 25 | a.Nil(err) 26 | props, err := instance.Repo().Read("/var/repo_int_test_saveThenRead") 27 | a.Nil(err) 28 | a.Equal(true, props["bool"]) 29 | a.Equal("12.5", props["float"]) 30 | a.Equal(123.0, props["integer"]) 31 | a.Equal("hello world", props["string"]) 32 | a.Equal([]interface{}{"a", "b", "c"}, props["strings"]) 33 | } 34 | 35 | func TestRepoSaveThenRemoveProp(t *testing.T) { 36 | t.Parallel() 37 | a := assert.New(t) 38 | 39 | aem := pkg.DefaultAEM() 40 | instance := aem.InstanceManager().NewLocalAuthor() 41 | err := instance.Repo().Save("/var/repo_int_test_removeProp", map[string]any{ 42 | "first": "1", 43 | "second": "2", 44 | }) 45 | a.Nil(err) 46 | props, err := instance.Repo().Read("/var/repo_int_test_removeProp") 47 | _, ok := props["second"] 48 | a.True(ok) 49 | err = instance.Repo().Save("/var/repo_int_test_removeProp", map[string]any{ 50 | "first": "1", 51 | "second": nil, 52 | }) 53 | props, err = instance.Repo().Read("/var/repo_int_test_removeProp") 54 | a.Nil(err) 55 | a.Equal("1", props["first"]) 56 | _, ok = props["second"] 57 | a.False(ok) 58 | } 59 | 60 | func TestRepoReadChildren(t *testing.T) { 61 | t.Parallel() 62 | 63 | a := assert.New(t) 64 | aem := pkg.DefaultAEM() 65 | 66 | instance := aem.InstanceManager().NewLocalAuthor() 67 | children, err := instance.Repo().Node("/content").Children() 68 | a.Nil(err) 69 | a.NotEmpty(children) 70 | } 71 | 72 | func TestRepoReadParents(t *testing.T) { 73 | t.Parallel() 74 | 75 | a := assert.New(t) 76 | aem := pkg.DefaultAEM() 77 | 78 | instance := aem.InstanceManager().NewLocalAuthor() 79 | parents := instance.Repo().Node("/content/dam/projects").Parents() 80 | a.Len(parents, 2) 81 | } 82 | 83 | func TestRepoTraverse(t *testing.T) { 84 | t.Parallel() 85 | 86 | a := assert.New(t) 87 | aem := pkg.DefaultAEM() 88 | 89 | instance := aem.InstanceManager().NewLocalAuthor() 90 | it := instance.Repo().Node("/etc/dam").Traversor() 91 | 92 | traversed := 0 93 | for { 94 | _, ok, err := it.Next() 95 | if !ok { 96 | break 97 | } 98 | a.Nil(err) 99 | traversed++ 100 | } 101 | a.GreaterOrEqual(traversed, 20) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/sling.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // Sling Facade for communicating with Sling framework. 4 | type Sling struct { 5 | jmx *JMX 6 | installer *SlingInstaller 7 | } 8 | 9 | func NewSling(instance *Instance) *Sling { 10 | return &Sling{NewJMX(instance), NewSlingInstaller(instance)} 11 | } 12 | 13 | func (s *Sling) JMX() *JMX { 14 | return s.jmx 15 | } 16 | 17 | func (s *Sling) Installer() *SlingInstaller { 18 | return s.installer 19 | } 20 | -------------------------------------------------------------------------------- /pkg/sling/html_response.go: -------------------------------------------------------------------------------- 1 | package sling 2 | 3 | import ( 4 | "github.com/PuerkitoBio/goquery" 5 | "github.com/spf13/cast" 6 | "strings" 7 | ) 8 | 9 | type HTMLData struct { 10 | Status int 11 | Message string 12 | Location string 13 | ParentLocation string 14 | Path string 15 | Referer string 16 | ChangeLog string 17 | } 18 | 19 | func HtmlData(html string) (data HTMLData, err error) { 20 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) 21 | if err != nil { 22 | return data, err 23 | } 24 | 25 | data.Status = cast.ToInt(htmlElementText(doc, "#Status", "0")) 26 | data.Message = htmlElementText(doc, "#Message", "Unknown error") 27 | data.Location = htmlElementHref(doc, "#Location", "") 28 | data.ParentLocation = htmlElementHref(doc, "#ParentLocation", "") 29 | data.Path = htmlElementText(doc, "#Path", "") 30 | data.Referer = htmlElementHref(doc, "#Referer", "") 31 | data.ChangeLog = htmlElementText(doc, "#ChangeLog", "") 32 | 33 | return data, nil 34 | } 35 | 36 | func (d HTMLData) IsError() bool { 37 | return d.Status <= 0 || d.Status > 399 38 | } 39 | 40 | func htmlElementText(doc *goquery.Document, selector string, defaultValue string) string { 41 | selection := doc.Find(selector) 42 | if len(selection.Nodes) > 0 { 43 | return selection.Text() 44 | } 45 | return defaultValue 46 | } 47 | 48 | func htmlElementHref(doc *goquery.Document, selector string, defaultValue string) string { 49 | selection := doc.Find(selector) 50 | if len(selection.Nodes) > 0 { 51 | href, _ := selection.Attr("href") 52 | return href 53 | } 54 | return defaultValue 55 | } 56 | -------------------------------------------------------------------------------- /pkg/sling_installer.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "github.com/spf13/cast" 5 | ) 6 | 7 | type SlingInstaller struct { 8 | instance *Instance 9 | } 10 | 11 | const ( 12 | SlingInstallerJMXBeanName = "org/apache/sling/installer/Installer/Sling%20OSGi%20Installer" 13 | SlingInstallerPauseRoot = "/system/sling/installer/jcr/pauseInstallation" 14 | ) 15 | 16 | func NewSlingInstaller(instance *Instance) *SlingInstaller { 17 | return &SlingInstaller{instance} 18 | } 19 | 20 | func (i SlingInstaller) State() (*SlingInstallerJMXBean, error) { 21 | bean := &SlingInstallerJMXBean{} 22 | if err := i.instance.sling.jmx.ReadBean(SlingInstallerJMXBeanName, bean); err != nil { 23 | return nil, err 24 | } 25 | return bean, nil 26 | } 27 | 28 | func (i SlingInstaller) CountPauses() (int, error) { 29 | pauseNodes, err := i.instance.Repo().Node(SlingInstallerPauseRoot).Children() 30 | if err != nil { 31 | return -1, err 32 | } 33 | return len(pauseNodes), nil 34 | } 35 | 36 | type SlingInstallerJMXBean struct { 37 | Active bool `json:"Active"` 38 | SuspendedSince int `json:"SuspendedSince"` 39 | ActiveResourceCount any `json:"ActiveResourceCount"` // AEM type bug: sometimes 'int' or 'string' 40 | InstalledResourceCount any `json:"InstalledResourceCount"` // AEM type bug: sometimes 'int' or 'string' 41 | } 42 | 43 | func (b SlingInstallerJMXBean) IsActive() bool { 44 | return b.Active /* || b.ActiveResources() > 0 */ // sometimes ActiveResourceCount > 0 but Active == false 45 | } 46 | 47 | func (b SlingInstallerJMXBean) ActiveResources() int { 48 | return cast.ToInt(b.ActiveResourceCount) 49 | } 50 | 51 | func (b SlingInstallerJMXBean) InstalledResources() int { 52 | return cast.ToInt(b.InstalledResourceCount) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/sling_jmx.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "github.com/wttech/aemc/pkg/common/fmtx" 6 | "strings" 7 | ) 8 | 9 | type JMX struct { 10 | instance *Instance 11 | } 12 | 13 | const ( 14 | JMXBeanPath = "/system/sling/monitoring/mbeans" 15 | ) 16 | 17 | func NewJMX(instance *Instance) *JMX { 18 | return &JMX{instance: instance} 19 | } 20 | 21 | func (j JMX) ReadBean(name string, out interface{}) error { 22 | name = strings.ReplaceAll(name, " ", "%20") 23 | response, err := j.instance.http.Request().Get(JMXBeanPath + "/" + name + ".json") 24 | if err != nil { 25 | return fmt.Errorf("%s > cannot read Sling JMX Bean '%s': %w", j.instance.IDColor(), name, err) 26 | } else if response.IsError() { 27 | return fmt.Errorf("%s > cannot read Sling JMX Bean '%s': %s", j.instance.IDColor(), name, response.Status()) 28 | } 29 | if err := fmtx.UnmarshalJSON(response.RawBody(), &out); err != nil { 30 | return fmt.Errorf("%s > cannot read Sling JMX Bean '%s'; cannot parse response: %w", j.instance.IDColor(), name, err) 31 | } 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /pkg/user/status.go: -------------------------------------------------------------------------------- 1 | package user 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/wttech/aemc/pkg/common/fmtx" 8 | ) 9 | 10 | const ( 11 | // Repository user types (as stored in JCR) 12 | RepUserType = "rep:User" 13 | RepSystemUserType = "rep:SystemUser" 14 | 15 | // Maped user types 16 | UserType = "user" 17 | SystemUserType = "systemUser" 18 | ) 19 | 20 | type Status struct { 21 | Type string `json:"jcr:primaryType"` 22 | AuthorizableID string `json:"rep:authorizableId"` 23 | } 24 | 25 | func UnmarshalStatus(readCloser io.ReadCloser) (*Status, error) { 26 | var status = Status{ 27 | Type: "rep:User", 28 | AuthorizableID: "", 29 | } 30 | if err := fmtx.UnmarshalJSON(readCloser, &status); err != nil { 31 | return nil, err 32 | } 33 | 34 | switch status.Type { 35 | case RepUserType: 36 | status.Type = UserType 37 | case RepSystemUserType: 38 | status.Type = SystemUserType 39 | default: 40 | return nil, fmt.Errorf("unknown user type: %s", status.Type) 41 | } 42 | 43 | return &status, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/user_manager.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wttech/aemc/pkg/user" 7 | ) 8 | 9 | type UserManager struct { 10 | instance *Instance 11 | } 12 | 13 | func NewUserManager(instance *Instance) *UserManager { 14 | return &UserManager{instance: instance} 15 | } 16 | 17 | const ( 18 | UsersPath = "/home/users" 19 | ) 20 | 21 | func (um *UserManager) Keystore() *KeystoreManager { 22 | return &KeystoreManager{instance: um.instance} 23 | } 24 | 25 | func (um *UserManager) ReadState(scope string, id string) (*user.Status, error) { 26 | userPath := composeUserPath(scope, id) 27 | 28 | response, err := um.instance.http.Request().Get(userPath + ".json") 29 | 30 | if err != nil { 31 | return nil, fmt.Errorf("%s > cannot read user: %w", um.instance.IDColor(), err) 32 | } 33 | if response.IsError() { 34 | return nil, fmt.Errorf("%s > cannot read user: %s", um.instance.IDColor(), response.Status()) 35 | } 36 | 37 | result, err := user.UnmarshalStatus(response.RawBody()) 38 | if err != nil { 39 | return nil, fmt.Errorf("%s > cannot parse user status response: %w", um.instance.IDColor(), err) 40 | } 41 | 42 | return result, nil 43 | } 44 | 45 | func (um *UserManager) SetPassword(scope string, id string, password string) (bool, error) { 46 | userStatus, err := um.ReadState(scope, id) 47 | if err != nil { 48 | return false, err 49 | } 50 | 51 | userPath := composeUserPath(scope, id) 52 | passwordCheckResponse, err := um.instance.http.Request(). 53 | SetBasicAuth(userStatus.AuthorizableID, password). 54 | Get(userPath + ".json") 55 | 56 | if err != nil { 57 | return false, fmt.Errorf("%s > cannot check user password: %w", um.instance.IDColor(), err) 58 | } 59 | if !passwordCheckResponse.IsError() { 60 | return false, nil 61 | } 62 | 63 | props := map[string]any{ 64 | "rep:password": password, 65 | } 66 | 67 | postResponse, err := um.instance.http.RequestFormData(props).Post(userPath) 68 | if err != nil { 69 | return false, fmt.Errorf("%s > cannot set user password: %w", um.instance.IDColor(), err) 70 | } 71 | if postResponse.IsError() { 72 | return false, fmt.Errorf("%s > cannot set user password: %s", um.instance.IDColor(), postResponse.Status()) 73 | } 74 | 75 | return true, nil 76 | } 77 | 78 | func composeUserPath(scope string, id string) string { 79 | if scope == "" { 80 | return UsersPath + "/" + id 81 | } 82 | return UsersPath + "/" + scope + "/" + id 83 | } 84 | -------------------------------------------------------------------------------- /pkg/vendor_manager.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | // VendorManager manages third-party tools like JDK, OakRun, and Vault CLI 4 | type VendorManager struct { 5 | aem *AEM 6 | 7 | javaManager *JavaManager 8 | oakRun *OakRun 9 | quickstart *Quickstart 10 | sdk *SDK 11 | } 12 | 13 | func NewVendorManager(aem *AEM) *VendorManager { 14 | result := &VendorManager{aem: aem} 15 | result.javaManager = NewJavaManager(result) 16 | result.sdk = NewSDK(result) 17 | result.quickstart = NewQuickstart(result) 18 | result.oakRun = NewOakRun(result) 19 | return result 20 | } 21 | 22 | func (vm *VendorManager) InstanceJar() (string, error) { 23 | sdk, err := vm.quickstart.IsDistSDK() 24 | if err != nil { 25 | return "", err 26 | } 27 | if sdk { 28 | return vm.sdk.QuickstartJar() 29 | } 30 | return vm.quickstart.FindDistFile() 31 | } 32 | 33 | func (vm *VendorManager) PrepareWithChanged(requireLibs bool) (bool, error) { 34 | changed := false 35 | 36 | javaChanged, err := vm.javaManager.PrepareWithChanged() 37 | changed = changed || javaChanged 38 | if err != nil { 39 | return changed, err 40 | } 41 | 42 | if requireLibs || vm.aem.baseOpts.HasLibs() { 43 | sdk, err := vm.quickstart.IsDistSDK() 44 | if err != nil { 45 | return false, err 46 | } 47 | if sdk { 48 | sdkChanged, err := vm.sdk.PrepareWithChanged() 49 | changed = changed || sdkChanged 50 | if err != nil { 51 | return changed, err 52 | } 53 | } 54 | } 55 | 56 | oakRunChanged, err := vm.oakRun.PrepareWithChanged() 57 | changed = changed || oakRunChanged 58 | if err != nil { 59 | return changed, err 60 | } 61 | 62 | return changed, nil 63 | } 64 | 65 | func (vm *VendorManager) JavaManager() *JavaManager { 66 | return vm.javaManager 67 | } 68 | 69 | func (vm *VendorManager) OakRun() *OakRun { 70 | return vm.oakRun 71 | } 72 | 73 | func (vm *VendorManager) Quickstart() *Quickstart { 74 | return vm.quickstart 75 | } 76 | 77 | func (vm *VendorManager) SDK() *SDK { 78 | return vm.sdk 79 | } 80 | -------------------------------------------------------------------------------- /pkg/workflow.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/spf13/cast" 7 | "strings" 8 | ) 9 | 10 | type WorkflowLauncher struct { 11 | manager *WorkflowManager 12 | 13 | path string 14 | } 15 | 16 | func (l WorkflowLauncher) LibNode() RepoNode { 17 | return l.manager.instance.repo.Node(l.path) 18 | } 19 | 20 | func (l WorkflowLauncher) ConfigNode() RepoNode { 21 | return l.manager.instance.repo.Node(strings.ReplaceAll(l.path, WorkflowLauncherLibRoot+"/", WorkflowLauncherConfigRoot+"/")) 22 | } 23 | 24 | func (l WorkflowLauncher) Prepare() error { 25 | configNode := l.ConfigNode() 26 | libNode := l.LibNode() 27 | configExists, err := configNode.Exists() 28 | if err != nil { 29 | return fmt.Errorf("%s > cannot read workflow launcher config '%s': %w", l.manager.instance.IDColor(), configNode.path, err) 30 | } 31 | if !configExists { 32 | if err := libNode.Copy(configNode.Path()); err != nil { 33 | return fmt.Errorf("%s > workflow launcher config node '%s' cannot be copied from lib node '%s': %s", l.manager.instance.IDColor(), configNode.path, libNode.path, err) 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | func (l WorkflowLauncher) Disable() error { 40 | node := l.ConfigNode() 41 | if err := node.Save(map[string]any{ 42 | WorkflowLauncherEnabledProp: false, 43 | WorkflowLauncherToggledProp: true, 44 | }); err != nil { 45 | return fmt.Errorf("%s > cannot disable workflow launcher '%s': %w", l.manager.instance.IDColor(), node.path, err) 46 | } 47 | log.Infof("%s > disabled workflow launcher '%s'", l.manager.instance.IDColor(), node.path) 48 | return nil 49 | } 50 | 51 | func (l WorkflowLauncher) Enable() error { 52 | node := l.ConfigNode() 53 | if err := node.Save(map[string]any{ 54 | WorkflowLauncherEnabledProp: true, 55 | WorkflowLauncherToggledProp: nil, 56 | }); err != nil { 57 | return fmt.Errorf("%s > cannot enable workflow launcher '%s': %w", l.manager.instance.IDColor(), node.path, err) 58 | } 59 | log.Infof("%s > enabled workflow launcher '%s'", l.manager.instance.IDColor(), node.path) 60 | return nil 61 | } 62 | 63 | func (l WorkflowLauncher) IsEnabled() (bool, error) { 64 | configNode := l.ConfigNode() 65 | configState, err := configNode.State() 66 | if err != nil { 67 | return false, fmt.Errorf("%s > cannot read workflow launcher config just copied '%s': %w", l.manager.instance.IDColor(), configNode.path, err) 68 | } 69 | enabledAny, enabledFound := configState.Properties[WorkflowLauncherEnabledProp] 70 | enabled := enabledFound && cast.ToBool(enabledAny) 71 | return enabled, nil 72 | } 73 | 74 | func (l WorkflowLauncher) IsToggled() (bool, error) { 75 | configNode := l.ConfigNode() 76 | configState, err := configNode.State() 77 | if err != nil { 78 | return false, fmt.Errorf("%s > cannot read config of workflow launcher '%s': %w", l.manager.instance.IDColor(), configNode.path, err) 79 | } 80 | if !configState.Exists { 81 | return false, fmt.Errorf("%s > config node of workflow launcher does not exist '%s': %w", l.manager.instance.IDColor(), configNode.path, err) 82 | } 83 | toggledAny, toggledFound := configState.Properties[WorkflowLauncherToggledProp] 84 | toggled := toggledFound && cast.ToBool(toggledAny) 85 | return toggled, nil 86 | } 87 | 88 | func (l WorkflowLauncher) String() string { 89 | return fmt.Sprintf("workflow launcher '%s'", l.path) 90 | } 91 | 92 | const ( 93 | WorkflowLauncherEnabledProp = "enabled" 94 | WorkflowLauncherToggledProp = "toggled" 95 | ) 96 | -------------------------------------------------------------------------------- /pkg/workflow_manager.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | log "github.com/sirupsen/logrus" 6 | "github.com/wttech/aemc/pkg/common/pathx" 7 | "github.com/wttech/aemc/pkg/common/stringsx" 8 | "time" 9 | ) 10 | 11 | type WorkflowManager struct { 12 | instance *Instance 13 | 14 | LauncherLibRoot string 15 | LauncherConfigRoot string 16 | LauncherToggleRetryTimeout time.Duration 17 | LauncherToggleRetryDelay time.Duration 18 | } 19 | 20 | func NewWorkflowManager(i *Instance) *WorkflowManager { 21 | cv := i.manager.aem.config.Values() 22 | 23 | return &WorkflowManager{ 24 | instance: i, 25 | 26 | LauncherLibRoot: cv.GetString("instance.workflow.launcher.lib_root"), 27 | LauncherConfigRoot: cv.GetString("instance.workflow.launcher.config_root"), 28 | LauncherToggleRetryTimeout: cv.GetDuration("instance.workflow.launcher.toggle_retry.timeout"), 29 | LauncherToggleRetryDelay: cv.GetDuration("instance.workflow.launcher.toggle_retry.delay"), 30 | } 31 | } 32 | 33 | func (w *WorkflowManager) Launcher(path string) *WorkflowLauncher { 34 | return &WorkflowLauncher{manager: w, path: path} 35 | } 36 | 37 | func (w *WorkflowManager) ToggleLaunchers(libPaths []string, action func() error) error { 38 | launchers, err := w.findLaunchers(libPaths) 39 | if err != nil { 40 | return err 41 | } 42 | defer func(launchers []WorkflowLauncher) { w.enableLaunchers(launchers) }(launchers) 43 | w.disableLaunchers(launchers) 44 | return action() 45 | } 46 | 47 | func (w *WorkflowManager) findLaunchers(paths []string) ([]WorkflowLauncher, error) { 48 | var result []WorkflowLauncher 49 | for _, libPath := range paths { 50 | dir, fileName := pathx.DirAndFileName(libPath) 51 | dirNode := w.instance.repo.Node(dir) 52 | children, err := dirNode.Children() 53 | if err != nil { 54 | return nil, err 55 | } 56 | for _, child := range children { 57 | if stringsx.Match(child.Name(), fileName) { 58 | result = append(result, *(w.Launcher(child.Path()))) 59 | } 60 | } 61 | } 62 | return result, nil 63 | } 64 | 65 | func (w *WorkflowManager) doLauncherAction(name string, callback func() error) error { 66 | started := time.Now() 67 | for { 68 | err := callback() 69 | if err == nil { 70 | return nil 71 | } 72 | if time.Now().After(started.Add(w.LauncherToggleRetryTimeout)) { 73 | return fmt.Errorf("%s > awaiting workflow launcher action '%s' timed out after %s: %w", w.instance.IDColor(), name, w.LauncherToggleRetryTimeout, err) 74 | } 75 | time.Sleep(w.LauncherToggleRetryDelay) 76 | } 77 | } 78 | 79 | func (w *WorkflowManager) disableLaunchers(launchers []WorkflowLauncher) { 80 | for _, launcher := range launchers { 81 | if err := w.doLauncherAction("disable", func() error { 82 | if err := launcher.Prepare(); err != nil { 83 | return err 84 | } 85 | enabled, err := launcher.IsEnabled() 86 | if err != nil { 87 | return err 88 | } 89 | if enabled { 90 | if err := launcher.Disable(); err != nil { 91 | return err 92 | } 93 | } 94 | return nil 95 | }); err != nil { 96 | log.Warnf("%s", err) 97 | continue 98 | } 99 | } 100 | } 101 | 102 | func (w *WorkflowManager) enableLaunchers(launchers []WorkflowLauncher) { 103 | for _, launcher := range launchers { 104 | if err := w.doLauncherAction("enable", func() error { 105 | toggled, err := launcher.IsToggled() 106 | if err != nil { 107 | return err 108 | } 109 | if toggled { 110 | if err := launcher.Enable(); err != nil { 111 | return err 112 | } 113 | } 114 | return nil 115 | }); err != nil { 116 | log.Warnf("%s", err) 117 | continue 118 | } 119 | } 120 | } 121 | 122 | const ( 123 | WorkflowLauncherLibRoot = "/libs/settings/workflow/launcher" 124 | WorkflowLauncherConfigRoot = "/conf/global/settings/workflow/launcher" 125 | ) 126 | -------------------------------------------------------------------------------- /project-install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | AEM_WRAPPER="aemw" 4 | 5 | if [ -f "$AEM_WRAPPER" ]; then 6 | echo "" 7 | echo "The project already contains AEM Compose!" 8 | exit 0 9 | fi 10 | 11 | SOURCE_URL="https://raw.githubusercontent.com/wttech/aemc/main/pkg/project/common" 12 | curl -s "${SOURCE_URL}/${AEM_WRAPPER}" -o "${AEM_WRAPPER}" 13 | 14 | echo "" 15 | echo "Downloading & Testing AEM Compose CLI" 16 | echo "" 17 | 18 | chmod +x "${AEM_WRAPPER}" 19 | sh ${AEM_WRAPPER} version 20 | 21 | echo "" 22 | echo "Success! Now scaffold the AEM Compose files in the project by running command below:" 23 | echo "" 24 | 25 | echo "sh ${AEM_WRAPPER} project scaffold" 26 | -------------------------------------------------------------------------------- /release-snapshot.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | goreleaser release --snapshot --rm-dist --skip-publish 4 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | VERSION=$1 4 | VERSION_TAG="v$VERSION" 5 | 6 | VERSION_CURRENT_TAG=$(git describe --tags --abbrev=0) 7 | VERSION_CURRENT_TAG="${VERSION_CURRENT_TAG:1}" 8 | 9 | if [ -z "$VERSION" ] 10 | then 11 | echo "Release version is not specified!" 12 | echo "Last released: ${VERSION_CURRENT_TAG}" 13 | exit 1 14 | fi 15 | 16 | GIT_STAT=$(git diff --stat) 17 | 18 | if [ "$GIT_STAT" != '' ]; then 19 | echo "Unable to release. Uncommitted changes detected!" 20 | exit 1 21 | fi 22 | 23 | echo "Releasing $VERSION_TAG" 24 | 25 | echo "Bumping version in files" 26 | 27 | README_FILE="README.MD" 28 | PROJECT_WRAPPER_SCRIPT="pkg/project/common/aemw" 29 | 30 | # 31 | if [ "$(uname)" = "Darwin" ]; then 32 | sed -i '' 's/AEM_CLI_VERSION:-"[^\"]*"/AEM_CLI_VERSION:-"'"$VERSION"'"/g' "$PROJECT_WRAPPER_SCRIPT" 33 | # shellcheck disable=SC2016 34 | sed -i '' 's/aem\@v[^\`]*\`/aem@v'"$VERSION"\`'/g' "$README_FILE" 35 | else 36 | sed -i 's/AEM_CLI_VERSION:-"[^\"]*"/AEM_CLI_VERSION:-"'"$VERSION"'"/g' "$PROJECT_WRAPPER_SCRIPT" 37 | # shellcheck disable=SC2016 38 | sed -i 's/aem\@v[^\`]*\`/aem@v'"$VERSION"\`'/g' "$README_FILE" 39 | fi 40 | 41 | echo "Pushing version bump" 42 | git commit -a -m "Release $VERSION_TAG" 43 | git push 44 | 45 | echo "Pushing release tag '$VERSION_TAG'" 46 | git tag "$VERSION_TAG" 47 | git push origin "$VERSION_TAG" 48 | -------------------------------------------------------------------------------- /revive.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/mgechev/revive#recommended-configuration 2 | ignoreGeneratedHeader = false 3 | severity = "error" 4 | confidence = 1.0 5 | errorCode = 0 6 | warningCode = 0 7 | 8 | [rule.blank-imports] 9 | [rule.context-as-argument] 10 | [rule.context-keys-type] 11 | [rule.dot-imports] 12 | [rule.error-return] 13 | [rule.error-strings] 14 | [rule.error-naming] 15 | #[rule.exported] 16 | [rule.if-return] 17 | [rule.increment-decrement] 18 | [rule.var-naming] 19 | [rule.var-declaration] 20 | #[rule.package-comments] 21 | [rule.range] 22 | [rule.receiver-naming] 23 | [rule.time-naming] 24 | [rule.unexported-return] 25 | [rule.indent-error-flow] 26 | [rule.errorf] 27 | [rule.empty-block] 28 | [rule.superfluous-else] 29 | [rule.unused-parameter] 30 | [rule.unreachable-code] 31 | [rule.redefines-builtin-id] 32 | -------------------------------------------------------------------------------- /test/filter_file_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg/common/tplx" 7 | "github.com/wttech/aemc/pkg/pkg" 8 | "testing" 9 | ) 10 | 11 | func testFilterFile(t *testing.T, filterFile string, expectedFile string, data map[string]any) { 12 | bytes, err := pkg.VaultFS.ReadFile(filterFile) 13 | if err != nil { 14 | t.Fatalf("%v %v", bytes, err) 15 | } 16 | expected, err := VaultFS.ReadFile(expectedFile) 17 | if err != nil { 18 | t.Fatalf("%v %v", bytes, err) 19 | } 20 | actual, err := tplx.RenderString(string(bytes), data) 21 | if actual != string(expected) { 22 | t.Errorf("RenderString(%s, %v) = %s; want %s", string(bytes), data, actual, expected) 23 | } 24 | } 25 | 26 | func TestFilterRoots(t *testing.T) { 27 | testFilterFile(t, "vault/META-INF/vault/filter.xml", "resources/filter_roots.xml", 28 | map[string]any{ 29 | "FilterRoots": []string{"/apps/my_site", "/content/my_site"}, 30 | }, 31 | ) 32 | } 33 | 34 | func TestFilterRootExcludes(t *testing.T) { 35 | testFilterFile(t, "vault/META-INF/vault/filter.xml", "resources/exclude_patterns.xml", 36 | map[string]any{ 37 | "FilterRoots": []string{"/apps/my_site", "/content/my_site"}, 38 | "FilterRootExcludes": []string{"/apps/my_site/cq:dialog(/.*)?", "/apps/my_site/rep:policy(/.*)?"}, 39 | }, 40 | ) 41 | } 42 | 43 | func TestFilterRootsUpdate(t *testing.T) { 44 | testFilterFile(t, "vault/META-INF/vault/filter.xml", "resources/filter_roots_update.xml", 45 | map[string]any{ 46 | "FilterRoots": []string{"/apps/my_site", "/content/my_site"}, 47 | "FilterMode": "update", 48 | }, 49 | ) 50 | } 51 | 52 | func TestFilterRootExcludesUpdate(t *testing.T) { 53 | testFilterFile(t, "vault/META-INF/vault/filter.xml", "resources/exclude_patterns_update.xml", 54 | map[string]any{ 55 | "FilterRoots": []string{"/apps/my_site", "/content/my_site"}, 56 | "FilterRootExcludes": []string{"/apps/my_site/cq:dialog(/.*)?", "/apps/my_site/rep:policy(/.*)?"}, 57 | "FilterMode": "update", 58 | }, 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /test/filter_root_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "github.com/wttech/aemc/pkg/common/pathx" 8 | "github.com/wttech/aemc/pkg/content" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | ) 13 | 14 | func TestDetermineFilterRoot(t *testing.T) { 15 | workDir := pathx.RandomDir(os.TempDir(), "filter_root") 16 | defer func() { _ = pathx.DeleteIfExists(workDir) }() 17 | if err := copyFiles("resources/repo", workDir); err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | tests := []struct { 22 | path, expected string 23 | }{ 24 | //{"/content/my_site", "/content/my_site"}, 25 | //{"/content/my_site/_cq_path", "/content/my_site/cq:path"}, 26 | //{"/content/my_site/_xmpBJ_path", "/content/my_site/xmpBJ:path"}, 27 | //{"/content/my_site/_s7sitecatalyst_path", "/content/my_site/s7sitecatalyst:path"}, 28 | //{"/content/my_site/adobe_dam%3apath", "/content/my_site/adobe_dam:path"}, 29 | //{"/content/my_site/_cq__sub_path_", "/content/my_site/cq:_sub_path_"}, 30 | //{"/content/my_site/__cq_path", "/content/my_site/_cq_path"}, 31 | //{"/content/my_site/__abc_path", "/content/my_site/_abc_path"}, 32 | //{"/content/my_site/__path", "/content/my_site/_path"}, 33 | {"/content/my_site/___path", "/content/my_site/__path"}, 34 | {"content\\my_site", "/content/my_site"}, 35 | {"content\\my_site\\_cq_path", "/content/my_site/cq:path"}, 36 | {"/content/my_app/_cq_dialog/.content.xml", "/content/my_app/cq:dialog"}, 37 | {"/content/my_app/_cq_dialog.xml", "/content/my_app/cq:dialog"}, 38 | {"/content/my_conf/workflow.xml", "/content/my_conf/workflow"}, 39 | {"/content/dam/my_site/image.png", "/content/dam/my_site/image.png"}, 40 | {"/conf/my_site/_sling_configs/com.config.ImageConfig", "/conf/my_site/sling:configs/com.config.ImageConfig"}, 41 | {"/conf/my_site/_sling_configs/com.config.ImageConfig/_jcr_content", "/conf/my_site/sling:configs/com.config.ImageConfig/jcr:content"}, 42 | {"/apps/mysite/components/helloworld/_cq_template/.content.xml", "/apps/mysite/components/helloworld/cq:template"}, 43 | {"/apps/mysite/components/helloworld/_cq_template.xml", "/apps/mysite/components/helloworld/cq:template"}, 44 | {"/apps/mysite/components/helloworld/_cq_editConfig.xml", "/apps/mysite/components/helloworld/cq:editConfig"}, 45 | {"/apps/mysite/components/helloworld/helloworld.html", "/apps/mysite/components/helloworld/helloworld.html"}, 46 | {"/conf/mysite/_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig", "/conf/mysite/sling:configs/com.mysite.pdfviewer.PdfViewerCaConfig"}, 47 | {"/conf/mysite/_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/.content.xml", "/conf/mysite/sling:configs/com.mysite.pdfviewer.PdfViewerCaConfig/jcr:content"}, 48 | {"/conf/mysite/_sling_configs/.content.xml", "/conf/mysite/sling:configs"}, 49 | {"/conf/mysite/_sling_configs", "/conf/mysite/sling:configs"}, 50 | {"/content/mysite/us/en/.content.xml", "/content/mysite/us/en/jcr:content"}, 51 | } 52 | for _, test := range tests { 53 | path := filepath.Join(workDir, content.JCRRoot, test.path) 54 | actual := pkg.DetermineFilterRoot(path) 55 | if actual != test.expected { 56 | t.Errorf("DetermineFilterRoot(%s) = %s; want %s", test.path, actual, test.expected) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /test/resources/exclude_patterns.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/resources/exclude_patterns_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/resources/filter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /test/resources/filter_roots.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/resources/filter_roots_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/MANIFEST.MF: -------------------------------------------------------------------------------- 1 | Manifest-Version: 1.0 2 | Content-Package-Id: my_packages:test-content:1.0.0-SNAPSHOT 3 | Content-Package-Type: mixed 4 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/vault/config.xml: -------------------------------------------------------------------------------- 1 | 17 | 18 | 22 | 23 | 26 | 27 | 28 | 33 | 34 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/vault/definition/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/vault/filter.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/vault/nodetypes.cnd: -------------------------------------------------------------------------------- 1 | <'rep'='internal'> 2 | 3 | [rep:RepoAccessControllable] 4 | mixin 5 | + rep:repoPolicy (rep:Policy) protected ignore 6 | -------------------------------------------------------------------------------- /test/resources/main_content/META-INF/vault/properties.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | my_packages 5 | test-content 6 | 1.0.0-SNAPSHOT 7 | AEM Compose 8 | merge 9 | 10 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/apps/mysite/components/helloworld/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/apps/mysite/components/helloworld/$_cq_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/apps/mysite/components/helloworld/$_cq_editConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/apps/mysite/components/helloworld/$_cq_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/apps/mysite/components/helloworld/helloworld.html: -------------------------------------------------------------------------------- 1 |
2 |

Hello World Component

3 |
4 |

Text property:

5 |
${properties.text}
6 |
7 |
8 |

Model message:

9 |
${model.message}
10 |
11 |
12 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/$_sling_configs/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/$_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/settings/wcm/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/settings/wcm/policies/$_rep_policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/settings/wcm/template-types/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/conf/mysite/settings/wcm/template-types/page/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/content/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/content/mysite/us/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/resources/main_content/jcr_root/var/workflow/models/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/$_cq_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 13 | 14 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/$_cq_editConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/$_cq_template.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/$_cq_template/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/apps/mysite/components/helloworld/helloworld.html: -------------------------------------------------------------------------------- 1 |
2 |

Hello World Component

3 |
4 |
Text property:
5 |
${properties.text}
6 |
7 |
8 |
Model message:
9 |
${model.message}
10 |
11 |
12 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/$_sling_configs/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/$_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/settings/wcm/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/settings/wcm/policies/$_rep_policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 12 | 16 | 20 | 21 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/settings/wcm/template-types/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/conf/mysite/settings/wcm/template-types/page/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/content/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/content/mysite/us/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/resources/new_content/jcr_root/var/workflow/models/mysite/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /test/resources/repo/jcr_root/apps/mysite/components/helloworld/$_cq_editConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /test/resources/repo/jcr_root/apps/mysite/components/helloworld/$_cq_template/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /test/resources/repo/jcr_root/apps/mysite/components/helloworld/helloworld.html: -------------------------------------------------------------------------------- 1 |
2 |

Hello World Component

3 |
4 |
Text property:
5 |
${properties.text}
6 |
7 |
8 |
Model message:
9 |
${model.message}
10 |
11 |
12 | -------------------------------------------------------------------------------- /test/resources/repo/jcr_root/conf/mysite/$_sling_configs/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /test/resources/repo/jcr_root/conf/mysite/$_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/$.content.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /test/sync_file_test.go: -------------------------------------------------------------------------------- 1 | //go:build int_test 2 | 3 | package test 4 | 5 | import ( 6 | "github.com/wttech/aemc/pkg" 7 | "github.com/wttech/aemc/pkg/common/pathx" 8 | "github.com/wttech/aemc/pkg/content" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestDetermineSyncFile(t *testing.T) { 16 | workDir := pathx.RandomDir(os.TempDir(), "sync_file") 17 | defer func() { _ = pathx.DeleteIfExists(workDir) }() 18 | if err := copyFiles("resources/repo", workDir); err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | tests := []struct { 23 | path, expected string 24 | }{ 25 | {"/content/mysite/us/en/.content.xml", "/content/mysite/us/en/.content.xml"}, 26 | {"/apps/mysite/components/helloworld/_cq_template/.content.xml", "/apps/mysite/components/helloworld/_cq_template/.content.xml"}, 27 | {"/apps/mysite/components/helloworld/_cq_template.xml", "/apps/mysite/components/helloworld/_cq_template/.content.xml"}, 28 | {"/apps/mysite/components/helloworld/_cq_editConfig.xml", "/apps/mysite/components/helloworld/_cq_editConfig.xml"}, 29 | {"/apps/mysite/components/helloworld/helloworld.html", "/apps/mysite/components/helloworld/helloworld.html"}, 30 | {"/conf/mysite/_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/.content.xml", "/conf/mysite/_sling_configs/com.mysite.pdfviewer.PdfViewerCaConfig/.content.xml"}, 31 | {"/conf/mysite/_sling_configs/.content.xml", "/conf/mysite/_sling_configs/.content.xml"}, 32 | {"/content/mysite/us/en/.content.xml", "/content/mysite/us/en/.content.xml"}, 33 | } 34 | for _, test := range tests { 35 | path := filepath.Join(workDir, content.JCRRoot, test.path) 36 | expected := filepath.Join(workDir, content.JCRRoot, test.expected) 37 | actual := pkg.DetermineSyncFile(workDir, path) 38 | if actual != expected { 39 | _, jcrPath, _ := strings.Cut(actual, content.JCRRoot) 40 | t.Errorf("DetermineSyncFile(%s) = %s; want %s", test.path, jcrPath, test.expected) 41 | } 42 | } 43 | } 44 | --------------------------------------------------------------------------------