Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,15 @@ Options:
in a single bulk operation after all channels are saved.
Allows Mirth's dependency logic to control deployment order.
More efficient than individual deployment for multiple channels.
-I, --interactive
--deploy-changed Deploy only changed channels after push
After all channels are saved, query the server for channel statuses
and deploy only those with a non-zero deployedRevisionDelta or
where codeTemplatesChanged is true.
--deploy-new Deploy channels that are not currently deployed
Use with --deploy-changed. During push, tracks which channels were
saved. After push, any saved channel not found in the dashboard
statuses (i.e. not currently deployed) will also be deployed.
-I, --interactive
Allow for console prompts for user input
--commit-message MESSAGE mirthsync commit Commit message for git operations
--git-author NAME <computed> Git author name for commits
Expand Down Expand Up @@ -396,6 +404,15 @@ $ java -jar mirthsync-<version>-standalone.jar -s https://localhost:8443/api -u
$ java -jar mirthsync-<version>-standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-all push -t ./mirth-config -r "Channels/Production Group"
```

**Selective deployment (deploy only changed channels):**
``` shell
# Deploy only channels that have pending changes (non-zero revision delta or code template changes)
$ java -jar mirthsync-<version>-standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-changed push -t ./mirth-config

# Deploy changed channels AND any newly pushed channels that aren't currently deployed
$ java -jar mirthsync-<version>-standalone.jar -s https://localhost:8443/api -u admin -p admin --deploy-changed --deploy-new push -t ./mirth-config
```

**Performance comparison:**
- `--deploy`: Each channel is deployed immediately after being saved (N API calls for N channels)
- `--deploy-all`: All channels are collected during push, then deployed in a single bulk operation (1 API call for all channels)
Expand Down
61 changes: 61 additions & 0 deletions src/mirthsync/apis.clj
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@
(let [channel-id (mi/find-id api (:el-loc app-conf))]
(log/infof "Collecting channel ID for bulk deployment: %s" channel-id)
(swap! (:bulk-deploy-channels app-conf) conj channel-id)))
;; Track pushed channel IDs for --deploy-new
(when (:pushed-channel-ids app-conf)
(let [channel-id (mi/find-id api (:el-loc app-conf))]
(swap! (:pushed-channel-ids app-conf) conj channel-id)))
true)
(do (log/error (str "Unable to save the channel."
(when-not (app-conf :force) " There may be remote changes or the remote version does not match the local version. If you want to push the local changes anyway you can use the \"-f\" flag to force an overwrite.")))
Expand Down Expand Up @@ -266,6 +270,63 @@
(log/error (str "Error during bulk channel deployment: " body))
false)))))

(defn deploy-changed-channels
"Query the server for channel deployment statuses and deploy only channels
that have a non-zero deployedRevisionDelta or, when code templates were
pushed, where codeTemplatesChanged is true. When --deploy-new is active,
also deploy any pushed channels not found in the dashboard statuses."
[{:keys [code-templates-pushed pushed-channel-ids] :as app-conf}]
(log/info "Checking for channels with pending deployment changes...")
(try+
(let [body (mhttp/get-xml app-conf "/channels/statuses")
statuses-zip (mx/to-zip body)
dashboard-statuses (cdzx/xml-> statuses-zip :dashboardStatus)
deployed-ids (set (map #(cdzx/xml1-> % :channelId cdzx/text) dashboard-statuses))
;; Find changed channels from dashboard statuses
changed-channels
(reduce
(fn [acc status-loc]
(let [channel-id (cdzx/xml1-> status-loc :channelId cdzx/text)
channel-name (cdzx/xml1-> status-loc :name cdzx/text)
delta-text (cdzx/xml1-> status-loc :deployedRevisionDelta cdzx/text)
delta (when delta-text
(try (Integer/parseInt delta-text)
(catch NumberFormatException _ nil)))
code-templates-changed (= "true" (cdzx/xml1-> status-loc :codeTemplatesChanged cdzx/text))
revision-changed (and delta (not= 0 delta))
needs-deploy (or revision-changed
(and code-templates-pushed code-templates-changed))]
(log/debugf "Channel '%s' (%s): revisionDelta=%s, codeTemplatesChanged=%s"
channel-name channel-id (or delta-text "nil") code-templates-changed)
(when needs-deploy
(log/infof "Collecting channel for deployment: %s (%s)" channel-name channel-id))
(if needs-deploy
(conj acc channel-id)
acc)))
[]
dashboard-statuses)
;; Find undeployed channels (pushed but not in dashboard)
new-channels (when pushed-channel-ids
(let [pushed-ids @pushed-channel-ids
undeployed (remove deployed-ids pushed-ids)]
(when (seq undeployed)
(doseq [id undeployed]
(log/infof "Collecting undeployed channel for deployment: %s" id))
undeployed)))
all-channels (distinct (concat changed-channels new-channels))]
(if (seq all-channels)
(do (log/infof "Deploying %d channel(s): %s" (count all-channels) (pr-str all-channels))
(let [channel-set (apply str "<set>"
(map #(str "<string>" % "</string>") all-channels)
"</set>")]
(mhttp/post-xml app-conf "/channels/_deploy" channel-set
{:returnErrors "true" :debug "false"} false)
(log/info "Deploy-changed completed successfully")))
(log/info "No channels have pending deployment changes")))
(catch Object {:keys [body]}
(log/error (str "Error during deploy-changed: " body))
false)))

(defmethod mi/pre-node-action :default [_ app-conf] app-conf)
(defmethod mi/pre-node-action :code-template-libraries [_ app-conf]
(pre-node-action :server-codelibs app-conf))
Expand Down
10 changes: 10 additions & 0 deletions src/mirthsync/cli.clj
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@
During a push, collect all channel IDs and deploy them together
at the end, allowing Mirth's dependency logic to control order."]

[nil "--deploy-changed" "Deploy only changed channels after push
After all channels are saved, query the server for channel statuses
and deploy only those with a non-zero deployedRevisionDelta or
where codeTemplatesChanged is true."]

[nil "--deploy-new" "Deploy channels that are not currently deployed
Use with --deploy-changed. During push, tracks which channels were
saved. After push, any saved channel not found in the dashboard
statuses (i.e. not currently deployed) will also be deployed."]

["-I" "--interactive" "
Allow for console prompts for user input"]

Expand Down
18 changes: 17 additions & 1 deletion src/mirthsync/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
app-conf (http/with-authentication
app-conf
(fn []
(when (and (= "push" action)
(> (count (filter identity [(:deploy app-conf) (:deploy-all app-conf) (:deploy-changed app-conf)])) 1))
(log/warn "Multiple deploy flags specified. This may cause redundant deployment operations."))
(when (and (= "push" action) (:deploy-new app-conf) (not (:deploy-changed app-conf)))
(log/warn "--deploy-new has no effect without --deploy-changed"))
(let [preprocessed-conf (api/iterate-apis app-conf (api/apis app-conf) api/preprocess-api)
;; For pull operations, always capture local files before pull for orphan detection
conf-with-pre-pull (if (= "pull" action)
Expand All @@ -43,10 +48,21 @@
conf-with-bulk-deploy (if (and (= "push" action) (:deploy-all conf-with-pre-pull))
(assoc conf-with-pre-pull :bulk-deploy-channels (atom []))
conf-with-pre-pull)
processed-conf (api/iterate-apis conf-with-bulk-deploy (api/apis conf-with-bulk-deploy) action-fn)]
;; Initialize pushed-channel-ids atom for --deploy-new tracking (requires --deploy-changed)
conf-with-tracking (if (and (= "push" action) (:deploy-new conf-with-bulk-deploy) (:deploy-changed conf-with-bulk-deploy))
(assoc conf-with-bulk-deploy :pushed-channel-ids (atom []))
conf-with-bulk-deploy)
processed-conf (api/iterate-apis conf-with-tracking (api/apis conf-with-tracking) action-fn)]
;; After push with --deploy-all, deploy all channels
(when (and (= "push" action) (:deploy-all processed-conf))
(api/deploy-all-channels processed-conf))
;; After push with --deploy-changed, deploy only channels with revision delta
(when (and (= "push" action) (:deploy-changed processed-conf))
(let [pushed-apis (set (api/apis processed-conf))
code-templates-pushed (or (contains? pushed-apis :code-template-libraries)
(contains? pushed-apis :code-templates))]
(api/deploy-changed-channels
(assoc processed-conf :code-templates-pushed code-templates-pushed))))
;; After pull, always check for orphaned files
(if (= "pull" action)
(act/cleanup-orphaned-files-with-pre-pull processed-conf (api/apis processed-conf))
Expand Down
8 changes: 8 additions & 0 deletions src/mirthsync/http_client.clj
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,11 @@
:body
mxml/to-zip
find-elements))

(defn get-xml
"Authenticated GET to the given path, returns the response body string."
[{:keys [server ignore-cert-warnings]} path]
(-> (client/get (str server path)
{:headers (build-headers)
:insecure? ignore-cert-warnings})
:body))
129 changes: 129 additions & 0 deletions test/mirthsync/apis_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,135 @@
(ct/is (= false (mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result)))
(ct/is (empty? @(:bulk-deploy-channels app-conf))))))

(defn- find-changed-channel-ids
"Helper to extract channel IDs that need deployment from dashboard statuses.
A channel needs deployment if deployedRevisionDelta is non-zero or codeTemplatesChanged is true."
[dashboard-statuses]
(reduce
(fn [acc status-loc]
(let [channel-id (cdzx/xml1-> status-loc :channelId cdzx/text)
delta-text (cdzx/xml1-> status-loc :deployedRevisionDelta cdzx/text)
delta (when delta-text
(try (Integer/parseInt delta-text)
(catch NumberFormatException _ nil)))
code-templates-changed (= "true" (cdzx/xml1-> status-loc :codeTemplatesChanged cdzx/text))
needs-deploy (or (and delta (not= 0 delta))
code-templates-changed)]
(if needs-deploy
(conj acc channel-id)
acc)))
[]
dashboard-statuses))

(ct/deftest deploy-changed-channels-tests
(ct/testing "Channels with non-zero revision delta are selected"
(let [sample-xml "<list>
<dashboardStatus>
<channelId>1aa2c102-3167-4122-9467-dd989ce3d189</channelId>
<name>Channel A</name>
<deployedRevisionDelta>1</deployedRevisionDelta>
<codeTemplatesChanged>false</codeTemplatesChanged>
</dashboardStatus>
<dashboardStatus>
<channelId>b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98</channelId>
<name>Channel B</name>
<deployedRevisionDelta>0</deployedRevisionDelta>
<codeTemplatesChanged>false</codeTemplatesChanged>
</dashboardStatus>
<dashboardStatus>
<channelId>c3d4e5f6-7890-4abc-def1-234567890abc</channelId>
<name>Channel C</name>
<deployedRevisionDelta>3</deployedRevisionDelta>
<codeTemplatesChanged>false</codeTemplatesChanged>
</dashboardStatus>
</list>"
statuses-zip (mx/to-zip sample-xml)
changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))]
(ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189" "c3d4e5f6-7890-4abc-def1-234567890abc"] changed-ids))
(ct/is (not (some #{"b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"} changed-ids)))))

(ct/testing "Channels with codeTemplatesChanged=true are selected"
(let [sample-xml "<list>
<dashboardStatus>
<channelId>1aa2c102-3167-4122-9467-dd989ce3d189</channelId>
<name>Channel A</name>
<deployedRevisionDelta>0</deployedRevisionDelta>
<codeTemplatesChanged>true</codeTemplatesChanged>
</dashboardStatus>
<dashboardStatus>
<channelId>b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98</channelId>
<name>Channel B</name>
<deployedRevisionDelta>0</deployedRevisionDelta>
<codeTemplatesChanged>false</codeTemplatesChanged>
</dashboardStatus>
</list>"
statuses-zip (mx/to-zip sample-xml)
changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))]
(ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189"] changed-ids))))

(ct/testing "Both delta and codeTemplatesChanged trigger deploy"
(let [sample-xml "<list>
<dashboardStatus>
<channelId>1aa2c102-3167-4122-9467-dd989ce3d189</channelId>
<name>Channel A</name>
<deployedRevisionDelta>2</deployedRevisionDelta>
<codeTemplatesChanged>true</codeTemplatesChanged>
</dashboardStatus>
</list>"
statuses-zip (mx/to-zip sample-xml)
changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))]
(ct/is (= ["1aa2c102-3167-4122-9467-dd989ce3d189"] changed-ids))))

(ct/testing "All channels up to date results in empty list"
(let [sample-xml "<list>
<dashboardStatus>
<channelId>1aa2c102-3167-4122-9467-dd989ce3d189</channelId>
<name>Channel A</name>
<deployedRevisionDelta>0</deployedRevisionDelta>
<codeTemplatesChanged>false</codeTemplatesChanged>
</dashboardStatus>
</list>"
statuses-zip (mx/to-zip sample-xml)
changed-ids (find-changed-channel-ids (cdzx/xml-> statuses-zip :dashboardStatus))]
(ct/is (empty? changed-ids))))

(ct/testing "Empty dashboard status list"
(let [sample-xml "<list/>"
statuses-zip (mx/to-zip sample-xml)
dashboard-statuses (cdzx/xml-> statuses-zip :dashboardStatus)]
(ct/is (empty? dashboard-statuses)))))

(ct/deftest deploy-new-tracking-tests
(ct/testing "Channel after-push tracks pushed IDs when pushed-channel-ids atom is present"
(let [app-conf {:pushed-channel-ids (atom [])}
api :channels
el-loc (mx/to-zip "<channel><id>d4e5f6a7-8901-4bcd-ef23-456789abcdef</id><name>New Channel</name></channel>")
result {:status 200 :body "true"}]
(with-redefs [mirthsync.interfaces/find-id (fn [_ _] "d4e5f6a7-8901-4bcd-ef23-456789abcdef")]
(mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result)
(ct/is (= ["d4e5f6a7-8901-4bcd-ef23-456789abcdef"] @(:pushed-channel-ids app-conf))))))

(ct/testing "Channel after-push does not track when pushed-channel-ids atom is absent"
(let [app-conf {}
api :channels
el-loc (mx/to-zip "<channel><id>d4e5f6a7-8901-4bcd-ef23-456789abcdef</id><name>Channel</name></channel>")
result {:status 200 :body "true"}]
(with-redefs [mirthsync.interfaces/find-id (fn [_ _] "d4e5f6a7-8901-4bcd-ef23-456789abcdef")]
(mirthsync.interfaces/after-push api (assoc app-conf :el-loc el-loc) result)
(ct/is (nil? (:pushed-channel-ids app-conf))))))

(ct/testing "Undeployed pushed channels are identified correctly"
(let [pushed-ids (atom ["1aa2c102-3167-4122-9467-dd989ce3d189" "e5f6a7b8-9012-4cde-f345-6789abcdef01"])
deployed-ids #{"1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"}
undeployed (remove deployed-ids @pushed-ids)]
(ct/is (= ["e5f6a7b8-9012-4cde-f345-6789abcdef01"] (vec undeployed)))))

(ct/testing "All pushed channels already deployed results in no new channels"
(let [pushed-ids (atom ["1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98"])
deployed-ids #{"1aa2c102-3167-4122-9467-dd989ce3d189" "b7e8f4a1-52d3-4c9e-a1b6-7f3e2d1c0a98" "c3d4e5f6-7890-4abc-def1-234567890abc"}
undeployed (remove deployed-ids @pushed-ids)]
(ct/is (empty? undeployed)))))

(comment
(ct/deftest iterate-apis
(ct/is (= "target/foo/blah.xm" (local-path-str "foo/blah.xml" "target")))))
Loading