@@ -3,13 +3,26 @@ package com.rundeck.plugins.ansible.ansible
33import com.dtolabs.rundeck.core.common.Framework
44import com.dtolabs.rundeck.core.common.INodeSet
55import com.dtolabs.rundeck.core.execution.ExecutionContext
6+ import com.dtolabs.rundeck.core.execution.ExecutionLogger
7+ import com.dtolabs.rundeck.core.utils.PropertyLookup
68import com.dtolabs.rundeck.plugins.step.PluginStepContext
79import com.rundeck.plugins.ansible.plugin.AnsiblePluginGroup
810import spock.lang.Specification
9- import com.dtolabs.rundeck.core.execution.ExecutionLogger
11+ import spock.lang.TempDir
12+
13+ import java.nio.file.Files
14+ import java.util.concurrent.ConcurrentLinkedQueue
15+ import java.util.concurrent.CountDownLatch
16+ import java.util.concurrent.CyclicBarrier
17+ import java.util.concurrent.ExecutorService
18+ import java.util.concurrent.Executors
19+ import java.util.concurrent.TimeUnit
1020
1121class AnsibleRunnerContextBuilderSpec extends Specification {
1222
23+ @TempDir
24+ File baseTmpDir
25+
1326 def " test plugin group" (){
1427 given :
1528
@@ -80,4 +93,184 @@ class AnsibleRunnerContextBuilderSpec extends Specification {
8093 ! contextBuilder. encryptExtraVars()
8194 contextBuilder. getPlaybookPath() == " path/to/playbook"
8295 }
96+
97+ // ----------------------------------------------------------------------------
98+ // Race condition fix: getExecutionSpecificTmpDir() / cleanupTempFiles()
99+ // See ansible-plugin-race-condition-context.md
100+ // ----------------------------------------------------------------------------
101+
102+ /**
103+ * Builds a fresh AnsibleRunnerContextBuilder wired so that
104+ * AnsibleUtil.getCustomTmpPathDir(framework) resolves to {@code baseTmpDir } and
105+ * the data context exposes the given {@code execId }.
106+ */
107+ private AnsibleRunnerContextBuilder newBuilder (String execId ) {
108+ def propertyLookup = Mock (PropertyLookup )
109+ propertyLookup. getProperty(" framework.tmp.dir" ) >> baseTmpDir. absolutePath
110+
111+ def framework = Mock (Framework )
112+ framework. getPropertyLookup() >> propertyLookup
113+ framework. hasProjectProperty(_, _) >> false
114+ framework. hasProperty(_) >> false
115+
116+ def execContext = Mock (ExecutionContext )
117+ execContext. getDataContext() >> [' job' : [' execid' : execId]]
118+ execContext. getFrameworkProject() >> ' test-project'
119+
120+ def nodes = Mock (INodeSet )
121+ nodes. getNodes() >> []
122+
123+ return new AnsibleRunnerContextBuilder (execContext, framework, nodes, [:])
124+ }
125+
126+ /**
127+ * Drops a fake "inventory" file inside the builder's working directory, mirroring
128+ * what AnsibleInventoryBuilder.buildInventory() does in production.
129+ */
130+ private File seedInventoryFile (AnsibleRunnerContextBuilder builder ) {
131+ File dir = new File (builder. getExecutionSpecificTmpDir())
132+ File inventory = File . createTempFile(" ansible-inventory" , " .json" , dir)
133+ inventory. text = ' {"all":{"hosts":{}}}'
134+ return inventory
135+ }
136+
137+ def " two builders with the same executionId get distinct directories with the executionId prefix" () {
138+ given :
139+ def builderA = newBuilder(" 4242" )
140+ def builderB = newBuilder(" 4242" )
141+
142+ when :
143+ File dirA = new File (builderA. getExecutionSpecificTmpDir())
144+ File dirB = new File (builderB. getExecutionSpecificTmpDir())
145+
146+ then : " both directories exist"
147+ dirA. exists() && dirA. isDirectory()
148+ dirB. exists() && dirB. isDirectory()
149+
150+ and : " they are different directories"
151+ dirA. absolutePath != dirB. absolutePath
152+
153+ and : " they sit directly under the configured base tmp dir (no shared parent)"
154+ dirA. parentFile. absolutePath == baseTmpDir. absolutePath
155+ dirB. parentFile. absolutePath == baseTmpDir. absolutePath
156+
157+ and : " their names start with the ansible-exec-<execId>-builder- prefix for filtering"
158+ dirA. name. startsWith(" ansible-exec-4242-builder-" )
159+ dirB. name. startsWith(" ansible-exec-4242-builder-" )
160+ }
161+
162+ def " directories of distinct executions can coexist under the same base tmp dir" () {
163+ given :
164+ def builderX = newBuilder(" 4242" )
165+ def builderY = newBuilder(" 9999" )
166+
167+ when :
168+ File dirX = new File (builderX. getExecutionSpecificTmpDir())
169+ File dirY = new File (builderY. getExecutionSpecificTmpDir())
170+
171+ then :
172+ dirX. parentFile. absolutePath == baseTmpDir. absolutePath
173+ dirY. parentFile. absolutePath == baseTmpDir. absolutePath
174+ dirX. name. startsWith(" ansible-exec-4242-builder-" )
175+ dirY. name. startsWith(" ansible-exec-9999-builder-" )
176+ }
177+
178+ def " cleanupTempFiles on one builder must not delete a sibling's inventory" () {
179+ given :
180+ def builderA = newBuilder(" 9001" )
181+ def builderB = newBuilder(" 9001" )
182+
183+ File invA = seedInventoryFile(builderA)
184+ File invB = seedInventoryFile(builderB)
185+ File dirA = new File (builderA. getExecutionSpecificTmpDir())
186+ File dirB = new File (builderB. getExecutionSpecificTmpDir())
187+
188+ expect :
189+ invA. exists()
190+ invB. exists()
191+
192+ when : " builder A finishes and cleans up"
193+ builderA. cleanupTempFiles()
194+
195+ then : " A's directory and inventory are gone, but B's is intact"
196+ ! dirA. exists()
197+ ! invA. exists()
198+ dirB. exists()
199+ invB. exists()
200+
201+ when : " builder B finishes and cleans up"
202+ builderB. cleanupTempFiles()
203+
204+ then : " B's directory is also gone"
205+ ! dirB. exists()
206+ ! invB. exists()
207+ }
208+
209+ def " parallel builders never lose their inventory files before their own cleanup" () {
210+ given :
211+ int parallelism = 16
212+ ExecutorService pool = Executors . newFixedThreadPool(parallelism)
213+ CyclicBarrier barrier = new CyclicBarrier (parallelism)
214+ CountDownLatch done = new CountDownLatch (parallelism)
215+ ConcurrentLinkedQueue<String > failures = new ConcurrentLinkedQueue<> ()
216+
217+ when :
218+ parallelism. times {
219+ pool. submit({
220+ AnsibleRunnerContextBuilder builder = null
221+ try {
222+ builder = newBuilder(" 55555" )
223+ File workDir = new File (builder. getExecutionSpecificTmpDir())
224+ File inventory = File . createTempFile(" ansible-inventory" , " .json" , workDir)
225+ inventory. text = ' {"all":{"hosts":{}}}'
226+
227+ // All threads have created their own dir+inventory; now hold them open
228+ // simultaneously to maximise the chance of a sibling's cleanup running while
229+ // ours is still alive.
230+ barrier. await(10 , TimeUnit . SECONDS )
231+
232+ if (! inventory. exists()) {
233+ failures. add(" inventory disappeared while builder was alive: ${ inventory.absolutePath} " )
234+ }
235+ if (! workDir. exists()) {
236+ failures. add(" work dir disappeared while builder was alive: ${ workDir.absolutePath} " )
237+ }
238+ } catch (Throwable t) {
239+ failures. add(" worker threw: ${ t.message} " )
240+ } finally {
241+ builder?. cleanupTempFiles()
242+ done. countDown()
243+ }
244+ } as Runnable )
245+ }
246+
247+ boolean finished = done. await(30 , TimeUnit . SECONDS )
248+ pool. shutdownNow()
249+
250+ then :
251+ finished
252+ failures. isEmpty()
253+
254+ and : " no leftover ansible-exec-55555-builder-* directories remain after cleanup"
255+ baseTmpDir. listFiles({ f -> f. name. startsWith(" ansible-exec-55555-builder-" ) } as FileFilter ). length == 0
256+ }
257+
258+ def " debug mode preserves the per-builder directory" () {
259+ given :
260+ def builder = newBuilder(" debug-123" )
261+ // ansible-debug=true must short-circuit cleanup paths.
262+ Map<String , Object > jobConf = (Map<String , Object > ) builder. getJobConf()
263+ jobConf. put(" ansible-debug" , " true" )
264+
265+ File workDir = new File (builder. getExecutionSpecificTmpDir())
266+ File inventory = seedInventoryFile(builder)
267+
268+ when :
269+ builder. cleanupTempFiles()
270+
271+ then : " nothing is deleted in debug mode, mirroring legacy behaviour"
272+ inventory. exists()
273+ workDir. exists()
274+ workDir. name. startsWith(" ansible-exec-debug-123-builder-" )
275+ }
83276}
0 commit comments