From d5da19fecc6b10ec6d08add1b0c91923ec7c9d37 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Thu, 29 Aug 2024 09:42:19 -0700 Subject: [PATCH 01/40] [cuebot] Fix the issue with auto-retrying killed frames (#1444) Auto-retrying frames killed automatically by the OOM-kill logic was not working as expected. RQD is currently not able to report exit_signal=9 when a frame is killed by the OOM logic. The current solution sets exitStatus to Dispatcher.EXIT_STATUS_MEMORY_FAILURE before killing the frame, this enables auto-retrying frames affected by the logic when they report with a frameCompleteReport. --- .../spcue/dispatcher/FrameCompleteHandler.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/FrameCompleteHandler.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/FrameCompleteHandler.java index c405a9e31..6e9d99a34 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/FrameCompleteHandler.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/FrameCompleteHandler.java @@ -153,7 +153,18 @@ public void handleFrameCompleteReport(final FrameCompleteReport report) { final String key = proc.getJobId() + "_" + report.getFrame().getLayerId() + "_" + report.getFrame().getFrameId(); - if (dispatchSupport.stopFrame(frame, newFrameState, report.getExitStatus(), + // rqd is currently not able to report exit_signal=9 when a frame is killed by + // the OOM logic. The current solution sets exitStatus to + // Dispatcher.EXIT_STATUS_MEMORY_FAILURE before killing the frame, this enables + // auto-retrying frames affected by the logic when they report with a + // frameCompleteReport. This status retouch ensures a frame complete report is + // not able to override what has been set by the previous logic. + int exitStatus = report.getExitStatus(); + if (frameDetail.exitStatus == Dispatcher.EXIT_STATUS_MEMORY_FAILURE) { + exitStatus = frameDetail.exitStatus; + } + + if (dispatchSupport.stopFrame(frame, newFrameState, exitStatus, report.getFrame().getMaxRss())) { if (dispatcher.isTestMode()) { // Database modifications on a threadpool cannot be captured by the test thread From e4f62d335d6d229f658e075a8e26559f0b89b44e Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Thu, 29 Aug 2024 09:51:34 -0700 Subject: [PATCH 02/40] [cuegui] Save job user colors (#1463) Job colors set using the right-click menu "set user color" option is now saved on the session config file and reloaded when the application is initialized. --- cuegui/cuegui/JobMonitorTree.py | 9 +++++++++ cuegui/cuegui/plugins/MonitorJobsPlugin.py | 3 +++ 2 files changed, 12 insertions(+) diff --git a/cuegui/cuegui/JobMonitorTree.py b/cuegui/cuegui/JobMonitorTree.py index 148e9516c..5b8897bba 100644 --- a/cuegui/cuegui/JobMonitorTree.py +++ b/cuegui/cuegui/JobMonitorTree.py @@ -23,6 +23,7 @@ from future.utils import iteritems from builtins import map import time +import pickle from qtpy import QtCore from qtpy import QtGui @@ -395,6 +396,14 @@ def removeFinishedItems(self): for item in self.findItems("Finished", QtCore.Qt.MatchFixedString, COLUMN_STATE): self.removeItem(item) + def getUserColors(self): + """Returns the colored jobs to be saved""" + return list(pickle.dumps(self.__userColors)) + + def setUserColors(self, state): + """Sets the colored jobs that were saved""" + self.__userColors = pickle.loads(bytes(state)) + def contextMenuEvent(self, e): """Creates a context menu when an item is right clicked. @param e: Right click QEvent diff --git a/cuegui/cuegui/plugins/MonitorJobsPlugin.py b/cuegui/cuegui/plugins/MonitorJobsPlugin.py index 9758af1ac..00e217fbc 100644 --- a/cuegui/cuegui/plugins/MonitorJobsPlugin.py +++ b/cuegui/cuegui/plugins/MonitorJobsPlugin.py @@ -81,6 +81,9 @@ def __init__(self, parent): ("jobs", self.getJobIds, self.restoreJobIds), + ("userColors", + self.jobMonitor.getUserColors, + self.jobMonitor.setUserColors), ("columnVisibility", self.jobMonitor.getColumnVisibility, self.jobMonitor.setColumnVisibility), From 1e4695d1d274ec099dadfb9f2920e648ac4c5bbe Mon Sep 17 00:00:00 2001 From: Anton Brand Date: Thu, 29 Aug 2024 13:07:56 -0400 Subject: [PATCH 03/40] [cuebot] fix dispatched frame chunk end frame number (#1467) **Link the Issue(s) this Pull Request is related to.** #1129 **Summarize your change.** Fix the frame end resolution of a frame chunk used to fill the place holder `#FRAME_END#`. The current behavior incorrectly returns the index of the last frame in the frame list instead of its value. Leverage `FrameSet.get_chunk` method that is already doing all the legwork to get the last frame. **Related topics** - https://github.com/AcademySoftwareFoundation/OpenCue/pull/1320 - https://github.com/AcademySoftwareFoundation/OpenCue/pull/367 --------- Signed-off-by: Anton Brand Co-authored-by: Kern Attila GERMAIN <5556461+KernAttila@users.noreply.github.com> --- .../spcue/dispatcher/DispatchSupportService.java | 9 +++------ .../spcue/test/util/FrameSetTests.java | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java index 0e3f4412c..3d7211585 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java @@ -376,12 +376,9 @@ public RunFrame prepareRqdRunFrame(VirtualProc proc, DispatchFrame frame) { FrameSet fs = new FrameSet(frame.range); int startFrameIndex = fs.index(frameNumber); String frameSpec = fs.getChunk(startFrameIndex, frame.chunkSize); - int lastFrameIndex = fs.size() - 1; - int endChunkIndex = startFrameIndex + frame.chunkSize - 1; - if (endChunkIndex > lastFrameIndex) { - endChunkIndex = lastFrameIndex; - } + FrameSet chunkFrameSet = new FrameSet(frameSpec); + int chunkEndFrame = chunkFrameSet.get(chunkFrameSet.size()-1); RunFrame.Builder builder = RunFrame.newBuilder() .setShot(frame.shot) @@ -424,7 +421,7 @@ public RunFrame prepareRqdRunFrame(VirtualProc proc, DispatchFrame frame) { .replaceAll("#ZFRAME#", zFrameNumber) .replaceAll("#IFRAME#", String.valueOf(frameNumber)) .replaceAll("#FRAME_START#", String.valueOf(frameNumber)) - .replaceAll("#FRAME_END#", String.valueOf(endChunkIndex)) + .replaceAll("#FRAME_END#", String.valueOf(chunkEndFrame)) .replaceAll("#FRAME_CHUNK#", String.valueOf(frame.chunkSize)) .replaceAll("#LAYER#", frame.layerName) .replaceAll("#JOB#", frame.jobName) diff --git a/cuebot/src/test/java/com/imageworks/spcue/test/util/FrameSetTests.java b/cuebot/src/test/java/com/imageworks/spcue/test/util/FrameSetTests.java index 404d53b0f..a138f700d 100644 --- a/cuebot/src/test/java/com/imageworks/spcue/test/util/FrameSetTests.java +++ b/cuebot/src/test/java/com/imageworks/spcue/test/util/FrameSetTests.java @@ -84,4 +84,20 @@ public void shouldStopBeforeTheEndOfTheRange() { assertEquals("55-60", result.getChunk(0, 10)); } + + @Test + public void shouldReturnLastFrame() { + FrameSet result1 = new FrameSet("1-10x2"); + + FrameSet chunk1 = new FrameSet(result1.getChunk(0, 3)); + FrameSet chunk2 = new FrameSet(result1.getChunk(3, 3)); + + assertEquals(5, chunk1.get(chunk1.size()-1)); + assertEquals(9, chunk2.get(chunk2.size()-1)); + + FrameSet result2 = new FrameSet("1"); + FrameSet chunk3 = new FrameSet(result2.getChunk(0, 3)); + + assertEquals(1, chunk3.get(chunk3.size()-1)); + } } From 13087f3e35347a798ef6d06c2d7a45944ff54a0b Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Thu, 29 Aug 2024 10:08:12 -0700 Subject: [PATCH 04/40] [cuegui] Update AllocationsPlugin columns (#1462) Currently, the Allocations plugin "Idle" cores column includes cores in hosts that are locked, down, or set to repair. It would be preferable for those cores to be broken into their columns so that the number of cores available to be booked would be more accurate. --- cuegui/cuegui/plugins/AllocationsPlugin.py | 56 ++++++++++++++++++---- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/cuegui/cuegui/plugins/AllocationsPlugin.py b/cuegui/cuegui/plugins/AllocationsPlugin.py index d594466f5..cc8273c25 100644 --- a/cuegui/cuegui/plugins/AllocationsPlugin.py +++ b/cuegui/cuegui/plugins/AllocationsPlugin.py @@ -77,21 +77,57 @@ def __init__(self, parent): self.addColumn("Tag", 100, id=2, data=lambda alloc: alloc.data.tag) - self.addColumn("Cores", 45, id=3, - data=lambda allocation: allocation.data.stats.cores, + self.addColumn("Cores", 50, id=3, + data=lambda allocation: int(allocation.data.stats.cores), sort=lambda allocation: allocation.data.stats.cores) - self.addColumn("Idle",45, id=4, - data=lambda allocation: (int(allocation.data.stats.available_cores)), - sort=lambda allocation: allocation.data.stats.available_cores) - - self.addColumn("Hosts", 45, id=5, + self.addColumn("Idle", 50, id=4, + data=lambda allocation: (int(allocation.totalAvailableCores())), + sort=lambda allocation: allocation.totalAvailableCores()) + + self.addColumn("Locked", 65, id=5, + data=lambda allocation: int(allocation.totalLockedCores()), + sort=lambda allocation: allocation.totalLockedCores()) + + self.addColumn("Down", 55, id=6, + data=lambda allocation: sum(int(host.cores()) + for host in allocation.getHosts() + if host.state() == 1), + sort=lambda allocation: sum(int(host.cores()) + for host in allocation.getHosts() + if host.state() == 1)) + + self.addColumn("Repair", 65, id=7, + data=lambda allocation: sum(int(host.cores()) + for host in allocation.getHosts() + if host.state() == 4), + sort=lambda allocation: sum(int(host.cores()) + for host in allocation.getHosts() + if host.state() == 4)) + + self.addColumn("Hosts", 55, id=8, data=lambda alloc: alloc.data.stats.hosts, sort=lambda alloc: alloc.data.stats.hosts) - # It would be nice to display this again: - #self.addColumn("Nimby", 40, id=6, - # data=lambda alloc:(alloc.totalNimbyLockedHosts())) + self.addColumn("Locked", 65, id=9, + data=lambda alloc: alloc.totalLockedHosts() + + len([host for host in alloc.getHosts() + if host.lockState() == 2]), + sort=lambda alloc: alloc.totalLockedHosts() + + len([host for host in alloc.getHosts() + if host.lockState() == 2])) + + self.addColumn("Down", 55, id=10, + data=lambda alloc: alloc.totalDownHosts(), + sort=lambda alloc: alloc.totalDownHosts()) + + self.addColumn("Repair", 50, id=11, + data=lambda allocation: len([host + for host in allocation.getHosts() + if host.state() == 4]), + sort=lambda allocation: len([host + for host in allocation.getHosts() + if host.state() == 4])) cuegui.AbstractTreeWidget.AbstractTreeWidget.__init__(self, parent) From e67a8b35e9ba0fca2b41951275b3e7ba9ed9d2b4 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 08:58:50 -0700 Subject: [PATCH 05/40] [cuebot/rqd] Prevent running frames on Swap memory (#1497) Improve logic previously implemented to handle Out-of-memory conditions to consider swap usage. When a host is using more than `dispatcher.oom_max_safe_used_physical_memory_threshold` if its physical memory and more than `dispatcher.oom_max_safe_used_swap_memory_threshold` of its swap memory, a logic that kills frames that are relying heavily on swap memory is triggered. This logic will automatically mark killed frames to be retried and possibly increase its parent layer memory requirements if it had been using more memory than initially reserved. Co-authored-by: Ramon Figueiredo --- VERSION.in | 2 +- .../com/imageworks/spcue/dao/ProcDao.java | 11 +- .../spcue/dao/postgres/ProcDaoJdbc.java | 54 +------ .../spcue/dispatcher/DispatchSupport.java | 3 +- .../dispatcher/DispatchSupportService.java | 10 +- .../spcue/dispatcher/HostReportHandler.java | 143 ++++++++++-------- .../imageworks/spcue/service/HostManager.java | 7 - .../spcue/service/HostManagerService.java | 6 - .../V29__Add_swap_memory_used_column.sql | 3 + cuebot/src/main/resources/opencue.properties | 7 +- .../spcue/test/dao/postgres/ProcDaoTests.java | 61 ++------ .../dispatcher/HostReportHandlerTests.java | 121 ++++++++------- cuebot/src/test/resources/opencue.properties | 5 +- proto/report.proto | 1 + rqd/rqd/rqmachine.py | 23 +++ rqd/rqd/rqnetwork.py | 5 +- 16 files changed, 218 insertions(+), 244 deletions(-) create mode 100644 cuebot/src/main/resources/conf/ddl/postgres/migrations/V29__Add_swap_memory_used_column.sql diff --git a/VERSION.in b/VERSION.in index 61d2f3576..c74e8a041 100644 --- a/VERSION.in +++ b/VERSION.in @@ -1 +1 @@ -0.34 +0.35 diff --git a/cuebot/src/main/java/com/imageworks/spcue/dao/ProcDao.java b/cuebot/src/main/java/com/imageworks/spcue/dao/ProcDao.java index 206b19e22..dcdf8d097 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dao/ProcDao.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dao/ProcDao.java @@ -56,14 +56,6 @@ public interface ProcDao { long getReservedGpuMemory(ProcInterface proc); - /** - * Return the proc that has exceeded its reserved memory by the largest factor. - * - * @param host - * @return - */ - VirtualProc getWorstMemoryOffender(HostInterface host); - /** * Removes a little bit of reserved memory from every other running frame * in order to give some to the target proc. @@ -151,7 +143,8 @@ public interface ProcDao { */ void updateProcMemoryUsage(FrameInterface f, long rss, long maxRss, long vsize, long maxVsize, long usedGpuMemory, - long maxUsedGpuMemory, byte[] children); + long maxUsedGpuMemory, long usedSwapMemory, + byte[] children); /** * get aq virual proc from its unique id diff --git a/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/ProcDaoJdbc.java b/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/ProcDaoJdbc.java index 586d1f1df..ecf39caf7 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/ProcDaoJdbc.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/ProcDaoJdbc.java @@ -30,9 +30,9 @@ import java.util.Map; import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.support.JdbcDaoSupport; -import org.springframework.jdbc.core.PreparedStatementCreator; import com.imageworks.spcue.FrameInterface; import com.imageworks.spcue.HostInterface; @@ -240,6 +240,7 @@ public boolean clearVirtualProcAssignment(FrameInterface frame) { "int_virt_max_used = ?, " + "int_gpu_mem_used = ?, " + "int_gpu_mem_max_used = ?, " + + "int_swap_used = ?, " + "bytea_children = ?, " + "ts_ping = current_timestamp " + "WHERE " + @@ -247,7 +248,8 @@ public boolean clearVirtualProcAssignment(FrameInterface frame) { @Override public void updateProcMemoryUsage(FrameInterface f, long rss, long maxRss, - long vss, long maxVss, long usedGpuMemory, long maxUsedGpuMemory, byte[] children) { + long vss, long maxVss, long usedGpuMemory, long maxUsedGpuMemory, + long usedSwapMemory, byte[] children) { /* * This method is going to repeat for a proc every 1 minute, so * if the proc is being touched by another thread, then return @@ -274,8 +276,9 @@ public PreparedStatement createPreparedStatement(Connection conn) updateProc.setLong(4, maxVss); updateProc.setLong(5, usedGpuMemory); updateProc.setLong(6, maxUsedGpuMemory); - updateProc.setBytes(7, children); - updateProc.setString(8, f.getFrameId()); + updateProc.setLong(7, usedSwapMemory); + updateProc.setBytes(8, children); + updateProc.setString(9, f.getFrameId()); return updateProc; } }); @@ -569,49 +572,6 @@ public boolean increaseReservedMemory(ProcInterface p, long value) { } } - private static final String FIND_WORST_MEMORY_OFFENDER = - "SELECT " + - "pk_proc, " + - "pk_host, " + - "pk_show, "+ - "pk_job, "+ - "pk_layer,"+ - "pk_frame,"+ - "b_unbooked,"+ - "b_local, "+ - "pk_alloc, "+ - "pk_facility, " + - "int_cores_reserved,"+ - "int_mem_reserved," + - "int_mem_max_used,"+ - "int_mem_used,"+ - "int_gpus_reserved," + - "int_gpu_mem_reserved," + - "int_gpu_mem_max_used," + - "int_gpu_mem_used," + - "int_virt_max_used,"+ - "int_virt_used,"+ - "host_name, " + - "str_os, " + - "bytea_children " + - "FROM (" - + GET_VIRTUAL_PROC + " " + - "AND " + - "host.pk_host = ? " + - "AND " + - "proc.int_mem_reserved != 0 " + - "AND " + - "proc.int_virt_used >= proc.int_mem_pre_reserved " + - "ORDER BY " + - "proc.int_virt_used / proc.int_mem_pre_reserved DESC " + - ") AS t1 LIMIT 1"; - - @Override - public VirtualProc getWorstMemoryOffender(HostInterface host) { - return getJdbcTemplate().queryForObject(FIND_WORST_MEMORY_OFFENDER, - VIRTUAL_PROC_MAPPER, host.getHostId()); - } - public long getReservedMemory(ProcInterface proc) { return getJdbcTemplate().queryForObject( "SELECT int_mem_reserved FROM proc WHERE pk_proc=?", diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupport.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupport.java index ffb4a7a34..106d413ce 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupport.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupport.java @@ -437,7 +437,8 @@ void updateFrameMemoryUsageAndLluTime(FrameInterface frame, long rss, long maxRs */ void updateProcMemoryUsage(FrameInterface frame, long rss, long maxRss, long vsize, long maxVsize, long usedGpuMemory, - long maxUsedGpuMemory, byte[] children); + long maxUsedGpuMemory, long usedSwapMemory, + byte[] children); /** * Return true if adding the given core units would put the show diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java index 3d7211585..f60b2c1e6 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/DispatchSupportService.java @@ -214,12 +214,12 @@ public void runFrame(VirtualProc proc, DispatchFrame frame) { " could not be booked on " + frame.getName() + ", " + e); } } - + @Override @Transactional(propagation = Propagation.REQUIRED) public void startFrameAndProc(VirtualProc proc, DispatchFrame frame) { logger.trace("starting frame: " + frame); - + frameDao.updateFrameStarted(proc, frame); reserveProc(proc, frame); @@ -571,9 +571,11 @@ public void lostProc(VirtualProc proc, String reason, int exitStatus) { @Transactional(propagation = Propagation.REQUIRED) public void updateProcMemoryUsage(FrameInterface frame, long rss, long maxRss, long vsize, long maxVsize, long usedGpuMemory, - long maxUsedGpuMemory, byte[] children) { + long maxUsedGpuMemory, long usedSwapMemory, + byte[] children) { procDao.updateProcMemoryUsage(frame, rss, maxRss, vsize, maxVsize, - usedGpuMemory, maxUsedGpuMemory, children); + usedGpuMemory, maxUsedGpuMemory, usedSwapMemory, + children); } @Override diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java index 777186086..46d56929f 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java @@ -21,6 +21,7 @@ import java.sql.Timestamp; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -339,11 +340,11 @@ else if (!dispatchSupport.isCueBookable(host)) { /** * Check if a reported temp storage size and availability is enough for running a job - * + * * Use dispatcher.min_available_temp_storage_percentage (opencue.properties) to * define what's the accepted threshold. Providing hostOs is necessary as this feature * is currently not available on Windows hosts - * + * * @param tempTotalStorage Total storage on the temp directory * @param tempFreeStorage Free storage on the temp directory * @param hostOs Reported os @@ -371,7 +372,7 @@ private boolean isTempDirStorageEnough(Long tempTotalStorage, Long tempFreeStora * @param reportState * @param isBoot */ - private void changeHardwareState(DispatchHost host, HardwareState reportState, boolean isBoot) { + private void changeHardwareState(DispatchHost host, HardwareState reportState, boolean isBoot) { // If the states are the same there is no reason to do this update. if (host.hardwareState.equals(reportState)) { return; @@ -411,7 +412,7 @@ private void changeHardwareState(DispatchHost host, HardwareState reportState, b * - Set the host state to UP, when the amount of free space in the temporary directory * is greater or equal to the minimum required and the host has a comment with * subject: SUBJECT_COMMENT_FULL_TEMP_DIR - * + * * @param host * @param reportHost * @return @@ -499,47 +500,88 @@ private void changeLockState(DispatchHost host, CoreDetail coreInfo) { } /** - * Prevent host from entering an OOM state where oom-killer might start killing important OS processes. + * Prevent host from entering an OOM state where oom-killer might start killing + * important OS processes and frames start using SWAP memory * The kill logic will kick in one of the following conditions is met: - * - Host has less than OOM_MEMORY_LEFT_THRESHOLD_PERCENT memory available - * - A frame is taking more than OOM_FRAME_OVERBOARD_PERCENT of what it had reserved - * For frames that are using more than they had reserved but not above the threshold, negotiate expanding - * the reservations with other frames on the same host - * + * - Host has less than oom_max_safe_used_physical_memory_threshold memory + * available and less than oom_max_safe_used_swap_memory_threshold swap + * available + * - A frame is taking more than OOM_FRAME_OVERBOARD_PERCENT of what it had + * reserved + * For frames that are using more than they had reserved but not above the + * threshold, negotiate expanding the reservations with other frames on the same + * host + * * @param dispatchHost * @param report */ private void handleMemoryUsage(final DispatchHost dispatchHost, RenderHost renderHost, List runningFrames) { - // Don't keep memory balances on nimby hosts - if (dispatchHost.isNimby) { + // Don't keep memory balances on nimby hosts and hosts with invalid memory + // information + if (dispatchHost.isNimby || renderHost.getTotalMem() <= 0) { return; } - final double OOM_MAX_SAFE_USED_MEMORY_THRESHOLD = env - .getRequiredProperty("dispatcher.oom_max_safe_used_memory_threshold", Double.class); + final double OOM_MAX_SAFE_USED_PHYSICAL_THRESHOLD = env + .getRequiredProperty("dispatcher.oom_max_safe_used_physical_memory_threshold", Double.class); + final double OOM_MAX_SAFE_USED_SWAP_THRESHOLD = env + .getRequiredProperty("dispatcher.oom_max_safe_used_swap_memory_threshold", Double.class); final double OOM_FRAME_OVERBOARD_ALLOWED_THRESHOLD = env .getRequiredProperty("dispatcher.oom_frame_overboard_allowed_threshold", Double.class); - boolean memoryWarning = renderHost.getTotalMem() > 0 && - ((double)renderHost.getFreeMem()/renderHost.getTotalMem() < - (1.0 - OOM_MAX_SAFE_USED_MEMORY_THRESHOLD)); + Double physMemoryUsageRatio = renderHost.getTotalMem() > 0 ? + 1.0 - renderHost.getFreeMem() / (double) renderHost.getTotalMem() : + 0.0; + + Double swapMemoryUsageRatio = renderHost.getTotalSwap() > 0 ? + 1.0 - renderHost.getFreeSwap() / (double) renderHost.getTotalSwap() : + 0.0; + + // If checking for the swap threshold has been disabled, only memory usage is + // taken into consideration. + // If checking for memory has been disabled, checking for swap isolated is not + // safe, therefore disabled + boolean memoryWarning = false; + if (OOM_MAX_SAFE_USED_PHYSICAL_THRESHOLD > 0.0 && OOM_MAX_SAFE_USED_SWAP_THRESHOLD > 0.0 && + !physMemoryUsageRatio.isNaN() && !swapMemoryUsageRatio.isNaN()) { + memoryWarning = physMemoryUsageRatio > OOM_MAX_SAFE_USED_PHYSICAL_THRESHOLD && + swapMemoryUsageRatio > OOM_MAX_SAFE_USED_SWAP_THRESHOLD; + } else if (OOM_MAX_SAFE_USED_PHYSICAL_THRESHOLD > 0.0 && !physMemoryUsageRatio.isNaN()) { + memoryWarning = physMemoryUsageRatio > OOM_MAX_SAFE_USED_PHYSICAL_THRESHOLD; + } if (memoryWarning) { - long memoryAvailable = renderHost.getFreeMem(); - long minSafeMemoryAvailable = (long)(renderHost.getTotalMem() * (1.0 - OOM_MAX_SAFE_USED_MEMORY_THRESHOLD)); - // Only allow killing up to 10 frames at a time - int killAttemptsRemaining = 10; - VirtualProc killedProc = null; - do { - killedProc = killWorstMemoryOffender(dispatchHost); + logger.warn("Memory warning(" + renderHost.getName() + "): physMemoryRatio: " + + physMemoryUsageRatio + ", swapRatio: " + swapMemoryUsageRatio); + // Try to kill frames using swap memory as they are probably performing poorly + long swapUsed = renderHost.getTotalSwap() - renderHost.getFreeSwap(); + long maxSwapUsageAllowed = (long) (renderHost.getTotalSwap() + * OOM_MAX_SAFE_USED_SWAP_THRESHOLD); + + // Sort runningFrames bassed on how much swap they are using + runningFrames.sort(Comparator.comparingLong((RunningFrameInfo frame) -> + frame.getUsedSwapMemory()).reversed()); + + int killAttemptsRemaining = 5; + for (RunningFrameInfo frame : runningFrames) { + // Reached the first frame on the sorted list without swap usage + if (frame.getUsedSwapMemory() <= 0) { + break; + } + if (killProcForMemory(frame.getFrameId(), renderHost.getName(), + KillCause.HostUnderOom)) { + swapUsed -= frame.getUsedSwapMemory(); + logger.info("Memory warning(" + renderHost.getName() + "): " + + "Killing frame on " + frame.getJobName() + "." + + frame.getFrameName() + ", using too much swap."); + } + killAttemptsRemaining -= 1; - if (killedProc != null) { - memoryAvailable = memoryAvailable + killedProc.memoryUsed; + if (killAttemptsRemaining <= 0 || swapUsed <= maxSwapUsageAllowed) { + break; } - } while (killAttemptsRemaining > 0 && - memoryAvailable < minSafeMemoryAvailable && - killedProc != null); + } } else { // When no mass cleaning was required, check for frames going overboard // if frames didn't go overboard, manage its reservations trying to increase @@ -582,10 +624,12 @@ private boolean killFrameOverusingMemory(RunningFrameInfo frame, String hostname if (proc.isLocalDispatch) { return false; } - - logger.info("Killing frame on " + frame.getJobName() + "." + frame.getFrameName() + - ", using too much memory."); - return killProcForMemory(proc, hostname, KillCause.FrameOverboard); + boolean killed = killProcForMemory(proc.frameId, hostname, KillCause.FrameOverboard); + if (killed) { + logger.info("Killing frame on " + frame.getJobName() + "." + frame.getFrameName() + + ", using too much memory."); + } + return killed; } catch (EmptyResultDataAccessException e) { return false; } @@ -627,12 +671,12 @@ private boolean getKillClearance(String hostname, String frameId) { return true; } - private boolean killProcForMemory(VirtualProc proc, String hostname, KillCause killCause) { - if (!getKillClearance(hostname, proc.frameId)) { + private boolean killProcForMemory(String frameId, String hostname, KillCause killCause) { + if (!getKillClearance(hostname, frameId)) { return false; } - FrameInterface frame = jobManager.getFrame(proc.frameId); + FrameInterface frame = jobManager.getFrame(frameId); if (dispatcher.isTestMode()) { // Different threads don't share the same database state on the test environment (new DispatchRqdKillFrameMemory(hostname, frame, killCause.toString(), rqdClient, @@ -674,28 +718,6 @@ private boolean killFrame(String frameId, String hostname, KillCause killCause) return true; } - /** - * Kill proc with the worst user/reserved memory ratio. - * - * @param host - * @return killed proc, or null if none could be found or failed to be killed - */ - private VirtualProc killWorstMemoryOffender(final DispatchHost host) { - try { - VirtualProc proc = hostManager.getWorstMemoryOffender(host); - logger.info("Killing frame on " + proc.getName() + ", host is under stress."); - - if (!killProcForMemory(proc, host.getName(), KillCause.HostUnderOom)) { - proc = null; - } - return proc; - } - catch (EmptyResultDataAccessException e) { - logger.error(host.name + " is under OOM and no proc is memory overboard."); - return null; - } - } - /** * Check frame memory usage comparing the amount used with the amount it had reserved * @param frame @@ -825,7 +847,6 @@ private void killTimedOutFrames(List runningFrames, String hos private void updateMemoryUsageAndLluTime(List rFrames) { for (RunningFrameInfo rf: rFrames) { - FrameInterface frame = jobManager.getFrame(rf.getFrameId()); dispatchSupport.updateFrameMemoryUsageAndLluTime(frame, @@ -833,7 +854,9 @@ private void updateMemoryUsageAndLluTime(List rFrames) { dispatchSupport.updateProcMemoryUsage(frame, rf.getRss(), rf.getMaxRss(), rf.getVsize(), rf.getMaxVsize(), rf.getUsedGpuMemory(), - rf.getMaxUsedGpuMemory(), rf.getChildren().toByteArray()); + rf.getMaxUsedGpuMemory(), rf.getUsedSwapMemory(), + rf.getChildren().toByteArray()); + } updateJobMemoryUsage(rFrames); diff --git a/cuebot/src/main/java/com/imageworks/spcue/service/HostManager.java b/cuebot/src/main/java/com/imageworks/spcue/service/HostManager.java index ce5f861f8..dae9bf552 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/service/HostManager.java +++ b/cuebot/src/main/java/com/imageworks/spcue/service/HostManager.java @@ -172,13 +172,6 @@ void setHostStatistics(HostInterface host, void unbookVirtualProcs(List procs); void unbookProc(ProcInterface proc); - /** - * For a given host, return the proc using more memory above what it had initially reserved - * @param h - * @return - */ - VirtualProc getWorstMemoryOffender(HostInterface h); - /** * Return the Virtual proc with the specified unique ID. * diff --git a/cuebot/src/main/java/com/imageworks/spcue/service/HostManagerService.java b/cuebot/src/main/java/com/imageworks/spcue/service/HostManagerService.java index 36de34a1c..6abb08090 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/service/HostManagerService.java +++ b/cuebot/src/main/java/com/imageworks/spcue/service/HostManagerService.java @@ -337,12 +337,6 @@ public void setHostResources(DispatchHost host, HostReport report) { hostDao.updateHostResources(host, report); } - @Override - @Transactional(propagation = Propagation.REQUIRED, readOnly=true) - public VirtualProc getWorstMemoryOffender(HostInterface h) { - return procDao.getWorstMemoryOffender(h); - } - @Override @Transactional(propagation = Propagation.REQUIRED, readOnly=true) public VirtualProc getVirtualProc(String id) { diff --git a/cuebot/src/main/resources/conf/ddl/postgres/migrations/V29__Add_swap_memory_used_column.sql b/cuebot/src/main/resources/conf/ddl/postgres/migrations/V29__Add_swap_memory_used_column.sql new file mode 100644 index 000000000..997bb4e19 --- /dev/null +++ b/cuebot/src/main/resources/conf/ddl/postgres/migrations/V29__Add_swap_memory_used_column.sql @@ -0,0 +1,3 @@ +-- Add a new column to track swap memory usage in the proc table + +ALTER TABLE proc ADD COLUMN int_swap_used BIGINT DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/cuebot/src/main/resources/opencue.properties b/cuebot/src/main/resources/opencue.properties index 257b5695b..b40fbc9c6 100644 --- a/cuebot/src/main/resources/opencue.properties +++ b/cuebot/src/main/resources/opencue.properties @@ -130,7 +130,12 @@ dispatcher.booking_queue.max_pool_size=6 dispatcher.booking_queue.queue_capacity=1000 # Percentage of used memory to consider a risk for triggering oom-killer -dispatcher.oom_max_safe_used_memory_threshold=0.98 +# If equals to -1, it means the feature is turned off +dispatcher.oom_max_safe_used_physical_memory_threshold=0.9 + +# Percentage of used swap to consider a risk for triggering oom-killer +# If equals to -1, it means the feature is turned off +dispatcher.oom_max_safe_used_swap_memory_threshold=0.05 # How much can a frame exceed its reserved memory. # - 0.5 means 50% above reserve diff --git a/cuebot/src/test/java/com/imageworks/spcue/test/dao/postgres/ProcDaoTests.java b/cuebot/src/test/java/com/imageworks/spcue/test/dao/postgres/ProcDaoTests.java index 7504fa751..0cbe09970 100644 --- a/cuebot/src/test/java/com/imageworks/spcue/test/dao/postgres/ProcDaoTests.java +++ b/cuebot/src/test/java/com/imageworks/spcue/test/dao/postgres/ProcDaoTests.java @@ -107,7 +107,7 @@ public class ProcDaoTests extends AbstractTransactionalJUnit4SpringContextTests @Resource FrameSearchFactory frameSearchFactory; - + @Resource ProcSearchFactory procSearchFactory; @@ -328,7 +328,7 @@ public void testUpdateProcMemoryUsage() { procDao.verifyRunningProc(proc.getId(), frame.getId()); byte[] children = new byte[100]; - procDao.updateProcMemoryUsage(frame, 100, 100, 1000, 1000, 0, 0, children); + procDao.updateProcMemoryUsage(frame, 100, 100, 1000, 1000, 0, 0, 0, children); } @@ -572,47 +572,6 @@ public void testIncreaseReservedMemory() { procDao.increaseReservedMemory(proc, 3145728); } - @Test - @Transactional - @Rollback(true) - public void testFindReservedMemoryOffender() { - DispatchHost host = createHost(); - - - jobLauncher.launch(new File("src/test/resources/conf/jobspec/jobspec_dispatch_test.xml")); - JobDetail job = jobManager.findJobDetail("pipe-dev.cue-testuser_shell_dispatch_test_v1"); - jobManager.setJobPaused(job, false); - - int i = 1; - List frames = dispatcherDao.findNextDispatchFrames(job, host, 6); - assertEquals(6, frames.size()); - byte[] children = new byte[100]; - for (DispatchFrame frame: frames) { - - VirtualProc proc = VirtualProc.build(host, frame); - proc.childProcesses = children; - frame.minMemory = Dispatcher.MEM_RESERVED_DEFAULT; - dispatcher.dispatch(frame, proc); - - // Increase the memory usage as frames are added - procDao.updateProcMemoryUsage(frame, - 1000*i, 1000*i, - Dispatcher.MEM_RESERVED_DEFAULT*i, Dispatcher.MEM_RESERVED_DEFAULT*i, - 0, 0, children); - i++; - } - - // Now compare the last frame which has the highest memory - // usage to the what is returned by getWorstMemoryOffender - VirtualProc offender = procDao.getWorstMemoryOffender(host); - - FrameDetail f = frameDao.getFrameDetail(frames.get(5)); - FrameDetail o = frameDao.getFrameDetail(offender); - - assertEquals(f.getName(), o.getName()); - assertEquals(f.id, o.getFrameId()); - } - @Test @Transactional @Rollback(true) @@ -672,7 +631,7 @@ public void testBalanceUnderUtilizedProcs() { procDao.insertVirtualProc(proc1); byte[] children = new byte[100]; - procDao.updateProcMemoryUsage(frame1, 250000, 250000, 250000, 250000, 0, 0, children); + procDao.updateProcMemoryUsage(frame1, 250000, 250000, 250000, 250000, 0, 0, 0, children); layerDao.updateLayerMaxRSS(frame1, 250000, true); FrameDetail frameDetail2 = frameDao.findFrameDetail(job, "0002-pass_1"); @@ -682,7 +641,7 @@ public void testBalanceUnderUtilizedProcs() { proc2.frameId = frame2.id; procDao.insertVirtualProc(proc2); - procDao.updateProcMemoryUsage(frame2, 255000, 255000,255000, 255000, 0, 0, children); + procDao.updateProcMemoryUsage(frame2, 255000, 255000,255000, 255000, 0, 0, 0, children); layerDao.updateLayerMaxRSS(frame2, 255000, true); FrameDetail frameDetail3 = frameDao.findFrameDetail(job, "0003-pass_1"); @@ -692,7 +651,7 @@ public void testBalanceUnderUtilizedProcs() { proc3.frameId = frame3.id; procDao.insertVirtualProc(proc3); - procDao.updateProcMemoryUsage(frame3, 3145728, 3145728,3145728, 3145728, 0, 0, children); + procDao.updateProcMemoryUsage(frame3, 3145728, 3145728,3145728, 3145728, 0, 0, 0, children); layerDao.updateLayerMaxRSS(frame3,300000, true); procDao.balanceUnderUtilizedProcs(proc3, 100000); @@ -856,23 +815,23 @@ public void getProcsBySearch() { public void testVirtualProcWithSelfishService() { DispatchHost host = createHost(); JobDetail job = launchJob(); - + FrameDetail frameDetail = frameDao.findFrameDetail(job, "0001-pass_1_preprocess"); DispatchFrame frame = frameDao.getDispatchFrame(frameDetail.id); frame.minCores = 250; frame.threadable = true; // Frame from a non-selfish sevice - VirtualProc proc = VirtualProc.build(host, frame, "something-else"); + VirtualProc proc = VirtualProc.build(host, frame, "something-else"); assertEquals(250, proc.coresReserved); // When no selfish service config is provided - proc = VirtualProc.build(host, frame); + proc = VirtualProc.build(host, frame); assertEquals(250, proc.coresReserved); - // Frame with a selfish service - proc = VirtualProc.build(host, frame, "shell", "something-else"); + // Frame with a selfish service + proc = VirtualProc.build(host, frame, "shell", "something-else"); assertEquals(800, proc.coresReserved); } } diff --git a/cuebot/src/test/java/com/imageworks/spcue/test/dispatcher/HostReportHandlerTests.java b/cuebot/src/test/java/com/imageworks/spcue/test/dispatcher/HostReportHandlerTests.java index b39b97ad0..40c83d68d 100644 --- a/cuebot/src/test/java/com/imageworks/spcue/test/dispatcher/HostReportHandlerTests.java +++ b/cuebot/src/test/java/com/imageworks/spcue/test/dispatcher/HostReportHandlerTests.java @@ -297,11 +297,11 @@ public void testHandleHostReportWithFullTemporaryDirectories() { * Precondition: * - HardwareState=UP * Action: - * - Receives a HostReport with less freeTempDir than the threshold + * - Receives a HostReport with less freeTempDir than the threshold * (opencue.properties: min_available_temp_storage_percentage) * Postcondition: * - Host hardwareState changes to REPAIR - * - A comment is created with subject=SUBJECT_COMMENT_FULL_TEMP_DIR and + * - A comment is created with subject=SUBJECT_COMMENT_FULL_TEMP_DIR and * user=CUEBOT_COMMENT_USER * */ // Create HostReport with totalMcp=4GB and freeMcp=128MB @@ -337,12 +337,12 @@ public void testHandleHostReportWithFullTemporaryDirectories() { /* * Test 2: - * Precondition: + * Precondition: * - HardwareState=REPAIR - * - There is a comment for the host with subject=SUBJECT_COMMENT_FULL_TEMP_DIR and + * - There is a comment for the host with subject=SUBJECT_COMMENT_FULL_TEMP_DIR and * user=CUEBOT_COMMENT_USER * Action: - * Receives a HostReport with more freeTempDir than the threshold + * Receives a HostReport with more freeTempDir than the threshold * (opencue.properties: min_available_temp_storage_percentage) * Postcondition: * - Host hardwareState changes to UP @@ -548,81 +548,94 @@ public void testMemoryAggressionMemoryWarning() { // Ok RunningFrameInfo info1 = RunningFrameInfo.newBuilder() - .setJobId(proc1.getJobId()) - .setLayerId(proc1.getLayerId()) - .setFrameId(proc1.getFrameId()) - .setResourceId(proc1.getProcId()) - .setVsize(CueUtil.GB2) - .setRss(CueUtil.GB2) - .setMaxRss(CueUtil.GB2) - .build(); + .setJobId(proc1.getJobId()) + .setLayerId(proc1.getLayerId()) + .setFrameId(proc1.getFrameId()) + .setResourceId(proc1.getProcId()) + .setUsedSwapMemory(CueUtil.MB512 - CueUtil.MB128) + .setVsize(CueUtil.GB2) + .setRss(CueUtil.GB2) + .setMaxRss(CueUtil.GB2) + .build(); // Overboard Rss RunningFrameInfo info2 = RunningFrameInfo.newBuilder() - .setJobId(proc2.getJobId()) - .setLayerId(proc2.getLayerId()) - .setFrameId(proc2.getFrameId()) - .setResourceId(proc2.getProcId()) - .setVsize(CueUtil.GB4) - .setRss(CueUtil.GB4) - .setMaxRss(CueUtil.GB4) - .build(); + .setJobId(proc2.getJobId()) + .setLayerId(proc2.getLayerId()) + .setFrameId(proc2.getFrameId()) + .setResourceId(proc2.getProcId()) + .setUsedSwapMemory(CueUtil.MB512) + .setVsize(CueUtil.GB4) + .setRss(CueUtil.GB4) + .setMaxRss(CueUtil.GB4) + .build(); // Overboard Rss long memoryUsedProc3 = CueUtil.GB8; RunningFrameInfo info3 = RunningFrameInfo.newBuilder() - .setJobId(proc3.getJobId()) - .setLayerId(proc3.getLayerId()) - .setFrameId(proc3.getFrameId()) - .setResourceId(proc3.getProcId()) - .setVsize(memoryUsedProc3) - .setRss(memoryUsedProc3) - .setMaxRss(memoryUsedProc3) - .build(); - - RenderHost hostAfterUpdate = getRenderHostBuilder(hostname).setFreeMem(0).build(); + .setJobId(proc3.getJobId()) + .setLayerId(proc3.getLayerId()) + .setFrameId(proc3.getFrameId()) + .setResourceId(proc3.getProcId()) + .setUsedSwapMemory(CueUtil.MB512 * 2) + .setVsize(memoryUsedProc3) + .setRss(memoryUsedProc3) + .setMaxRss(memoryUsedProc3) + .build(); + + RenderHost hostAfterUpdate = getRenderHostBuilder(hostname) + .setFreeMem(0) + .setFreeSwap(CueUtil.GB2 - + info1.getUsedSwapMemory() - + info2.getUsedSwapMemory() - + info3.getUsedSwapMemory()) + .build(); HostReport report = HostReport.newBuilder() - .setHost(hostAfterUpdate) - .setCoreInfo(getCoreDetail(200, 200, 0, 0)) - .addAllFrames(Arrays.asList(info1, info2, info3)) - .build(); + .setHost(hostAfterUpdate) + .setCoreInfo(getCoreDetail(200, 200, 0, 0)) + .addAllFrames(Arrays.asList(info1, info2, info3)) + .build(); // Get layer state before report gets sent LayerDetail layerBeforeIncrease = jobManager.getLayerDetail(proc3.getLayerId()); - // In this case, killing one job should be enough to ge the machine to a safe state + // In this case, killing 2 frames should be enough to ge the machine to a safe + // state. Total Swap: 2GB, usage before kill: 1944MB, usage after kill: 348 (less than 20%) long killCount = DispatchSupport.killedOffenderProcs.get(); hostReportHandler.handleHostReport(report, false); - assertEquals(killCount + 1, DispatchSupport.killedOffenderProcs.get()); + assertEquals(killCount + 2, DispatchSupport.killedOffenderProcs.get()); - // Confirm the frame will be set to retry after it's completion has been processed + // Confirm the frame will be set to retry after it's completion has been + // processed RunningFrameInfo runningFrame = RunningFrameInfo.newBuilder() - .setFrameId(proc3.getFrameId()) - .setFrameName("frame_name") - .setLayerId(proc3.getLayerId()) - .setRss(memoryUsedProc3) - .setMaxRss(memoryUsedProc3) - .setResourceId(proc3.id) - .build(); + .setFrameId(proc3.getFrameId()) + .setFrameName("frame_name") + .setLayerId(proc3.getLayerId()) + .setRss(memoryUsedProc3) + .setMaxRss(memoryUsedProc3) + .setResourceId(proc3.id) + .build(); FrameCompleteReport completeReport = FrameCompleteReport.newBuilder() - .setHost(hostAfterUpdate) - .setFrame(runningFrame) - .setExitSignal(9) - .setRunTime(1) - .setExitStatus(1) - .build(); + .setHost(hostAfterUpdate) + .setFrame(runningFrame) + .setExitSignal(9) + .setRunTime(1) + .setExitStatus(1) + .build(); frameCompleteHandler.handleFrameCompleteReport(completeReport); FrameDetail killedFrame = jobManager.getFrameDetail(proc3.getFrameId()); LayerDetail layer = jobManager.getLayerDetail(proc3.getLayerId()); assertEquals(FrameState.WAITING, killedFrame.state); - // Memory increases are processed in two different places one will set the new value to proc.reserved + 2GB - // and the other will set to the maximum reported proc.maxRss the end value will be whoever is higher. + // Memory increases are processed in two different places. + // First: proc.reserved + 2GB + // Second: the maximum reported proc.maxRss + // The higher valuer beween First and Second wins. // In this case, proc.maxRss assertEquals(Math.max(memoryUsedProc3, layerBeforeIncrease.getMinimumMemory() + CueUtil.GB2), - layer.getMinimumMemory()); + layer.getMinimumMemory()); } } diff --git a/cuebot/src/test/resources/opencue.properties b/cuebot/src/test/resources/opencue.properties index 47c7b8e31..cfaec991c 100644 --- a/cuebot/src/test/resources/opencue.properties +++ b/cuebot/src/test/resources/opencue.properties @@ -77,11 +77,12 @@ dispatcher.booking_queue.max_pool_size=6 dispatcher.booking_queue.queue_capacity=1000 dispatcher.min_available_temp_storage_percentage=20 dispatcher.min_bookable_free_mcp_kb=1048576 -dispatcher.oom_max_safe_used_memory_threshold=0.95 +dispatcher.oom_max_safe_used_physical_memory_threshold=0.9 +dispatcher.oom_max_safe_used_swap_memory_threshold=0.2 dispatcher.oom_frame_overboard_allowed_threshold=0.6 dispatcher.frame_kill_retry_limit=3 -# A comma separated list of services that should have their frames considered +# A comma separated list of services that should have their frames considered # selfish. A selfish frame will reserve all the available cores to avoid # having to share resources with other renders. dispatcher.frame.selfish.services=arnold,selfish-service \ No newline at end of file diff --git a/proto/report.proto b/proto/report.proto index 441f810c3..f9c56d5f4 100644 --- a/proto/report.proto +++ b/proto/report.proto @@ -101,6 +101,7 @@ message RunningFrameInfo { int64 max_used_gpu_memory = 16; // kB int64 used_gpu_memory = 17; // kB ChildrenProcStats children = 18; //additional data about the running frame's child processes + int64 used_swap_memory = 19; // kB }; message ChildrenProcStats { diff --git a/rqd/rqd/rqmachine.py b/rqd/rqd/rqmachine.py index 6874bd25b..f67b8955d 100644 --- a/rqd/rqd/rqmachine.py +++ b/rqd/rqd/rqmachine.py @@ -266,6 +266,8 @@ def rssUpdate(self, frames): # The time in jiffies the process started # after system boot. "start_time": statFields[21], + # Fetch swap usage + "swap": self._getProcSwap(pid), } # cmdline: p = psutil.Process(int(pid)) @@ -301,6 +303,7 @@ def rssUpdate(self, frames): session = str(frame.pid) rss = 0 vsize = 0 + swap = 0 pcpu = 0 # children pids share the same session id for pid, data in pids.items(): @@ -308,6 +311,7 @@ def rssUpdate(self, frames): try: rss += int(data["rss"]) vsize += int(data["vsize"]) + swap += int(data["swap"]) # jiffies used by this process, last two means that dead # children are counted @@ -343,6 +347,7 @@ def rssUpdate(self, frames): frame.childrenProcs[pid]['rss'] = childRss frame.childrenProcs[pid]['vsize'] = \ int(data["vsize"]) // 1024 + frame.childrenProcs[pid]['swap'] = swap // 1024 frame.childrenProcs[pid]['statm_rss'] = \ (int(data["statm_rss"]) \ * resource.getpagesize()) // 1024 @@ -355,6 +360,7 @@ def rssUpdate(self, frames): 'rss_page': int(data["rss"]), 'rss': (int(data["rss"]) * resource.getpagesize()) // 1024, 'vsize': int(data["vsize"]) // 1024, + 'swap': swap // 1024, 'state': data['state'], # statm reports in pages (~ 4kB) # same as VmRss in /proc/[pid]/status (in KB) @@ -373,9 +379,11 @@ def rssUpdate(self, frames): # convert bytes to KB rss = (rss * resource.getpagesize()) // 1024 vsize = int(vsize/1024) + swap = swap // 1024 frame.rss = rss frame.maxRss = max(rss, frame.maxRss) + frame.usedSwapMemory = swap if os.path.exists(frame.runFrame.log_dir_file): stat = os.stat(frame.runFrame.log_dir_file).st_mtime @@ -395,6 +403,21 @@ def rssUpdate(self, frames): except Exception as e: log.exception('Failure with rss update due to: %s', e) + def _getProcSwap(self, pid): + """Helper function to get swap memory used by a process""" + swap_used = 0 + try: + with open("/proc/%s/status" % pid, "r", encoding='utf-8') as statusFile: + for line in statusFile: + if line.startswith("VmSwap:"): + swap_used = int(line.split()[1]) + break + except FileNotFoundError: + log.info('Process %s terminated before swap info could be read.', pid) + except Exception as e: + log.warning('Failed to read swap usage for pid %s: %s', pid, e) + return swap_used + def getLoadAvg(self): """Returns average number of processes waiting to be served for the last 1 minute multiplied by 100.""" diff --git a/rqd/rqd/rqnetwork.py b/rqd/rqd/rqnetwork.py index fa1fb8060..de1b38475 100644 --- a/rqd/rqd/rqnetwork.py +++ b/rqd/rqd/rqnetwork.py @@ -71,6 +71,8 @@ def __init__(self, rqCore, runFrame): self.usedGpuMemory = 0 self.maxUsedGpuMemory = 0 + self.usedSwapMemory = 0 + self.realtime = 0 self.utime = 0 self.stime = 0 @@ -98,7 +100,8 @@ def runningFrameInfo(self): num_gpus=self.runFrame.num_gpus, max_used_gpu_memory=self.maxUsedGpuMemory, used_gpu_memory=self.usedGpuMemory, - children=self._serializeChildrenProcs() + children=self._serializeChildrenProcs(), + used_swap_memory=self.usedSwapMemory, ) return runningFrameInfo From ee1cc819f26d2b8c4e8de600cb22565f80696b50 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 12:11:15 -0700 Subject: [PATCH 06/40] [cuegui] Fix output viewer cmd format (#1498) The output cmd call was not handling well all types of cmd formats. The cmd string needs to be passed as an array to be properly interpreted by the subprocess. --- cuegui/cuegui/MenuActions.py | 4 ++-- cuegui/cuegui/Utils.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cuegui/cuegui/MenuActions.py b/cuegui/cuegui/MenuActions.py index 14bf267ac..ee2e15532 100644 --- a/cuegui/cuegui/MenuActions.py +++ b/cuegui/cuegui/MenuActions.py @@ -1215,7 +1215,7 @@ def eat(self, rpcObjects=None): def kill(self, rpcObjects=None): names = [frame.data.name for frame in self._getOnlyFrameObjects(rpcObjects)] if names: - if not cuegui.Utils.isPermissible(self._getSource(), self): + if not cuegui.Utils.isPermissible(self._getSource()): cuegui.Utils.showErrorMessageBox( AbstractActions.USER_INTERACTION_PERMISSIONS.format( "kill frames", @@ -1331,7 +1331,7 @@ def eatandmarkdone(self, rpcObjects=None): if frames: frameNames = [frame.data.name for frame in frames] #check permissions - if not cuegui.Utils.isPermissible(self._getSource(), self): + if not cuegui.Utils.isPermissible(self._getSource()): cuegui.Utils.showErrorMessageBox( AbstractActions.USER_INTERACTION_PERMISSIONS.format( "eat and mark done frames", diff --git a/cuegui/cuegui/Utils.py b/cuegui/cuegui/Utils.py index 916bba0ba..80163da8f 100644 --- a/cuegui/cuegui/Utils.py +++ b/cuegui/cuegui/Utils.py @@ -644,10 +644,9 @@ def launchViewerUsingPaths(paths, test_mode=False): # Launch viewer and inform user msg = 'Launching viewer: {0}'.format(cmd) if not test_mode: - QtGui.qApp.emit(QtCore.SIGNAL('status(PyQt_PyObject)'), msg) print(msg) try: - subprocess.check_call(cmd) + subprocess.check_call(cmd.split()) except subprocess.CalledProcessError as e: showErrorMessageBox(str(e), title='Error running Viewer command') except Exception as e: From 45e5730123e592145fb646efc7e531c62aa258b3 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 12:14:13 -0700 Subject: [PATCH 07/40] [cuegui] Fix the memory bar on MonitorHostTree (#1499) When new columns were added a while back, what used to be a progress bar for memory used ended up becoming just the value for memory used. This PR also renames some of the fields to disambiguate them. --------- Signed-off-by: Diego Tavares --- cuegui/cuegui/HostMonitorTree.py | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/cuegui/cuegui/HostMonitorTree.py b/cuegui/cuegui/HostMonitorTree.py index bff3d6ab2..a7c63147f 100644 --- a/cuegui/cuegui/HostMonitorTree.py +++ b/cuegui/cuegui/HostMonitorTree.py @@ -69,48 +69,49 @@ def __init__(self, parent): "indication that a frame is using more cores than it\n" "reserved. If all cores are reserved and the percentage\n" "is way below 100% then the cpu is underutilized.") - self.addColumn("Swap", 60, id=4, + self.addColumn("Swap", 70, id=4, data=lambda host: cuegui.Utils.memoryToString(host.data.free_swap), sort=lambda host: host.data.free_swap, delegate=cuegui.ItemDelegate.HostSwapBarDelegate, tip="The amount of used swap (red) vs available swap (green)") - self.addColumn("Memory", 60, id=5, + self.addColumn("Physical", 70, id=5, data=lambda host: cuegui.Utils.memoryToString(host.data.free_memory), sort=lambda host: host.data.free_memory, - tip="The amount of used memory (red) vs available gpu memory (green)") - self.addColumn("GPU Memory", 60, id=6, + delegate=cuegui.ItemDelegate.HostMemBarDelegate, + tip="The amount of used memory (red) vs available phys memory (green)") + self.addColumn("GPU Memory", 70, id=6, data=lambda host: cuegui.Utils.memoryToString(host.data.free_gpu_memory), sort=lambda host: host.data.free_gpu_memory, delegate=cuegui.ItemDelegate.HostGpuBarDelegate, tip="The amount of used gpu memory (red) vs available gpu memory (green)") - self.addColumn("freeMcp", 60, id=7, + self.addColumn("Total Memory", 60, id=7, + data=lambda host: cuegui.Utils.memoryToString(host.data.memory), + sort=lambda host: host.data.total_memory, + tip="The total amount of available memory.\n\n" + "Takes into consideration free memory and cached memory.") + self.addColumn("Idle Memory", 60, id=8, + data=lambda host: cuegui.Utils.memoryToString(host.data.idle_memory), + sort=lambda host: host.data.idle_memory, + tip="The amount of unreserved memory.") + self.addColumn("Temp available", 70, id=9, data=lambda host: cuegui.Utils.memoryToString(host.data.free_mcp), sort=lambda host: host.data.free_mcp, tip="The amount of free space in /mcp/") - self.addColumn("Cores", 45, id=8, + self.addColumn("Cores", 60, id=10, data=lambda host: "%.2f" % host.data.cores, sort=lambda host: host.data.cores, tip="The total number of cores.\n\n" "On a frame it is the number of cores reserved.") - self.addColumn("Idle", 40, id=9, + self.addColumn("Idle Cores", 60, id=11, data=lambda host: "%.2f" % host.data.idle_cores, sort=lambda host: host.data.idle_cores, tip="The number of cores that are not reserved.") - self.addColumn("Mem", 50, id=10, - data=lambda host: cuegui.Utils.memoryToString(host.data.memory), - sort=lambda host: host.data.memory, - tip="The total amount of reservable memory.\n\n" - "On a frame it is the amount of memory reserved.") - self.addColumn("Idle", 50, id=11, - data=lambda host: cuegui.Utils.memoryToString(host.data.idle_memory), - sort=lambda host: host.data.idle_memory, - tip="The amount of unreserved memory.") self.addColumn("GPUs", 50, id=12, data=lambda host: "%d" % host.data.gpus, sort=lambda host: host.data.gpus, tip="The total number of gpus.\n\n" "On a frame it is the number of gpus reserved.") - self.addColumn("Idle GPUs", 40, id=13, + self.addColumn("Idle GPUs", 50, id=13, data=lambda host: "%d" % host.data.idle_gpus, sort=lambda host: host.data.idle_gpus, tip="The number of gpus that are not reserved.") @@ -147,7 +148,10 @@ def __init__(self, parent): tip="A frame that runs on this host will:\n" "All: Use all cores.\n" "Auto: Use the number of cores as decided by the cuebot.\n") - self.addColumn("Tags/Job", 50, id=20, + self.addColumn("OS", 50, id=20, + data=lambda host: host.data.os, + tip="Host operational system or distro.") + self.addColumn("Tags/Job", 50, id=21, data=lambda host: ",".join(host.data.tags), tip="The tags applied to the host.\n\n" "On a frame it is the name of the job.") From 7ebecc0ae8144ddc2ea27778b2a20eb8591c8dc4 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 12:14:27 -0700 Subject: [PATCH 08/40] [cuegui] Remove constants test that is not testing constants (#1505) Remove the unit test `test__should_use_default_values` which is a recipe for problems when deploying cuegui. The test was supposed to confirm constants defaults are set properly, but the current implementation applies values from cuegui.yaml, which is supposed to be configured by users to match their local requirements. With this, running the unit test locally after configuring cuegui.yaml will always fail. The test itself didn't accomplish much, so I suggest removing it altogether. --- cuegui/tests/Constants_tests.py | 91 --------------------------------- 1 file changed, 91 deletions(-) diff --git a/cuegui/tests/Constants_tests.py b/cuegui/tests/Constants_tests.py index cfa88d062..a50379760 100644 --- a/cuegui/tests/Constants_tests.py +++ b/cuegui/tests/Constants_tests.py @@ -25,9 +25,7 @@ import mock import pyfakefs.fake_filesystem_unittest -from qtpy import QtGui -import opencue import cuegui.Constants @@ -74,95 +72,6 @@ def test__should_load_user_config_from_user_profile(self): self.assertEqual(30000, result.JOB_UPDATE_DELAY) self.assertEqual(10000, result.LAYER_UPDATE_DELAY) - @mock.patch('platform.system', new=mock.Mock(return_value='Linux')) - def test__should_use_default_values(self): - import cuegui.Constants - result = importlib.reload(cuegui.Constants) - - self.assertNotEqual('98.707.68', result.VERSION) - self.assertEqual(0, result.STARTUP_NOTICE_DATE) - self.assertEqual('', result.STARTUP_NOTICE_MSG) - self.assertEqual(10000, result.JOB_UPDATE_DELAY) - self.assertEqual(10000, result.LAYER_UPDATE_DELAY) - self.assertEqual(10000, result.FRAME_UPDATE_DELAY) - self.assertEqual(20000, result.HOST_UPDATE_DELAY) - self.assertEqual(1000, result.AFTER_ACTION_UPDATE_DELAY) - self.assertEqual(5, result.MINIMUM_UPDATE_INTERVAL) - self.assertEqual('Luxi Sans', result.FONT_FAMILY) - self.assertEqual(10, result.FONT_SIZE) - self.assertEqual( - os.path.join(os.path.dirname(cuegui.__file__), 'images'), result.RESOURCE_PATH) - self.assertEqual( - os.path.join(os.path.dirname(cuegui.__file__), 'config'), result.CONFIG_PATH) - self.assertEqual( - os.path.join(os.path.dirname(cuegui.__file__), 'config'), result.DEFAULT_INI_PATH) - self.assertEqual( - [os.path.join(os.path.dirname(cuegui.__file__), 'plugins')], - result.DEFAULT_PLUGIN_PATHS) - self.assertEqual('%(levelname)-9s %(module)-10s %(message)s', result.LOGGER_FORMAT) - self.assertEqual('WARNING', result.LOGGER_LEVEL) - self.assertEqual('cuemail: please check ', result.EMAIL_SUBJECT_PREFIX) - self.assertEqual('Your Support Team requests that you check:\n', result.EMAIL_BODY_PREFIX) - self.assertEqual('\n\n', result.EMAIL_BODY_SUFFIX) - self.assertEqual('', result.EMAIL_DOMAIN) - self.assertEqual( - 'https://github.com/AcademySoftwareFoundation/OpenCue/issues/new', - result.GITHUB_CREATE_ISSUE_URL) - self.assertEqual('https://www.opencue.io/docs/', result.URL_USERGUIDE) - self.assertEqual( - 'https://github.com/AcademySoftwareFoundation/OpenCue/issues/new' - '?labels=enhancement&template=enhancement.md', result.URL_SUGGESTION) - self.assertEqual( - 'https://github.com/AcademySoftwareFoundation/OpenCue/issues/new' - '?labels=bug&template=bug_report.md', result.URL_BUG) - self.assertEqual( - 'gview -R -m -M -U %s +' % os.path.join( - os.path.dirname(cuegui.__file__), 'config', 'gvimrc'), - result.DEFAULT_EDITOR) - self.assertEqual({ - 'rhel7': '/shots', - 'linux': '/shots', - 'windows': 'S:', - 'mac': '/Users/shots', - 'darwin': '/Users/shots', - }, result.LOG_ROOT_OS) - self.assertEqual(( - 'general', 'desktop', 'playblast', 'util', 'preprocess', 'wan', 'cuda', 'splathw', - 'naiad', 'massive'), result.ALLOWED_TAGS) - self.assertEqual( - os.path.join(os.path.dirname(cuegui.__file__), 'config', 'darkpalette.qss'), - result.DARK_STYLE_SHEET) - self.assertEqual('plastique', result.COLOR_THEME) - self.assertEqual(QtGui.QColor(50, 50, 100), result.COLOR_USER_1) - self.assertEqual(QtGui.QColor(100, 100, 50), result.COLOR_USER_2) - self.assertEqual(QtGui.QColor(0, 50, 0), result.COLOR_USER_3) - self.assertEqual(QtGui.QColor(50, 30, 00), result.COLOR_USER_4) - self.assertEqual({ - opencue.api.job_pb2.DEAD: QtGui.QColor(255, 0, 0), - opencue.api.job_pb2.DEPEND: QtGui.QColor(160, 32, 240), - opencue.api.job_pb2.EATEN: QtGui.QColor(150, 0, 0), - opencue.api.job_pb2.RUNNING: QtGui.QColor(200, 200, 55), - opencue.api.job_pb2.SETUP: QtGui.QColor(160, 32, 240), - opencue.api.job_pb2.SUCCEEDED: QtGui.QColor(55, 200, 55), - opencue.api.job_pb2.WAITING: QtGui.QColor(135, 207, 235), - opencue.api.job_pb2.CHECKPOINT: QtGui.QColor(61, 98, 247), - }, result.RGB_FRAME_STATE) - self.assertEqual(5242880, result.MEMORY_WARNING_LEVEL) - self.assertEqual( - ['error', 'aborted', 'fatal', 'failed', 'killed', 'command not found', - 'no licenses could be found', 'killMessage'], result.LOG_HIGHLIGHT_ERROR) - self.assertEqual(['warning', 'not found'], result.LOG_HIGHLIGHT_WARN) - self.assertEqual(['info:', 'rqd cmd:'], result.LOG_HIGHLIGHT_INFO) - self.assertEqual(2147483647, result.QT_MAX_INT) - self.assertEqual({ - 'max_cores': 32, - 'max_gpu_memory': 128, - 'max_gpus': 8, - 'max_memory': 128, - 'max_proc_hour_cutoff': 30, - 'redirect_wasted_cores_threshold': 100, - }, result.RESOURCE_LIMITS) - @mock.patch('platform.system', new=mock.Mock(return_value='Darwin')) def test__should_use_mac_editor(self): import cuegui.Constants From 7dbefee48677d89cda3bca7dd10f633da31e0dcf Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 12:14:35 -0700 Subject: [PATCH 09/40] [cuegui] Fix test_kill unit test to become user agnostic (#1506) The previous version of the test would only work if executed by root --- cuegui/tests/MenuActions_tests.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cuegui/tests/MenuActions_tests.py b/cuegui/tests/MenuActions_tests.py index 9bf3a74ed..81ce5c88a 100644 --- a/cuegui/tests/MenuActions_tests.py +++ b/cuegui/tests/MenuActions_tests.py @@ -22,6 +22,7 @@ import unittest +import getpass import mock import qtpy.QtGui import qtpy.QtWidgets @@ -975,10 +976,10 @@ def test_kill(self, isPermissibleMock, yesNoMock): frame = opencue.wrappers.frame.Frame(opencue.compiled_proto.job_pb2.Frame(name=frame_name)) self.frame_actions.kill(rpcObjects=[frame]) - + username = getpass.getuser() self.job.killFrames.assert_called_with( name=[frame_name], - reason="Manual Frame(s) Kill Request in Cuegui by root") + reason="Manual Frame(s) Kill Request in Cuegui by %s" % username) @mock.patch('cuegui.Utils.questionBoxYesNo', return_value=True) def test_markAsWaiting(self, yesNoMock): From 61a976f3aa24bcba2dd85b8f4f8dcf83859482cd Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 13:41:49 -0700 Subject: [PATCH 10/40] [rqd] Refactor rqd logging (#1504) Logging was not following the configured constants. Now there's also a way to deactivate file logging by not setting a level on the config file (rqd.conf) --- rqd/rqd/__main__.py | 47 +++++++++++++++++++++--------------------- rqd/rqd/rqconstants.py | 7 ++++--- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/rqd/rqd/__main__.py b/rqd/rqd/__main__.py index b444b0141..e17de155a 100755 --- a/rqd/rqd/__main__.py +++ b/rqd/rqd/__main__.py @@ -149,30 +149,30 @@ def daemonize(log_path=None, chdir_to_root=True): pass def setupLogging(): - """Sets up the logging for RQD. Logs to /var/log/messages""" - - consolehandler = logging.StreamHandler() - consolehandler.setLevel(rqd.rqconstants.CONSOLE_LOG_LEVEL) - consolehandler.setFormatter(logging.Formatter(rqd.rqconstants.LOG_FORMAT)) - logging.getLogger('').addHandler(consolehandler) - - if platform.system() in ('Linux', 'Darwin'): - if platform.system() == 'Linux': - syslogAddress = '/dev/log' - else: - syslogAddress = '/var/run/syslog' - if os.path.exists(syslogAddress): - logfile = logging.handlers.SysLogHandler(address=syslogAddress) + """Sets up the logging for RQD. + Logs to /var/log/messages""" + logger = logging.getLogger() + logger.setLevel(rqd.rqconstants.CONSOLE_LOG_LEVEL) + for handler in logger.handlers: + handler.setFormatter(logging.Formatter(rqd.rqconstants.LOG_FORMAT)) + + if rqd.rqconstants.FILE_LOG_LEVEL is not None: + if platform.system() in ('Linux', 'Darwin'): + if platform.system() == 'Linux': + syslogAddress = '/dev/log' + else: + syslogAddress = '/var/run/syslog' + if os.path.exists(syslogAddress): + logfile = logging.handlers.SysLogHandler(address=syslogAddress) + else: + logfile = logging.handlers.SysLogHandler() + elif platform.system() == 'Windows': + logfile = logging.FileHandler(os.path.expandvars('%TEMP%/openrqd.log')) else: logfile = logging.handlers.SysLogHandler() - elif platform.system() == 'Windows': - logfile = logging.FileHandler(os.path.expandvars('%TEMP%/openrqd.log')) - else: - logfile = logging.handlers.SysLogHandler() - logfile.setLevel(rqd.rqconstants.FILE_LOG_LEVEL) - logfile.setFormatter(logging.Formatter(rqd.rqconstants.LOG_FORMAT)) - logging.getLogger('').addHandler(logfile) - logging.getLogger('').setLevel(logging.DEBUG) + logfile.setLevel(rqd.rqconstants.FILE_LOG_LEVEL) + logfile.setFormatter(logging.Formatter(rqd.rqconstants.LOG_FORMAT)) + logger.addHandler(logfile) def setup_sentry(): @@ -210,6 +210,7 @@ def usage(): def main(): """Entrypoint for RQD.""" setupLogging() + logger = logging.getLogger() if platform.system() == 'Linux' and os.getuid() != 0 and \ rqd.rqconstants.RQD_BECOME_JOB_USER: @@ -234,7 +235,7 @@ def main(): rqd.rqutil.permissionsLow() - logging.warning('RQD Starting Up') + logger.warning('RQD Starting Up') setup_sentry() diff --git a/rqd/rqd/rqconstants.py b/rqd/rqd/rqconstants.py index 105685270..6f23ebc89 100644 --- a/rqd/rqd/rqconstants.py +++ b/rqd/rqd/rqconstants.py @@ -141,9 +141,10 @@ ALLOW_GPU = False LOAD_MODIFIER = 0 # amount to add/subtract from load -LOG_FORMAT = '%(asctime)s %(levelname)-9s openrqd-%(module)-10s %(message)s' -CONSOLE_LOG_LEVEL = logging.DEBUG -FILE_LOG_LEVEL = logging.WARNING # Equal to or greater than the consoleLevel +LOG_FORMAT = '%(levelname)-9s openrqd-%(module)-10s: %(message)s' +CONSOLE_LOG_LEVEL = logging.WARNING +# Equal to or greater than the consoleLevel. None deactives logging to file +FILE_LOG_LEVEL = None if subprocess.getoutput('/bin/su --help').find('session-command') != -1: SU_ARGUMENT = '--session-command' From 03960eab5be5e0b8b5f5e27fe7818f1a21d00164 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 13:41:57 -0700 Subject: [PATCH 11/40] [rqd] Fix permission issues when becoming a user (#1496) Changes implemented by #1416 impacted the locking mechanism for handling permissions on rqd, causing multiple threads to compete for permission settings and access to passwords. Besides fixing the bug, this PR also introduces a fix for a potential security issue that would allow frames to run as root if the frame user didn't exist and the process to create this user fails. --- rqd/rqd/rqcore.py | 24 +++++++++----------- rqd/rqd/rqutil.py | 57 ++++++++++++++++++++++++++--------------------- 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/rqd/rqd/rqcore.py b/rqd/rqd/rqcore.py index 51e0b90f0..51d669e14 100644 --- a/rqd/rqd/rqcore.py +++ b/rqd/rqd/rqcore.py @@ -295,10 +295,6 @@ def runLinux(self): self.__createEnvVariables() self.__writeHeader() - if rqd.rqconstants.RQD_CREATE_USER_IF_NOT_EXISTS: - rqd.rqutil.permissionsHigh() - rqd.rqutil.checkAndCreateUser(runFrame.user_name, runFrame.uid, runFrame.gid) - rqd.rqutil.permissionsLow() tempStatFile = "%srqd-stat-%s-%s" % (self.rqCore.machine.getTempPath(), frameInfo.frameId, @@ -508,17 +504,17 @@ def run(self): runFrame.log_dir_file = os.path.join(runFrame.log_dir, runFrame.log_file) try: # Exception block for all exceptions - - # Change to frame user if needed: - if runFrame.HasField("uid"): - # Do everything as launching user: - runFrame.gid = rqd.rqconstants.LAUNCH_FRAME_USER_GID - rqd.rqutil.permissionsUser(runFrame.uid, runFrame.gid) - + # Ensure permissions return to Low after this block try: - # - # Setup proc to allow launching of frame - # + if rqd.rqconstants.RQD_CREATE_USER_IF_NOT_EXISTS and runFrame.HasField("uid"): + rqd.rqutil.checkAndCreateUser(runFrame.user_name, + runFrame.uid, + runFrame.gid) + # Do everything as launching user: + runFrame.gid = rqd.rqconstants.LAUNCH_FRAME_USER_GID + rqd.rqutil.permissionsUser(runFrame.uid, runFrame.gid) + + # Setup frame logging try: self.rqlog = rqd.rqlogging.RqdLogger(runFrame.log_dir_file) self.rqlog.waitForFile() diff --git a/rqd/rqd/rqutil.py b/rqd/rqd/rqutil.py index df7c0bf30..3c11e75ff 100644 --- a/rqd/rqd/rqutil.py +++ b/rqd/rqd/rqutil.py @@ -79,14 +79,17 @@ def permissionsHigh(): """Sets the effective gid/uid to processes original values (root)""" if platform.system() == "Windows" or not rqd.rqconstants.RQD_BECOME_JOB_USER: return - with PERMISSIONS: - os.setegid(os.getgid()) - os.seteuid(os.getuid()) - try: - os.setgroups(HIGH_PERMISSION_GROUPS) - # pylint: disable=broad-except - except Exception: - pass + # PERMISSIONS gets locked here and unlocked at permissionsLow() + # therefore 'with' should not be used here + # pylint: disable=consider-using-with + PERMISSIONS.acquire() + os.setegid(os.getgid()) + os.seteuid(os.getuid()) + try: + os.setgroups(HIGH_PERMISSION_GROUPS) + # pylint: disable=broad-except + except Exception: + pass def permissionsLow(): @@ -114,10 +117,9 @@ def permissionsUser(uid, gid): groups = [20] + [g.gr_gid for g in grp.getgrall() if username in g.gr_mem] os.setgroups(groups) # pylint: disable=broad-except - except Exception: - pass - os.setegid(gid) - os.seteuid(uid) + finally: + os.setegid(gid) + os.seteuid(uid) def __becomeRoot(): @@ -134,24 +136,29 @@ def __becomeRoot(): def checkAndCreateUser(username, uid=None, gid=None): """Check to see if the provided user exists, if not attempt to create it.""" - # TODO(gregdenton): Add Windows and Mac support here. (Issue #61) - if not rqd.rqconstants.RQD_BECOME_JOB_USER: + if platform.system() == "Windows" or not rqd.rqconstants.RQD_BECOME_JOB_USER: return try: pwd.getpwnam(username) return except KeyError: - cmd = [ - 'useradd', - '-p', str(uuid.uuid4()), # generate a random password - ] - if uid: - cmd += ['-u', str(uid)] - if gid: - cmd += ['-g', str(gid)] - cmd.append(username) - log.info("Frame's username not found on host. Adding user with: %s", cmd) - subprocess.check_call(cmd) + # Multiple processes can be trying to access passwd, permissionHigh and + # permissionLow handle locking + permissionsHigh() + try: + cmd = [ + 'useradd', + '-p', str(uuid.uuid4()), # generate a random password + ] + if uid: + cmd += ['-u', str(uid)] + if gid: + cmd += ['-g', str(gid)] + cmd.append(username) + log.info("Frame's username not found on host. Adding user with: %s", cmd) + subprocess.check_call(cmd) + finally: + permissionsLow() def getHostIp(): From a62a393ae4b62b3d3bd10e5777ee11a809230a3e Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 6 Sep 2024 14:11:21 -0700 Subject: [PATCH 12/40] [cueadmin] Minor fixes to cueadmin (#1507) Fix issue when accessing pycue grpc objects and properly convert core values on displaySubscriptions --- cueadmin/cueadmin/common.py | 6 +++--- cueadmin/cueadmin/output.py | 11 +++++++---- cueadmin/tests/output_tests.py | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/cueadmin/cueadmin/common.py b/cueadmin/cueadmin/common.py index b80a76e12..237695cef 100644 --- a/cueadmin/cueadmin/common.py +++ b/cueadmin/cueadmin/common.py @@ -410,18 +410,18 @@ def dropAllDepends(job, layer=None, frame=None): logger.debug("dropping all depends on: %s/%04d-%s", job, layer, frame) depend_er_frame = opencue.api.findFrame(job, layer, frame) for depend in depend_er_frame.getWhatThisDependsOn(): - depend.proxy.satisfy() + depend.satisfy() elif layer: logger.debug("dropping all depends on: %s/%s", job, layer) depend_er_layer = opencue.api.findLayer(job, layer) for depend in depend_er_layer.getWhatThisDependsOn(): - depend.proxy.satisfy() + depend.satisfy() else: logger.debug("dropping all depends on: %s", job) depend_er_job = opencue.api.findJob(job) for depend in depend_er_job.getWhatThisDependsOn(): logger.debug("dropping depend %s %s", depend.data.type, opencue.id(depend)) - depend.proxy.satisfy() + depend.satisfy() class Convert(object): diff --git a/cueadmin/cueadmin/output.py b/cueadmin/cueadmin/output.py index 991a32b1f..2d338cb19 100644 --- a/cueadmin/cueadmin/output.py +++ b/cueadmin/cueadmin/output.py @@ -141,16 +141,19 @@ def displaySubscriptions(subscriptions, show): sub_format = "%-30s %-12s %6s %8s %8s %8s" print(sub_format % ("Allocation", "Show", "Size", "Burst", "Run", "Used")) for s in subscriptions: + size = s.data.size/100 + burst = s.data.burst/100 + run = s.data.reserved_cores/100 if s.data.size: perc = float(s.data.reserved_cores) / s.data.size * 100.0 else: - perc = s.data.reserved_cores * 100.0 + perc = run print(sub_format % (s.data.allocation_name, s.data.show_name, - s.data.size, - s.data.burst, - "%0.2f" % s.data.reserved_cores, + size, + burst, + "%0.2f" % run, "%0.2f%%" % perc)) diff --git a/cueadmin/tests/output_tests.py b/cueadmin/tests/output_tests.py index 86305ac74..616263faa 100644 --- a/cueadmin/tests/output_tests.py +++ b/cueadmin/tests/output_tests.py @@ -213,8 +213,8 @@ def testDisplaySubscriptions(self, getStubMock): self.assertEqual( 'Subscriptions for showName\n' 'Allocation Show Size Burst Run Used\n' - 'local.general showName 1000 1500 500.00 50.00%\n' - 'cloud.desktop showName 0 1500 50.00 5000.00%\n', + 'local.general showName 10.0 15.0 5.00 50.00%\n' + 'cloud.desktop showName 0.0 15.0 0.50 0.50%\n', out.getvalue()) def testDisplayJobs(self, getStubMock): From 0b60722053a19bdfadf9c7ffa3b798a536e6c915 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 10 Sep 2024 16:08:16 -0700 Subject: [PATCH 13/40] [cuegui] Fix inconsistencies on cuegui.yaml (#1495) Some fields were expected to be read as dictionaries and others as direct keys. email fields are now read directly and output_viewer fields are now treated as a dict. --------- Signed-off-by: Diego Tavares --- cuegui/cuegui/Constants.py | 10 +++++----- cuegui/cuegui/config/cuegui.yaml | 19 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index ae88cff73..ff45e85d4 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -183,11 +183,11 @@ def __packaged_version(): RESOURCE_LIMITS = __config.get('resources') -OUTPUT_VIEWER_ACTION_TEXT = __config.get('output_viewer.action_text') -OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = __config.get('output_viewer.extract_args_regex') -OUTPUT_VIEWER_CMD_PATTERN = __config.get('output_viewer.cmd_pattern') -OUTPUT_VIEWER_DIRECT_CMD_CALL = __config.get('output_viewer.direct_cmd_call') -OUTPUT_VIEWER_STEREO_MODIFIERS = __config.get('output_viewer.stereo_modifiers') +OUTPUT_VIEWER_ACTION_TEXT = __config.get('output_viewer', {}).get('action_text') +OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = __config.get('output_viewer', {}).get('extract_args_regex') +OUTPUT_VIEWER_CMD_PATTERN = __config.get('output_viewer', {}).get('cmd_pattern') +OUTPUT_VIEWER_DIRECT_CMD_CALL = __config.get('output_viewer', {}).get('direct_cmd_call') +OUTPUT_VIEWER_STEREO_MODIFIERS = __config.get('output_viewer', {}).get('stereo_modifiers') FINISHED_JOBS_READONLY_FRAME = __config.get('finished_jobs_readonly.frame', False) FINISHED_JOBS_READONLY_LAYER = __config.get('finished_jobs_readonly.layer', False) diff --git a/cuegui/cuegui/config/cuegui.yaml b/cuegui/cuegui/config/cuegui.yaml index 383a2917b..e210cfc20 100644 --- a/cuegui/cuegui/config/cuegui.yaml +++ b/cuegui/cuegui/config/cuegui.yaml @@ -101,16 +101,15 @@ links.issue.bug: '?labels=bug&template=bug_report.md' allowed_tags: ['general', 'desktop', 'playblast', 'util', 'preprocess', 'wan', 'cuda', 'splathw', 'naiad', 'massive'] -email: - subject_prefix: 'cuemail: please check ' - body_prefix: 'Your Support Team requests that you check ' - body_suffix: "\n\n" - domain: 'your.domain.com' - # A template for what email should be used for show support. - # - {domain} is required and will be replaced by email.domain - # - {show} is not required and will be replaced by the job show - # - Multiple addresses might be provided in a comma separated list - show_support_cc_template: "{show}-support@{domain}" +email.subject_prefix: 'cuemail: please check ' +email.body_prefix: 'Your Support Team requests that you check ' +email.body_suffix: "\n\n" +email.domain: 'your.domain.com' +# A template for what email should be used for show support. +# - {domain} is required and will be replaced by email.domain +# - {show} is not required and will be replaced by the job show +# - Multiple addresses might be provided in a comma-separated list +email.show_support_cc_template: "{show}-support@{domain}" # Unix epoch timestamp. If the user last viewed the startup notice before this time, the # notice will be shown. From 388255c5a33c5774e470b5f28eea8f4d6d08d226 Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Thu, 19 Sep 2024 09:54:41 -0700 Subject: [PATCH 14/40] Add a flag to search job with appended results (#1514) - Add a new configuration option `search_jobs.append_results` in cuegui.yaml to control job search behaviour. - If `search_jobs.append_results` is set to true, search results will be appended to the current list of monitored jobs. - If `search_jobs.append_results` is set to false, the existing jobs will be cleared before displaying the search results. - Updated Constants.py to include the new configuration parameter. - Modified MonitorJobsPlugin.py to conditionally clear the job monitor based on the config setting. --- cuegui/cuegui/Constants.py | 2 ++ cuegui/cuegui/config/cuegui.yaml | 5 +++++ cuegui/cuegui/plugins/MonitorJobsPlugin.py | 8 +++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index ff45e85d4..fef8da08f 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -195,6 +195,8 @@ def __packaged_version(): for action_type in __config.get('filter_dialog.disabled_action_types', "").split(",")] +SEARCH_JOBS_APPEND_RESULTS = __config.get('search_jobs.append_results', True) + TYPE_JOB = QtWidgets.QTreeWidgetItem.UserType + 1 TYPE_LAYER = QtWidgets.QTreeWidgetItem.UserType + 2 TYPE_FRAME = QtWidgets.QTreeWidgetItem.UserType + 3 diff --git a/cuegui/cuegui/config/cuegui.yaml b/cuegui/cuegui/config/cuegui.yaml index e210cfc20..ffb716b97 100644 --- a/cuegui/cuegui/config/cuegui.yaml +++ b/cuegui/cuegui/config/cuegui.yaml @@ -152,3 +152,8 @@ finished_jobs_readonly.layer: True # MOVE_JOB_TO_GROUP, SET_ALL_RENDER_LAYER_MAX_CORES) # An empty string means all actions are active. filter_dialog.disabled_action_types: '' + +# Define whether or not new jobs should be appended to the current list of jobs on the JobMonitor widget +# - True: search result will append jobs to the current list of monitored jobs +# - False: repopulate the jobs table with the search result +search_jobs.append_results: True diff --git a/cuegui/cuegui/plugins/MonitorJobsPlugin.py b/cuegui/cuegui/plugins/MonitorJobsPlugin.py index 00e217fbc..744603088 100644 --- a/cuegui/cuegui/plugins/MonitorJobsPlugin.py +++ b/cuegui/cuegui/plugins/MonitorJobsPlugin.py @@ -193,12 +193,14 @@ def _loadFinishedJobsSetup(self, layout): self.__loadFinishedJobsCheckBox.stateChanged.connect(self._regexLoadJobsHandle) # pylint: disable=no-member def _regexLoadJobsHandle(self): - """This will select all jobs that have a name that contain the substring - in self.__regexLoadJobsEditBox.text() and scroll to the first match""" + """This will select all jobs that have a name that contains the substring + in self.__regexLoadJobsEditBox.text() and scroll to the first match.""" substring = str(self.__regexLoadJobsEditBox.text()).strip() load_finished_jobs = self.__loadFinishedJobsCheckBox.isChecked() - self.jobMonitor.removeAllItems() + # Only clear the existing jobs if SEARCH_JOBS_APPEND_RESULTS is False + if not cuegui.Constants.SEARCH_JOBS_APPEND_RESULTS: + self.jobMonitor.removeAllItems() if substring: # Load job if a uuid is provided From da2fe011cb3a005df27a1ac053f6553619979266 Mon Sep 17 00:00:00 2001 From: Kern Attila GERMAIN <5556461+KernAttila@users.noreply.github.com> Date: Wed, 25 Sep 2024 20:31:01 +0200 Subject: [PATCH 15/40] Cuebot reserve all cores (#1313) **Link the Issue(s) this Pull Request is related to.** Fixes #1297 **Summarize your change.** As in many render engines, we should be able to set a negative core requirement. minCores=8 > reserve 8 cores minCores=0 > reserve all cores minCores=-2 > reserve all cores minus 2 This PR addresses this feature by handling negative core requests. Cuebot will try to match this number against the number of cores on each host. The frame will be booked only if all cores are available in this scenario. If the host is busy (even slightly), the frame is **not** booked, to avoid filling the remaining cores. **Testing** I would need some guidance to create proper tests for cuebot. **Screenshot** ![negative_cores](https://github.com/AcademySoftwareFoundation/OpenCue/assets/5556461/d9c4400c-824a-40cc-9ba9-2f76a3fd8ceb) Update: There is now a "ALL" text for zero cores, or "ALL (-2)" for negative cores reservation. ![core_reservation](https://github.com/user-attachments/assets/88802b15-3ccd-4cb5-90b7-58e532523ae6) (cuesubmit feature in another PR #1284) --------- Signed-off-by: Kern Attila GERMAIN <5556461+KernAttila@users.noreply.github.com> --- .../com/imageworks/spcue/DispatchHost.java | 48 ++++++++++++++++++- .../imageworks/spcue/LocalHostAssignment.java | 32 ++++++++++++- .../com/imageworks/spcue/SortableShow.java | 8 ++-- .../com/imageworks/spcue/VirtualProc.java | 18 ++++++- .../com/imageworks/spcue/dao/LayerDao.java | 27 ++++++----- .../spcue/dao/postgres/LayerDaoJdbc.java | 8 +++- .../spcue/dispatcher/CoreUnitDispatcher.java | 10 +++- .../spcue/dispatcher/HostReportHandler.java | 9 ++-- .../spcue/service/JobManagerService.java | 2 +- .../com/imageworks/spcue/service/JobSpec.java | 12 +++-- cuegui/cuegui/FilterDialog.py | 2 +- cuegui/cuegui/LayerMonitorTree.py | 14 +++++- cuesubmit/cuesubmit/Submission.py | 2 +- cuesubmit/cuesubmit/Validators.py | 2 +- cuesubmit/tests/Validators_tests.py | 1 + 15 files changed, 159 insertions(+), 36 deletions(-) diff --git a/cuebot/src/main/java/com/imageworks/spcue/DispatchHost.java b/cuebot/src/main/java/com/imageworks/spcue/DispatchHost.java index 495d0a9b1..f01724e17 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/DispatchHost.java +++ b/cuebot/src/main/java/com/imageworks/spcue/DispatchHost.java @@ -24,9 +24,14 @@ import com.imageworks.spcue.grpc.host.LockState; import com.imageworks.spcue.util.CueUtil; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; + public class DispatchHost extends Entity implements HostInterface, FacilityInterface, ResourceContainer { + private static final Logger logger = LogManager.getLogger(DispatchHost.class); + public String facilityId; public String allocationId; public LockState lockState; @@ -76,12 +81,53 @@ public String getFacilityId() { return facilityId; } + public boolean canHandleNegativeCoresRequest(int requestedCores) { + // Request is positive, no need to test further. + if (requestedCores > 0) { + logger.debug(getName() + " can handle the job with " + requestedCores + " cores."); + return true; + } + // All cores are available, validate the request. + if (cores == idleCores) { + logger.debug(getName() + " can handle the job with " + requestedCores + " cores."); + return true; + } + // Some or all cores are busy, avoid booking again. + logger.debug(getName() + " cannot handle the job with " + requestedCores + " cores."); + return false; + } + + public int handleNegativeCoresRequirement(int requestedCores) { + // If we request a <=0 amount of cores, return positive core count. + // Request -2 on a 24 core machine will return 22. + + if (requestedCores > 0) { + // Do not process positive core requests. + logger.debug("Requested " + requestedCores + " cores."); + return requestedCores; + } + if (requestedCores <=0 && idleCores < cores) { + // If request is negative but cores are already used, return 0. + // We don't want to overbook the host. + logger.debug("Requested " + requestedCores + " cores, but the host is busy and cannot book more jobs."); + return 0; + } + // Book all cores minus the request + int totalCores = idleCores + requestedCores; + logger.debug("Requested " + requestedCores + " cores <= 0, " + + idleCores + " cores are free, booking " + totalCores + " cores"); + return totalCores; + } + @Override public boolean hasAdditionalResources(int minCores, long minMemory, int minGpus, long minGpuMemory) { - + minCores = handleNegativeCoresRequirement(minCores); if (idleCores < minCores) { return false; } + if (minCores <= 0) { + return false; + } else if (idleMemory < minMemory) { return false; } diff --git a/cuebot/src/main/java/com/imageworks/spcue/LocalHostAssignment.java b/cuebot/src/main/java/com/imageworks/spcue/LocalHostAssignment.java index 3e073fa73..65ce05c7e 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/LocalHostAssignment.java +++ b/cuebot/src/main/java/com/imageworks/spcue/LocalHostAssignment.java @@ -22,6 +22,9 @@ import com.imageworks.spcue.dispatcher.ResourceContainer; import com.imageworks.spcue.grpc.renderpartition.RenderPartitionType; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; + /** * Contains information about local desktop cores a user has * assigned to the given job. @@ -33,6 +36,8 @@ public class LocalHostAssignment extends Entity implements ResourceContainer { + private static final Logger logger = LogManager.getLogger(LocalHostAssignment.class); + private int idleCoreUnits; private long idleMemory; private int idleGpuUnits; @@ -62,12 +67,37 @@ public LocalHostAssignment(int maxCores, int threads, long maxMemory, int maxGpu this.maxGpuMemory = maxGpuMemory; } + public int handleNegativeCoresRequirement(int requestedCores) { + // If we request a <=0 amount of cores, return positive core count. + // Request -2 on a 24 core machine will return 22. + + if (requestedCores > 0) { + // Do not process positive core requests. + logger.debug("Requested " + requestedCores + " cores."); + return requestedCores; + } + if (requestedCores <=0 && idleCoreUnits < threads) { + // If request is negative but cores are already used, return 0. + // We don't want to overbook the host. + logger.debug("Requested " + requestedCores + " cores, but the host is busy and cannot book more jobs."); + return 0; + } + // Book all cores minus the request + int totalCores = idleCoreUnits + requestedCores; + logger.debug("Requested " + requestedCores + " cores <= 0, " + + idleCoreUnits + " cores are free, booking " + totalCores + " cores"); + return totalCores; + } + @Override public boolean hasAdditionalResources(int minCores, long minMemory, int minGpus, long minGpuMemory) { - + minCores = handleNegativeCoresRequirement(minCores); if (idleCoreUnits < minCores) { return false; } + if (minCores <= 0) { + return false; + } else if (idleMemory < minMemory) { return false; } diff --git a/cuebot/src/main/java/com/imageworks/spcue/SortableShow.java b/cuebot/src/main/java/com/imageworks/spcue/SortableShow.java index f13fbaae2..83798f079 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/SortableShow.java +++ b/cuebot/src/main/java/com/imageworks/spcue/SortableShow.java @@ -54,12 +54,12 @@ public boolean isSkipped(String tags, long cores, long memory) { try { if (failed.containsKey(tags)) { long [] mark = failed.get(tags); - if (cores <= mark[0]) { - logger.info("skipped due to not enough cores " + cores + " <= " + mark[0]); + if (cores < mark[0]) { + logger.info("skipped due to not enough cores " + cores + " < " + mark[0]); return true; } - else if (memory <= mark[1]) { - logger.info("skipped due to not enough memory " + memory + " <= " + mark[1]); + else if (memory < mark[1]) { + logger.info("skipped due to not enough memory " + memory + " < " + mark[1]); return true; } } diff --git a/cuebot/src/main/java/com/imageworks/spcue/VirtualProc.java b/cuebot/src/main/java/com/imageworks/spcue/VirtualProc.java index ea0f5b98e..8205f3021 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/VirtualProc.java +++ b/cuebot/src/main/java/com/imageworks/spcue/VirtualProc.java @@ -22,8 +22,13 @@ import com.imageworks.spcue.dispatcher.Dispatcher; import com.imageworks.spcue.grpc.host.ThreadMode; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; + public class VirtualProc extends FrameEntity implements ProcInterface { + private static final Logger logger = LogManager.getLogger(VirtualProc.class); + public String hostId; public String allocationId; public String frameId; @@ -31,6 +36,7 @@ public class VirtualProc extends FrameEntity implements ProcInterface { public String os; public byte[] childProcesses; + public boolean canHandleNegativeCoresRequest; public int coresReserved; public long memoryReserved; public long memoryUsed; @@ -111,7 +117,17 @@ public static final VirtualProc build(DispatchHost host, DispatchFrame frame, St proc.coresReserved = proc.coresReserved + host.strandedCores; } - if (proc.coresReserved >= 100) { + proc.canHandleNegativeCoresRequest = host.canHandleNegativeCoresRequest(proc.coresReserved); + + if (proc.coresReserved == 0) { + logger.debug("Reserving all cores"); + proc.coresReserved = host.cores; + } + else if (proc.coresReserved < 0) { + logger.debug("Reserving all cores minus " + proc.coresReserved); + proc.coresReserved = host.cores + proc.coresReserved; + } + else if (proc.coresReserved >= 100) { int originalCores = proc.coresReserved; diff --git a/cuebot/src/main/java/com/imageworks/spcue/dao/LayerDao.java b/cuebot/src/main/java/com/imageworks/spcue/dao/LayerDao.java index 9343c3aa0..c4b07edf9 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dao/LayerDao.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dao/LayerDao.java @@ -59,7 +59,7 @@ public interface LayerDao { public List getLayerDetails(JobInterface job); /** - * Returns true if supplied layer is compelte. + * Returns true if supplied layer is complete. * * @param layer * @return boolean @@ -82,7 +82,7 @@ public interface LayerDao { void insertLayerDetail(LayerDetail l); /** - * gets a layer detail from an object that implments layer + * gets a layer detail from an object that implements layer * * @param layer * @return LayerDetail @@ -167,7 +167,7 @@ public interface LayerDao { void updateLayerTags(LayerInterface layer, Set tags); /** - * Insert a key/valye pair into the layer environment + * Insert a key/value pair into the layer environment * * @param layer * @param key @@ -292,7 +292,7 @@ public interface LayerDao { /** * Update all layers of the set type in the specified job - * with the new min cores requirement. + * with the new min gpu requirement. * * @param job * @param gpus @@ -304,9 +304,8 @@ public interface LayerDao { * Update a layer's max cores value, which limits how * much threading can go on. * - * @param job - * @param cores - * @param type + * @param layer + * @param threadable */ void updateThreadable(LayerInterface layer, boolean threadable); @@ -314,7 +313,7 @@ public interface LayerDao { * Update a layer's timeout value, which limits how * much the frame can run on a host. * - * @param job + * @param layer * @param timeout */ void updateTimeout(LayerInterface layer, int timeout); @@ -323,8 +322,8 @@ public interface LayerDao { * Update a layer's LLU timeout value, which limits how * much the frame can run on a host without updates in the log file. * - * @param job - * @param timeout + * @param layer + * @param timeout_llu */ void updateTimeoutLLU(LayerInterface layer, int timeout_llu); @@ -341,7 +340,7 @@ public interface LayerDao { /** * Appends a tag to the current set of tags. If the tag - * already exists than nothing happens. + * already exists then nothing happens. * * @param layer * @param val @@ -363,8 +362,9 @@ public interface LayerDao { * Update layer usage with processor time usage. * This happens when the proc has completed or failed some work. * - * @param proc + * @param layer * @param newState + * @param exitStatus */ void updateUsage(LayerInterface layer, ResourceUsage usage, int exitStatus); @@ -387,6 +387,9 @@ public interface LayerDao { /** * Enable/disable memory optimizer. + * + * @param layer + * @param state */ void enableMemoryOptimizer(LayerInterface layer, boolean state); diff --git a/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/LayerDaoJdbc.java b/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/LayerDaoJdbc.java index f555bef6e..78753f578 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/LayerDaoJdbc.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dao/postgres/LayerDaoJdbc.java @@ -51,8 +51,12 @@ import com.imageworks.spcue.util.CueUtil; import com.imageworks.spcue.util.SqlUtil; -public class LayerDaoJdbc extends JdbcDaoSupport implements LayerDao { +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; + +public class LayerDaoJdbc extends JdbcDaoSupport implements LayerDao { + private static final Logger logger = LogManager.getLogger(LayerDaoJdbc.class); private static final String INSERT_OUTPUT_PATH = "INSERT INTO " + "layer_output " + @@ -77,7 +81,7 @@ public void insertLayerOutput(LayerInterface layer, String filespec) { "FROM " + "layer_output " + "WHERE " + - "pk_layer = ?" + + "pk_layer = ? " + "ORDER BY " + "ser_order"; diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/CoreUnitDispatcher.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/CoreUnitDispatcher.java index e55a76865..226d9466c 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/CoreUnitDispatcher.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/CoreUnitDispatcher.java @@ -264,10 +264,16 @@ public List dispatchHost(DispatchHost host, JobInterface job) { VirtualProc proc = VirtualProc.build(host, frame, selfishServices); - if (host.idleCores < frame.minCores || + if (frame.minCores <= 0 && !proc.canHandleNegativeCoresRequest) { + logger.debug("Cannot dispatch job, host is busy."); + break; + } + + if (host.idleCores < host.handleNegativeCoresRequirement(frame.minCores) || host.idleMemory < frame.minMemory || host.idleGpus < frame.minGpus || host.idleGpuMemory < frame.minGpuMemory) { + logger.debug("Cannot dispatch, insufficient resources."); break; } @@ -283,6 +289,8 @@ public List dispatchHost(DispatchHost host, JobInterface job) { boolean success = new DispatchFrameTemplate(proc, job, frame, false) { public void wrapDispatchFrame() { + logger.debug("Dispatching frame with " + frame.minCores + " minCores on proc with " + + proc.coresReserved + " coresReserved"); dispatch(frame, proc); dispatchSummary(proc, frame, "Booking"); return; diff --git a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java index 46d56929f..b0a7ccd9c 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java +++ b/cuebot/src/main/java/com/imageworks/spcue/dispatcher/HostReportHandler.java @@ -245,6 +245,7 @@ public void handleHostReport(HostReport report, boolean isBoot) { */ String msg = null; boolean hasLocalJob = bookingManager.hasLocalHostAssignment(host); + int coresToReserve = host.handleNegativeCoresRequirement(Dispatcher.CORE_POINTS_RESERVED_MIN); if (hasLocalJob) { List lcas = @@ -253,13 +254,13 @@ public void handleHostReport(HostReport report, boolean isBoot) { bookingManager.removeInactiveLocalHostAssignment(lca); } } - + if (!isTempDirStorageEnough(report.getHost().getTotalMcp(), report.getHost().getFreeMcp(), host.os)) { msg = String.format( - "%s doens't have enough free space in the temporary directory (mcp), %dMB", + "%s doesn't have enough free space in the temporary directory (mcp), %dMB", host.name, (report.getHost().getFreeMcp()/1024)); } - else if (host.idleCores < Dispatcher.CORE_POINTS_RESERVED_MIN) { + else if (coresToReserve <= 0 || host.idleCores < Dispatcher.CORE_POINTS_RESERVED_MIN) { msg = String.format("%s doesn't have enough idle cores, %d needs %d", host.name, host.idleCores, Dispatcher.CORE_POINTS_RESERVED_MIN); } @@ -268,7 +269,7 @@ else if (host.idleMemory < Dispatcher.MEM_RESERVED_MIN) { host.name, host.idleMemory, Dispatcher.MEM_RESERVED_MIN); } else if (report.getHost().getFreeMem() < CueUtil.MB512) { - msg = String.format("%s doens't have enough free system mem, %d needs %d", + msg = String.format("%s doesn't have enough free system mem, %d needs %d", host.name, report.getHost().getFreeMem(), Dispatcher.MEM_RESERVED_MIN); } else if(!host.hardwareState.equals(HardwareState.UP)) { diff --git a/cuebot/src/main/java/com/imageworks/spcue/service/JobManagerService.java b/cuebot/src/main/java/com/imageworks/spcue/service/JobManagerService.java index 844f67635..2c9c14425 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/service/JobManagerService.java +++ b/cuebot/src/main/java/com/imageworks/spcue/service/JobManagerService.java @@ -274,7 +274,7 @@ public JobDetail createJob(BuildableJob buildableJob) { } } - if (layer.minimumCores < Dispatcher.CORE_POINTS_RESERVED_MIN) { + if (layer.minimumCores > 0 && layer.minimumCores < Dispatcher.CORE_POINTS_RESERVED_MIN) { layer.minimumCores = Dispatcher.CORE_POINTS_RESERVED_MIN; } diff --git a/cuebot/src/main/java/com/imageworks/spcue/service/JobSpec.java b/cuebot/src/main/java/com/imageworks/spcue/service/JobSpec.java index 7be581d1b..2e2fa0801 100644 --- a/cuebot/src/main/java/com/imageworks/spcue/service/JobSpec.java +++ b/cuebot/src/main/java/com/imageworks/spcue/service/JobSpec.java @@ -117,7 +117,7 @@ public class JobSpec { public JobSpec() { } - public static final String NAME_REGEX = "^([\\w\\.]{3,})$"; + public static final String NAME_REGEX = "^([\\w\\.-]{3,})$"; public static final Pattern NAME_PATTERN = Pattern.compile(NAME_REGEX); @@ -607,12 +607,16 @@ private void determineMinimumCores(Element layerTag, LayerDetail layer) { int corePoints = layer.minimumCores; if (cores.contains(".")) { - corePoints = (int) (Double.valueOf(cores) * 100 + .5); + if (cores.contains("-")) { + corePoints = (int) (Double.valueOf(cores) * 100 - .5); + } else { + corePoints = (int) (Double.valueOf(cores) * 100 + .5); + } } else { corePoints = Integer.valueOf(cores); } - if (corePoints < Dispatcher.CORE_POINTS_RESERVED_MIN) { + if (corePoints > 0 && corePoints < Dispatcher.CORE_POINTS_RESERVED_MIN) { corePoints = Dispatcher.CORE_POINTS_RESERVED_DEFAULT; } else if (corePoints > Dispatcher.CORE_POINTS_RESERVED_MAX) { @@ -649,7 +653,7 @@ private void determineChunkSize(Element layerTag, LayerDetail layer) { */ private void determineThreadable(Element layerTag, LayerDetail layer) { // Must have at least 1 core to thread. - if (layer.minimumCores < 100) { + if (layer.minimumCores > 0 && layer.minimumCores < 100) { layer.isThreadable = false; } else if (layerTag.getChildTextTrim("threadable") != null) { diff --git a/cuegui/cuegui/FilterDialog.py b/cuegui/cuegui/FilterDialog.py index 90c72e263..ab4d4d25b 100644 --- a/cuegui/cuegui/FilterDialog.py +++ b/cuegui/cuegui/FilterDialog.py @@ -459,7 +459,7 @@ def createAction(self): "Create Action", "What value should this property be set to?", 0, - 0, + -8, # Minimum core value can be <=0, booking all cores minus this value. 50000, 2) value = float(value) diff --git a/cuegui/cuegui/LayerMonitorTree.py b/cuegui/cuegui/LayerMonitorTree.py index 6ddd6cf18..0f110f874 100644 --- a/cuegui/cuegui/LayerMonitorTree.py +++ b/cuegui/cuegui/LayerMonitorTree.py @@ -67,10 +67,12 @@ def __init__(self, parent): data=lambda layer: displayRange(layer), tip="The range of frames that the layer should render.") self.addColumn("Cores", 45, id=6, - data=lambda layer: "%.2f" % layer.data.min_cores, + data=lambda layer: self.labelCoresColumn(layer.data.min_cores), sort=lambda layer: layer.data.min_cores, tip="The number of cores that the frames in this layer\n" - "will reserve as a minimum.") + "will reserve as a minimum." + "Zero or negative value indicate that the layer will use\n" + "all available cores on the machine, minus this value.") self.addColumn("Memory", 60, id=7, data=lambda layer: cuegui.Utils.memoryToString(layer.data.min_memory), sort=lambda layer: layer.data.min_memory, @@ -181,6 +183,14 @@ def updateRequest(self): since last updated""" self.ticksWithoutUpdate = 9999 + def labelCoresColumn(self, reserved_cores): + """Returns the reserved cores for a job""" + if reserved_cores > 0: + return "%.2f" % reserved_cores + if reserved_cores == 0: + return "ALL" + return "ALL (%.2f)" % reserved_cores + # pylint: disable=inconsistent-return-statements def setJob(self, job): """Sets the current job. diff --git a/cuesubmit/cuesubmit/Submission.py b/cuesubmit/cuesubmit/Submission.py index dc5e64d0f..f064b4cd3 100644 --- a/cuesubmit/cuesubmit/Submission.py +++ b/cuesubmit/cuesubmit/Submission.py @@ -97,7 +97,7 @@ def buildLayer(layerData, command, lastLayer=None): @type lastLayer: outline.layer.Layer @param lastLayer: layer that this new layer should be dependent on if dependType is set. """ - threadable = float(layerData.cores) >= 2 + threadable = float(layerData.cores) >= 2 or float(layerData.cores) <= 0 layer = outline.modules.shell.Shell( layerData.name, command=command.split(), chunk=layerData.chunk, threads=float(layerData.cores), range=str(layerData.layerRange), threadable=threadable) diff --git a/cuesubmit/cuesubmit/Validators.py b/cuesubmit/cuesubmit/Validators.py index 540f92e21..0b0bfb6f8 100644 --- a/cuesubmit/cuesubmit/Validators.py +++ b/cuesubmit/cuesubmit/Validators.py @@ -53,7 +53,7 @@ def matchNoSpaces(value): def matchNumbersOnly(value): """Matches strings with numbers and '.' only.""" - if re.match(r'^[0-9.]+$', value): + if re.match(r'^-?[0-9.]+$', value): return True return False diff --git a/cuesubmit/tests/Validators_tests.py b/cuesubmit/tests/Validators_tests.py index 0a5ef78eb..cbbf0b9cd 100644 --- a/cuesubmit/tests/Validators_tests.py +++ b/cuesubmit/tests/Validators_tests.py @@ -77,6 +77,7 @@ def testMatchNoSpaces(self): def testMatchNumbersOnly(self): self.assertTrue(matchNumbersOnly('0123')) self.assertTrue(matchNumbersOnly('3.14')) + self.assertTrue(matchNumbersOnly('-3.14')) # bit weird, but that's how the function is written self.assertTrue(matchNumbersOnly('800.555.555')) From 4e0ec9758f12fb6bf72c582aa5cfeabadca88355 Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Thu, 26 Sep 2024 10:00:01 -0700 Subject: [PATCH 16/40] [cuegui] Add dynamic version fetching for the CueGUI About menu (#1517) - Implemented new configuration options in cuegui.yaml: - `cuegui.use.custom.version`: Toggle between using VERSION.in or custom CueGUI version. - `cuegui.custom.cmd.version.stable`: Command to fetch stable version (`setenv OPENCUE_BETA 0`) - `cuegui.custom.cmd.version.beta`: Command to fetch beta version (`setenv OPENCUE_BETA 1`) - Modified Constants.py to fetch version dynamically based on config. - Updated MainWindows.py to display the correct version (stable or beta) in the About menu. - Fallback to VERSION.in if custom version is disabled. --------- Signed-off-by: Ramon Figueiredo --- cuegui/cuegui/Constants.py | 20 +++++++++++++++++++- cuegui/cuegui/MainWindow.py | 7 +++++++ cuegui/cuegui/config/cuegui.yaml | 11 +++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index fef8da08f..319b7e147 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -23,6 +23,7 @@ import logging import os +import subprocess import platform from qtpy import QtGui @@ -88,10 +89,27 @@ def __packaged_version(): return default_version return "1.3.0" +def __get_version_from_cmd(command): + try: + result = subprocess.run(command, shell=True, capture_output=True, text=True, check=True) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Command failed with return code {e.returncode}: {e}") + except Exception as e: + print(f"Failed to get version from command: {e}") + return None __config = __loadConfigFromFile() -VERSION = __config.get('version', __packaged_version()) +# Decide which CueGUI version to show +if __config.get('cuegui.use.custom.version', False): + beta_version = os.getenv('OPENCUE_BETA', '0') + if beta_version == '1': + VERSION = __get_version_from_cmd(__config.get('cuegui.custom.cmd.version.beta')) + else: + VERSION = __get_version_from_cmd(__config.get('cuegui.custom.cmd.version.stable')) +else: + VERSION = __config.get('version', __packaged_version()) STARTUP_NOTICE_DATE = __config.get('startup_notice.date') STARTUP_NOTICE_MSG = __config.get('startup_notice.msg') diff --git a/cuegui/cuegui/MainWindow.py b/cuegui/cuegui/MainWindow.py index 6b81cb307..d9db14424 100644 --- a/cuegui/cuegui/MainWindow.py +++ b/cuegui/cuegui/MainWindow.py @@ -120,6 +120,13 @@ def showStatusBarMessage(self, message, delay=5000): def displayAbout(self): """Displays about text.""" msg = self.app_name + "\n\nA opencue tool\n\n" + msg += "CueGUI:\n%s\n\n" % cuegui.Constants.VERSION + + if os.getenv('OPENCUE_BETA'): + msg += "(Beta Version)\n\n" + else: + msg += "(Stable Version)\n\n" + msg += "Qt:\n%s\n\n" % QtCore.qVersion() msg += "Python:\n%s\n\n" % sys.version QtWidgets.QMessageBox.about(self, "About", msg) diff --git a/cuegui/cuegui/config/cuegui.yaml b/cuegui/cuegui/config/cuegui.yaml index ffb716b97..381eedc94 100644 --- a/cuegui/cuegui/config/cuegui.yaml +++ b/cuegui/cuegui/config/cuegui.yaml @@ -1,5 +1,16 @@ # Default CueGUI config file +# Configure how a version number should be acquired. +# - False, use the version number in VERSION.in +# - True, run the commands defined at cuegui.custom.cmd.version.beta (for beta) or cuegui.custom.cmd.version.stable (for stable) to acquire the version number +cuegui.use.custom.version: False +# Used to show the CueGUI beta version, if the environment variable OPENCUE_BETA = 1 (setenv OPENCUE_BETA 1) +# If the CueGUI beta version is enabled, it's possible to run a shell command to get the cuegui version or any other +# custom command specified in the environment configuration. +cuegui.custom.cmd.version.beta: "echo 1.0.0" +# Used to show the CueGUI stable version, if the environment variable OPENCUE_BETA = 0 (setenv OPENCUE_BETA 0) +cuegui.custom.cmd.version.stable: "echo 1.0.1" + logger.format: '%(levelname)-9s %(module)-10s %(message)s' logger.level: 'WARNING' From 7e9ae6c20b951632a51ff490a336718647139cee Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 27 Sep 2024 13:39:59 -0700 Subject: [PATCH 17/40] Delete ci/test_pyside6.sh (#1509) The pyside6 test pipeline already runs as part of the test_python pipeline as the dependency is already part of the requirements_gui.txt file for python>3.10 --- .github/workflows/testing-pipeline.yml | 9 ----- .gitignore | 4 +++ ci/run_python_tests_pyside6.sh | 48 -------------------------- ci/test_pyside6.sh | 36 ------------------- 4 files changed, 4 insertions(+), 93 deletions(-) delete mode 100755 ci/run_python_tests_pyside6.sh delete mode 100755 ci/test_pyside6.sh diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index bcbbb7706..63f09afe2 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -74,15 +74,6 @@ jobs: chown -R aswfuser:aswfgroup . su -c "cd cuebot && ./gradlew build --stacktrace --info" aswfuser - test_pyside6: - name: Run CueGUI Tests using PySide6 - runs-on: ubuntu-latest - container: almalinux:9 - steps: - - uses: actions/checkout@v3 - - name: Run CueGUI Tests - run: ci/test_pyside6.sh - lint_python: name: Lint Python Code runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 370dbd6b1..ecdefd280 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ htmlcov/ .vscode .venv/ .eggs/* +.gradle/* +/cuebot/logs +/cuebot/bin +/logs \ No newline at end of file diff --git a/ci/run_python_tests_pyside6.sh b/ci/run_python_tests_pyside6.sh deleted file mode 100755 index 384841cfe..000000000 --- a/ci/run_python_tests_pyside6.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash - -# Script for running OpenCue unit tests with PySide6. -# -# This script is written to be run within the OpenCue GitHub Actions environment. -# See `.github/workflows/testing-pipeline.yml`. - -set -e - -python_version=$(python -V 2>&1) -echo "Will run tests using ${python_version}" - -# NOTE: To run this in an almalinux environment, install these packages: -# yum -y install \ -# dbus-libs \ -# fontconfig \ -# gcc \ -# libxkbcommon-x11 \ -# mesa-libEGL-devel \ -# python-devel \ -# which \ -# xcb-util-keysyms \ -# xcb-util-image \ -# xcb-util-renderutil \ -# xcb-util-wm \ -# Xvfb - -# Install Python requirements. -python3 -m pip install --user -r requirements.txt -r requirements_gui.txt -# Replace PySide2 with PySide6. -python3 -m pip uninstall -y PySide2 -python3 -m pip install --user PySide6==6.3.2 - -# Protos need to have their Python code generated in order for tests to pass. -python -m grpc_tools.protoc -I=proto/ --python_out=pycue/opencue/compiled_proto --grpc_python_out=pycue/opencue/compiled_proto proto/*.proto -python -m grpc_tools.protoc -I=proto/ --python_out=rqd/rqd/compiled_proto --grpc_python_out=rqd/rqd/compiled_proto proto/*.proto - -# Fix compiled proto code for Python 3. -2to3 -wn -f import pycue/opencue/compiled_proto/*_pb2*.py -2to3 -wn -f import rqd/rqd/compiled_proto/*_pb2*.py - -python pycue/setup.py test -PYTHONPATH=pycue python pyoutline/setup.py test -PYTHONPATH=pycue python cueadmin/setup.py test -PYTHONPATH=pycue:pyoutline python cuesubmit/setup.py test -python rqd/setup.py test - -ci/run_gui_test.sh diff --git a/ci/test_pyside6.sh b/ci/test_pyside6.sh deleted file mode 100755 index 05bd4c173..000000000 --- a/ci/test_pyside6.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Script for testing CueGUI with PySide6. -# -# This script is written to be run within an almalinux environment in the OpenCue -# GitHub Actions environment. See .github/workflows/testing-pipeline.yml. - -set -e - -# Install needed packages. -yum -y install \ - dbus-libs \ - fontconfig \ - gcc \ - libxkbcommon-x11 \ - mesa-libEGL-devel \ - python-devel \ - which \ - xcb-util-keysyms \ - xcb-util-image \ - xcb-util-renderutil \ - xcb-util-wm \ - Xvfb - -# Install Python requirements. -python3 -m pip install --user -r requirements.txt -r requirements_gui.txt -# Replace PySide2 with PySide6. -python3 -m pip uninstall -y PySide2 -python3 -m pip install --user PySide6==6.3.2 - -# Fix compiled proto code for Python 3. -python3 -m grpc_tools.protoc -I=proto/ --python_out=pycue/opencue/compiled_proto --grpc_python_out=pycue/opencue/compiled_proto proto/*.proto -2to3 -wn -f import pycue/opencue/compiled_proto/*_pb2*.py - -# Run tests. -ci/run_gui_test.sh From 8f7f23cd667415bf20b565465dbd8b0a1956285a Mon Sep 17 00:00:00 2001 From: Kern Attila GERMAIN <5556461+KernAttila@users.noreply.github.com> Date: Tue, 1 Oct 2024 22:23:14 +0200 Subject: [PATCH 18/40] [cuegui] Kill a Job from Windows. (#1520) **Link the Issue(s) this Pull Request is related to.** Fixes: https://github.com/AcademySoftwareFoundation/OpenCue/issues/1519 **Summarize your change.** Use `platform.uname()` instead of `os.uname()`. They have the same result on Linux, but `os.uname()` is not available on Windows, making us unable to kill a job from this OS. **Additional information.** Linux (ubuntu 22.04.6): ``` python >>> os.uname() posix.uname_result(sysname='Linux', nodename='titan', release='6.8.0-45-generic', version='#45~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 11 15:25:05 UTC 2', machine='x86_64') >>>platform.uname() uname_result(system='Linux', node='titan', release='6.8.0-45-generic', version='#45~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 11 15:25:05 UTC 2', machine='x86_64') ``` Windows (11): ``` python >>> platform.uname() uname_result(system='Windows', node='StarChaser', release='10', version='10.0.22631', machine='AMD64') ``` --- pycue/opencue/wrappers/frame.py | 3 ++- pycue/opencue/wrappers/job.py | 5 +++-- pycue/opencue/wrappers/layer.py | 3 ++- pycue/tests/wrappers/frame_test.py | 3 ++- pycue/tests/wrappers/job_test.py | 5 +++-- pycue/tests/wrappers/layer_test.py | 3 ++- rqd/rqd/rqmachine.py | 6 +++--- 7 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pycue/opencue/wrappers/frame.py b/pycue/opencue/wrappers/frame.py index 3b1d54d5d..d996d51f9 100644 --- a/pycue/opencue/wrappers/frame.py +++ b/pycue/opencue/wrappers/frame.py @@ -18,6 +18,7 @@ import getpass import time import os +import platform from opencue import Cuebot from opencue.compiled_proto import job_pb2 @@ -75,7 +76,7 @@ def kill(self, username=None, pid=None, host_kill=None, reason=None): """Kills the frame.""" username = username if username else getpass.getuser() pid = pid if pid else os.getpid() - host_kill = host_kill if host_kill else os.uname()[1] + host_kill = host_kill if host_kill else platform.uname()[1] if self.data.state == job_pb2.FrameState.Value('RUNNING'): self.stub.Kill(job_pb2.FrameKillRequest(frame=self.data, username=username, diff --git a/pycue/opencue/wrappers/job.py b/pycue/opencue/wrappers/job.py index 7e4228eb7..65572090c 100644 --- a/pycue/opencue/wrappers/job.py +++ b/pycue/opencue/wrappers/job.py @@ -17,6 +17,7 @@ import enum import getpass import os +import platform import time from opencue import Cuebot @@ -49,7 +50,7 @@ def kill(self, username=None, pid=None, host_kill=None, reason=None): """Kills the job.""" username = username if username else getpass.getuser() pid = pid if pid else os.getpid() - host_kill = host_kill if host_kill else os.uname()[1] + host_kill = host_kill if host_kill else platform.uname()[1] self.stub.Kill(job_pb2.JobKillRequest(job=self.data, username=username, pid=str(pid), @@ -73,7 +74,7 @@ def killFrames(self, username=None, pid=None, host_kill=None, reason=None, **req """ username = username if username else getpass.getuser() pid = pid if pid else os.getpid() - host_kill = host_kill if host_kill else os.uname()[1] + host_kill = host_kill if host_kill else platform.uname()[1] criteria = opencue.search.FrameSearch.criteriaFromOptions(**request) self.stub.KillFrames(job_pb2.JobKillFramesRequest(job=self.data, req=criteria, diff --git a/pycue/opencue/wrappers/layer.py b/pycue/opencue/wrappers/layer.py index 7a9782e0e..acb5ed8d5 100644 --- a/pycue/opencue/wrappers/layer.py +++ b/pycue/opencue/wrappers/layer.py @@ -17,6 +17,7 @@ import enum import getpass import os +import platform import opencue.api from opencue.compiled_proto import job_pb2 @@ -51,7 +52,7 @@ def kill(self, username=None, pid=None, host_kill=None, reason=None): """Kills the entire layer.""" username = username if username else getpass.getuser() pid = pid if pid else os.getpid() - host_kill = host_kill if host_kill else os.uname()[1] + host_kill = host_kill if host_kill else platform.uname()[1] return self.stub.KillFrames(job_pb2.LayerKillFramesRequest(layer=self.data, username=username, pid=str(pid), diff --git a/pycue/tests/wrappers/frame_test.py b/pycue/tests/wrappers/frame_test.py index f3590c345..890a91da7 100644 --- a/pycue/tests/wrappers/frame_test.py +++ b/pycue/tests/wrappers/frame_test.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import getpass import os +import platform import time import unittest @@ -60,7 +61,7 @@ def testKill(self, getStubMock): job_pb2.Frame(name=TEST_FRAME_NAME, state=job_pb2.RUNNING)) username = getpass.getuser() pid = os.getpid() - host_kill = os.uname()[1] + host_kill = platform.uname()[1] reason = "Frames Kill Request" frame.kill(username=username, pid=pid, host_kill=host_kill, reason=reason) diff --git a/pycue/tests/wrappers/job_test.py b/pycue/tests/wrappers/job_test.py index 1b9d2d420..544f4a497 100644 --- a/pycue/tests/wrappers/job_test.py +++ b/pycue/tests/wrappers/job_test.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import getpass import os +import platform import unittest import mock @@ -49,7 +50,7 @@ def testKill(self, getStubMock): job_pb2.Job(name=TEST_JOB_NAME)) username = getpass.getuser() pid = os.getpid() - host_kill = os.uname()[1] + host_kill = platform.uname()[1] reason = "Job Kill Request" job.kill(username=username, pid=pid, host_kill=host_kill, reason=reason) @@ -95,7 +96,7 @@ def testKillFrames(self, getStubMock): job_pb2.Job(name=TEST_JOB_NAME)) username = getpass.getuser() pid = os.getpid() - host_kill = os.uname()[1] + host_kill = platform.uname()[1] reason = "Job Kill Request" job.killFrames(range=frameRange, username=username, diff --git a/pycue/tests/wrappers/layer_test.py b/pycue/tests/wrappers/layer_test.py index 8b71047a0..51a360abd 100644 --- a/pycue/tests/wrappers/layer_test.py +++ b/pycue/tests/wrappers/layer_test.py @@ -21,6 +21,7 @@ from __future__ import absolute_import import getpass import os +import platform import unittest import mock @@ -49,7 +50,7 @@ def testKill(self, getStubMock): job_pb2.Layer(name=TEST_LAYER_NAME)) username = getpass.getuser() pid = os.getpid() - host_kill = os.uname()[1] + host_kill = platform.uname()[1] reason = "Frames Kill Request" layer.kill(username=username, pid=pid, host_kill=host_kill, reason=reason) diff --git a/rqd/rqd/rqmachine.py b/rqd/rqd/rqmachine.py index f67b8955d..fc7fa59ab 100644 --- a/rqd/rqd/rqmachine.py +++ b/rqd/rqd/rqmachine.py @@ -571,11 +571,11 @@ def __initMachineTags(self): self.__renderHost.tags.append("windows") return - if os.uname()[-1] in ("i386", "i686"): + if platform.uname()[-1] in ("i386", "i686"): self.__renderHost.tags.append("32bit") - elif os.uname()[-1] == "x86_64": + elif platform.uname()[-1] == "x86_64": self.__renderHost.tags.append("64bit") - self.__renderHost.tags.append(os.uname()[2].replace(".EL.spi", "").replace("smp", "")) + self.__renderHost.tags.append(platform.uname()[2].replace(".EL.spi", "").replace("smp", "")) def testInitMachineStats(self, pathCpuInfo): """Initializes machine stats outside of normal startup process. Used for testing.""" From 380dbfebd3689f6281d1989d1a6eb5eaef82cd4d Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Tue, 1 Oct 2024 22:27:17 +0200 Subject: [PATCH 19/40] [cuegui] Add support for multiple viewers (#1513) **Summarize your change.** This adds support for multiple output viewers. The `cuegui.yaml` option `output_viewer_direct_cmd_call` is still kept as a single viewer command. It also changes the way that single frames are resolved. Instead of a hardcoded padding of 4, it will use the FileSequence and FrameSet classes to resolve the frame path. Examples: ![image](https://github.com/user-attachments/assets/d6b2641e-5bc6-4f3f-813f-24cafcd3ce78) ![image](https://github.com/user-attachments/assets/10f2b801-e2da-411b-a214-f6c0a7f1edea) --- cuegui/cuegui/Constants.py | 11 +++--- cuegui/cuegui/FrameMonitorTree.py | 16 +++++++-- cuegui/cuegui/JobMonitorTree.py | 15 +++++--- cuegui/cuegui/LayerMonitorTree.py | 12 +++++-- cuegui/cuegui/MenuActions.py | 18 ---------- cuegui/cuegui/Utils.py | 57 ++++++++++++++++++++---------- cuegui/cuegui/config/cuegui.yaml | 13 +++---- cuegui/tests/Utils_tests.py | 40 ++++++++++++--------- pycue/FileSequence/FileSequence.py | 10 ++++-- pycue/opencue/wrappers/job.py | 9 +++++ 10 files changed, 127 insertions(+), 74 deletions(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index 319b7e147..1c1a85937 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -201,11 +201,12 @@ def __get_version_from_cmd(command): RESOURCE_LIMITS = __config.get('resources') -OUTPUT_VIEWER_ACTION_TEXT = __config.get('output_viewer', {}).get('action_text') -OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = __config.get('output_viewer', {}).get('extract_args_regex') -OUTPUT_VIEWER_CMD_PATTERN = __config.get('output_viewer', {}).get('cmd_pattern') -OUTPUT_VIEWER_DIRECT_CMD_CALL = __config.get('output_viewer', {}).get('direct_cmd_call') -OUTPUT_VIEWER_STEREO_MODIFIERS = __config.get('output_viewer', {}).get('stereo_modifiers') +OUTPUT_VIEWERS = [] +for viewer in __config.get('output_viewers', {}): + OUTPUT_VIEWERS.append(viewer) + +OUTPUT_VIEWER_DIRECT_CMD_CALL = __config.get('output_viewer_direct_cmd_call') + FINISHED_JOBS_READONLY_FRAME = __config.get('finished_jobs_readonly.frame', False) FINISHED_JOBS_READONLY_LAYER = __config.get('finished_jobs_readonly.layer', False) diff --git a/cuegui/cuegui/FrameMonitorTree.py b/cuegui/cuegui/FrameMonitorTree.py index 9f6baf2f8..9e29aa0b2 100644 --- a/cuegui/cuegui/FrameMonitorTree.py +++ b/cuegui/cuegui/FrameMonitorTree.py @@ -24,6 +24,7 @@ from builtins import map from builtins import object import datetime +import functools import glob import os import re @@ -912,8 +913,19 @@ def __init__(self, widget, filterSelectedLayersCallback, readonly=False): if bool(int(self.app.settings.value("AllowDeeding", 0))): self.__menuActions.frames().addAction(self, "useLocalCores") - if cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN: - self.__menuActions.frames().addAction(self, "viewOutput") + if cuegui.Constants.OUTPUT_VIEWERS: + job = widget.getJob() + outputPaths = [] + for frame in widget.selectedObjects(): + layer = job.getLayer(frame.layer()) + outputPaths.extend(cuegui.Utils.getOutputFromFrame(layer, frame)) + if outputPaths: + for viewer in cuegui.Constants.OUTPUT_VIEWERS: + self.addAction(viewer['action_text'], + functools.partial(cuegui.Utils.viewFramesOutput, + job, + widget.selectedObjects(), + viewer['action_text'])) if self.app.applicationName() == "CueCommander": self.__menuActions.frames().addAction(self, "viewHost") diff --git a/cuegui/cuegui/JobMonitorTree.py b/cuegui/cuegui/JobMonitorTree.py index 5b8897bba..901ae62e9 100644 --- a/cuegui/cuegui/JobMonitorTree.py +++ b/cuegui/cuegui/JobMonitorTree.py @@ -22,6 +22,7 @@ from future.utils import iteritems from builtins import map +import functools import time import pickle @@ -425,10 +426,16 @@ def contextMenuEvent(self, e): if bool(int(self.app.settings.value("AllowDeeding", 0))): self.__menuActions.jobs().addAction(menu, "useLocalCores") - if cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN: - viewer_action = self.__menuActions.jobs().addAction(menu, "viewOutput") - viewer_action.setDisabled(__count == 0) - viewer_action.setToolTip("Open Viewer for the selected items") + if cuegui.Constants.OUTPUT_VIEWERS: + job = __selectedObjects[0] + for viewer in cuegui.Constants.OUTPUT_VIEWERS: + viewer_menu = QtWidgets.QMenu(viewer['action_text'], self) + for layer in job.getLayers(): + viewer_menu.addAction(layer.name(), + functools.partial(cuegui.Utils.viewOutput, + [layer], + viewer['action_text'])) + menu.addMenu(viewer_menu) depend_menu = QtWidgets.QMenu("&Dependencies",self) self.__menuActions.jobs().addAction(depend_menu, "viewDepends") diff --git a/cuegui/cuegui/LayerMonitorTree.py b/cuegui/cuegui/LayerMonitorTree.py index 0f110f874..83b615f68 100644 --- a/cuegui/cuegui/LayerMonitorTree.py +++ b/cuegui/cuegui/LayerMonitorTree.py @@ -20,6 +20,7 @@ from __future__ import print_function from __future__ import division +import functools from qtpy import QtCore from qtpy import QtWidgets @@ -239,8 +240,15 @@ def contextMenuEvent(self, e): menu = QtWidgets.QMenu() self.__menuActions.layers().addAction(menu, "view") - if cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN: - self.__menuActions.layers().addAction(menu, "viewOutput") + + if (len(cuegui.Constants.OUTPUT_VIEWERS) > 0 + and sum(len(layer.getOutputPaths()) for layer in __selectedObjects) > 0): + for viewer in cuegui.Constants.OUTPUT_VIEWERS: + menu.addAction(viewer['action_text'], + functools.partial(cuegui.Utils.viewOutput, + __selectedObjects, + viewer['action_text'])) + depend_menu = QtWidgets.QMenu("&Dependencies", self) self.__menuActions.layers().addAction(depend_menu, "viewDepends") self.__menuActions.layers().addAction(depend_menu, "dependWizard") diff --git a/cuegui/cuegui/MenuActions.py b/cuegui/cuegui/MenuActions.py index ee2e15532..d19637ee3 100644 --- a/cuegui/cuegui/MenuActions.py +++ b/cuegui/cuegui/MenuActions.py @@ -234,12 +234,6 @@ def view(self, rpcObjects=None): for job in self._getOnlyJobObjects(rpcObjects): self.app.view_object.emit(job) - viewOutput_info = [cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT, None, "view"] - def viewOutput(self, rpcObjects=None): - jobs = self._getOnlyJobObjects(rpcObjects) - if jobs and cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT: - cuegui.Utils.viewOutput(jobs) - viewDepends_info = ["&View Dependencies...", None, "log"] def viewDepends(self, rpcObjects=None): @@ -938,12 +932,6 @@ def dependWizard(self, rpcObjects=None): if layers: cuegui.DependWizard.DependWizard(self._caller, [self._getSource()], layers=layers) - viewOutput_info = [cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT, None, "view"] - def viewOutput(self, rpcObjects=None): - layers = self._getOnlyLayerObjects(rpcObjects) - if layers and cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT: - cuegui.Utils.viewOutput(layers) - reorder_info = ["Reorder Frames...", None, "configure"] def reorder(self, rpcObjects=None): @@ -1134,12 +1122,6 @@ def viewDepends(self, rpcObjects=None): frames = self._getOnlyFrameObjects(rpcObjects) cuegui.DependDialog.DependDialog(frames[0], self._caller).show() - viewOutput_info = [cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT, None, "view"] - def viewOutput(self, rpcObjects=None): - frames = self._getOnlyFrameObjects(rpcObjects) - if frames and cuegui.Constants.OUTPUT_VIEWER_ACTION_TEXT: - cuegui.Utils.viewFramesOutput(self._getSource(), frames) - getWhatDependsOnThis_info = ["print getWhatDependsOnThis", None, "log"] def getWhatDependsOnThis(self, rpcObjects=None): diff --git a/cuegui/cuegui/Utils.py b/cuegui/cuegui/Utils.py index 80163da8f..b59789d4b 100644 --- a/cuegui/cuegui/Utils.py +++ b/cuegui/cuegui/Utils.py @@ -38,6 +38,8 @@ from qtpy import QtWidgets import six +import FileSequence + import opencue import opencue.wrappers.group @@ -543,11 +545,13 @@ def popupFrameXdiff(job, frame1, frame2, frame3 = None): # View output in viewer ################################################################################ -def viewOutput(items): +def viewOutput(items, actionText): """Views the output of a list of jobs or list of layers in viewer - @type items: list or list - @param items: List of jobs or list of layers to view the entire job's outputs""" + @type items: list or list + @param items: List of jobs or list of layers to view the entire job's outputs + @type actionText: String + @param actionText: String to identity which viewer to use""" if items and len(items) >= 1: paths = [] @@ -563,27 +567,40 @@ def viewOutput(items): raise Exception("The function expects a list of jobs or a list of layers") # Launch viewer using paths if paths exists and are valid - launchViewerUsingPaths(paths) + launchViewerUsingPaths(paths, actionText) -def viewFramesOutput(job, frames): +def viewFramesOutput(job, frames, actionText): """Views the output of a list of frames in viewer using the job's layer associated with the frames @type job: Job or None @param job: The job with the output to view. @type frames: list - @param frames: List of frames to view the entire job's outputs""" + @param frames: List of frames to view the entire job's outputs + @type actionText: String + @param actionText: String to identity which viewer to use""" + if frames and len(frames) >= 1: paths = [] - all_layers = { layer.data.name: layer for layer in job.getLayers() } + all_layers = { layer.name(): layer for layer in job.getLayers() } for frame in frames: - paths.extend(__getOutputFromFrame(all_layers[frame.data.layer_name], frame)) - launchViewerUsingPaths(paths) + paths.extend(getOutputFromFrame(all_layers[frame.layer()], frame)) + launchViewerUsingPaths(paths, actionText) + +def getViewer(actionText): + """Retrieves the viewer from cuegui.Constants.OUTPUT_VIEWERS using the actionText + + @type actionText: String + @param actionText: String to identity which viewer to use""" + for viewer in cuegui.Constants.OUTPUT_VIEWERS: + if viewer['action_text'] == actionText: + return viewer + return None -def launchViewerUsingPaths(paths, test_mode=False): +def launchViewerUsingPaths(paths, actionText, test_mode=False): """Launch viewer using paths if paths exists and are valid This function relies on the following constants that should be configured on the output_viewer section of the config file: @@ -591,7 +608,10 @@ def launchViewerUsingPaths(paths, test_mode=False): - OUTPUT_VIEWER_EXTRACT_ARGS_REGEX - OUTPUT_VIEWER_CMD_PATTERN @type paths: list - @param paths: List of paths""" + @param paths: List of paths + @type actionText: String + @param actionText: String to identity which viewer to use""" + viewer = getViewer(actionText) if not paths: if not test_mode: showErrorMessageBox( @@ -603,8 +623,8 @@ def launchViewerUsingPaths(paths, test_mode=False): # Stereo ouputs are usually differentiated by a modifier like _lf_ and _rt_, # the viewer should only be called with one of them if OUTPUT_VIEWER_STEREO_MODIFIERS # is set. - if cuegui.Constants.OUTPUT_VIEWER_STEREO_MODIFIERS: - stereo_modifiers = cuegui.Constants.OUTPUT_VIEWER_STEREO_MODIFIERS.split(",") + if 'stereo_modifiers' in viewer: + stereo_modifiers = viewer['stereo_modifiers'].split(",") if len(paths) == 2 and len(stereo_modifiers) == 2: unified_paths = [path.replace(stereo_modifiers[0].strip(), stereo_modifiers[1].strip()) @@ -617,8 +637,8 @@ def launchViewerUsingPaths(paths, test_mode=False): # should be the same as the quantity expected by cmd_pattern. # If no regex is provided, cmd_pattern is executed as it is sample_path = paths[0] - regexp = cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX - cmd_pattern = cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN + regexp = viewer.get('extract_args_regex') + cmd_pattern = viewer.get('cmd_pattern') joined_paths = " ".join(paths) # Default to the cmd + paths @@ -697,7 +717,7 @@ def __getOutputFromLayers(layers): return paths -def __getOutputFromFrame(layer, frame): +def getOutputFromFrame(layer, frame): """Returns the output paths from a single frame @type layer: Layer @@ -710,9 +730,8 @@ def __getOutputFromFrame(layer, frame): outputs = layer.getOutputPaths() if not outputs: return [] - main_output = __findMainOutputPath(outputs) - main_output = main_output.replace("#", "%04d" % frame.data.number) - return [main_output] + seq = FileSequence.FileSequence(__findMainOutputPath(outputs)) + return seq.getFileList(frameSet=FileSequence.FrameSet(str(frame.number()))) except IndexError: return [] diff --git a/cuegui/cuegui/config/cuegui.yaml b/cuegui/cuegui/config/cuegui.yaml index 381eedc94..529cb9e00 100644 --- a/cuegui/cuegui/config/cuegui.yaml +++ b/cuegui/cuegui/config/cuegui.yaml @@ -131,27 +131,28 @@ startup_notice.msg: '' # Memory usage above this level will be displayed in a different color. memory_warning_level: 5242880 -# Output Viewer config. +# Output Viewers config. # # ------------------------------------------------------------------------------------------------------ # Frame, Layer and Job objects have right click menu option for opening an output viewer # (eg. OpenRV) -# output_viewer: -# # Text to be displayed at the menu action button -# action_text: "View Output in Itview" +#output_viewers: +# # Text to be displayed at the menu action button +# - action_text: "View in OpenRV" # # extract_args_regex: Regex to extract arguments from the output path produced by a job/layer/frame # # cmd_pattern: Command pattern to be matched with the regex defined at extract_args_regex # # if extract_args_regex is not provided, cmd_pattern is called directly with paths as arguments # extract_args_regex: '/shots/(?P\w+)/(?Pshot\w+)/.*' # cmd_pattern: "env SHOW={show} SHOT={shot} COLOR_IO=/{show}/home/colorspaces.xml OCIO=/{show}/home/config.ocio openrv {paths}" -# # Pattern to call viewer cmd directly without extracting environment variables. Used for previewing frames -# direct_cmd_call: "openrv {paths}" # # if provided, paths containing any of the two values are considered the same output and only one # # of them will be passed to the viewer # stereo_modifiers: "_rt_,_lf_" # # ------------------------------------------------------------------------------------------------------ +# Pattern to call viewer cmd directly without extracting environment variables. Used for previewing frames +# output_viewer_direct_cmd_call: "openrv {paths}" + # These flags determine whether or not layers/frames will be readonly when job is finished. # If flags are set as true, layers/frames cannot be retried, eaten, edited dependency on, etc. # In order to toggle the same protection on cuebot's side, set flags in opencue.properties diff --git a/cuegui/tests/Utils_tests.py b/cuegui/tests/Utils_tests.py index 325f66732..b4a1d9715 100644 --- a/cuegui/tests/Utils_tests.py +++ b/cuegui/tests/Utils_tests.py @@ -85,55 +85,63 @@ def test_shouldReturnResourceLimitsFromYaml(self): class UtilsViewerTests(unittest.TestCase): def test_shouldLaunchViewerUsingEmptyPaths(self): # Test launching without empty paths - self.assertIsNone(cuegui.Utils.launchViewerUsingPaths([], test_mode=True)) + self.assertIsNone(cuegui.Utils.launchViewerUsingPaths([], "test", test_mode=True)) def test_shouldLaunchViewerUsingSimplePath(self): # Test launching without regexp - cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = None - cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN = 'echo' - + cuegui.Constants.OUTPUT_VIEWERS = [{"action_text": "test", + "extract_args_regex": None, + "cmd_pattern": 'echo'}] out = cuegui.Utils.launchViewerUsingPaths(["/shots/test_show/test_shot/something/else"], + "test", test_mode=True) self.assertEqual('echo /shots/test_show/test_shot/something/else', out) def test_shouldNotLaunchViewerUsingInvalidCombination(self): # Test launching with invalig regex and pattern combination - cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = \ - r'/shots/(?P\w+)/(?Pshot\w+)/.*' - cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN = \ - 'echo show={not_a_show}, shot={shot}' + cuegui.Constants.OUTPUT_VIEWERS = [ + {"action_text": "test", + "extract_args_regex": r'/shots/(?P\w+)/(?Pshot\w+)/.*', + "cmd_pattern": 'echo show={not_a_show}, shot={shot}'}] out = cuegui.Utils.launchViewerUsingPaths(["/shots/test_show/test_shot/something/else"], + "test", test_mode=True) self.assertIsNone(out) def test_shouldLaunchViewerUsingRegextAndPattern(self): # Test launching with valid regex and pattern - cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = \ - r'/shots/(?P\w+)/(?P\w+)/.*' - cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN = 'echo show={show}, shot={shot}' + cuegui.Constants.OUTPUT_VIEWERS = [ + {"action_text": "test", + "extract_args_regex": r'/shots/(?P\w+)/(?P\w+)/.*', + "cmd_pattern": 'echo show={show}, shot={shot}'}] out = cuegui.Utils.launchViewerUsingPaths(["/shots/test_show/test_shot/something/else"], + "test", test_mode=True) self.assertEqual('echo show=test_show, shot=test_shot', out) def test_shouldLaunchViewerUsingStereoPaths(self): # Test launching with stereo output - cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = None - cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN = 'echo' - cuegui.Constants.OUTPUT_VIEWER_STEREO_MODIFIERS = '_lf_,_rt_' + cuegui.Constants.OUTPUT_VIEWERS = [{"action_text": "test", + "extract_args_regex": None, + "cmd_pattern": 'echo', + "stereo_modifiers": '_lf_,_rt_'}] out = cuegui.Utils.launchViewerUsingPaths(["/test/something_lf_something", "/test/something_rt_something"], + "test", test_mode=True) self.assertEqual('echo /test/something_lf_something', out) def test_shouldLaunchViewerUsingMultiplePaths(self): # Test launching multiple outputs - cuegui.Constants.OUTPUT_VIEWER_EXTRACT_ARGS_REGEX = None - cuegui.Constants.OUTPUT_VIEWER_CMD_PATTERN = 'echo' + cuegui.Constants.OUTPUT_VIEWERS = [{"action_text": "test", + "extract_args_regex": None, + "cmd_pattern": 'echo'}] out = cuegui.Utils.launchViewerUsingPaths(["/test/something_1", "/test/something_2"], + "test", test_mode=True) self.assertEqual('echo /test/something_1 /test/something_2', out) diff --git a/pycue/FileSequence/FileSequence.py b/pycue/FileSequence/FileSequence.py index 61d86a55e..784df0502 100644 --- a/pycue/FileSequence/FileSequence.py +++ b/pycue/FileSequence/FileSequence.py @@ -90,8 +90,14 @@ def getFileList(self, frameSet=None): """ Returns the file list of the sequence """ filelist = [] paddingString = "%%0%dd" % self.getPadSize() - for frame in self.frameSet.getAll(): - if frameSet is None or (isinstance(frameSet, FrameSet) and frame in frameSet.getAll()): + if self.frameSet: + for frame in self.frameSet.getAll(): + if (frameSet is None or + (isinstance(frameSet, FrameSet) and frame in frameSet.getAll())): + framepath = self.getPrefix() + paddingString % frame + self.getSuffix() + filelist.append(framepath) + else: + for frame in frameSet.getAll(): framepath = self.getPrefix() + paddingString % frame + self.getSuffix() filelist.append(framepath) return filelist diff --git a/pycue/opencue/wrappers/job.py b/pycue/opencue/wrappers/job.py index 65572090c..e582a91bf 100644 --- a/pycue/opencue/wrappers/job.py +++ b/pycue/opencue/wrappers/job.py @@ -23,6 +23,7 @@ from opencue import Cuebot from opencue.compiled_proto import comment_pb2 from opencue.compiled_proto import job_pb2 +import opencue.api import opencue.search import opencue.wrappers.comment import opencue.wrappers.depend @@ -188,6 +189,14 @@ def getLayers(self): layerSeq = response.layers return [opencue.wrappers.layer.Layer(lyr) for lyr in layerSeq.layers] + def getLayer(self, layerName): + """ Returns the layer with the specified name + :type: layername: str + :rtype: opencue.wrappers.layer.Layer + :return: specific layer in the job + """ + return opencue.api.findLayer(self.name(), layerName) + def getFrames(self, **options): """Returns the list of up to 1000 frames from within the job. From 28090376c1144ce09b70ffa5b006731eec66cfc7 Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Tue, 1 Oct 2024 22:33:22 +0200 Subject: [PATCH 20/40] [rqd] Remove hardcoded MAIL and HOME rqd environment variables (#1511) **Link the Issue(s) this Pull Request is related to.** Fixes #420 **Summarize your change.** Removes hardcoded environment variables specific for SPI If the variables are still needed, they can be exposed for the job again by adding them to the `[UseHostEnvVar]` section of `rqd.conf` --- rqd/rqd/rqcore.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/rqd/rqd/rqcore.py b/rqd/rqd/rqcore.py index 51d669e14..a72d94c0a 100644 --- a/rqd/rqd/rqcore.py +++ b/rqd/rqd/rqcore.py @@ -98,10 +98,7 @@ def __createEnvVariables(self): self.frameEnv["CUE_GPU_MEMORY"] = str(self.rqCore.machine.getGpuMemoryFree()) self.frameEnv["SP_NOMYCSHRC"] = "1" - if platform.system() in ("Linux", "Darwin"): - self.frameEnv["MAIL"] = "/usr/mail/%s" % self.runFrame.user_name - self.frameEnv["HOME"] = "/net/homedirs/%s" % self.runFrame.user_name - elif platform.system() == "Windows": + if platform.system() == "Windows": for variable in ["SYSTEMROOT", "APPDATA", "TMP", "COMMONPROGRAMFILES", "SYSTEMDRIVE"]: if variable in os.environ: self.frameEnv[variable] = os.environ[variable] From 798bfa64dd1ba3d237ef2244d2db4418e37aed74 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 1 Oct 2024 13:59:51 -0700 Subject: [PATCH 21/40] Upgrade gradle to 7.6.2 and fix sonarqube pipeline (#1393) the `sonarqube` pipeline requires a newer version of the jvm and that's a good excuse for upgrading gradle on the project as the version we have currently is very old. --- .github/workflows/sonar-cloud-pipeline.yml | 9 +- cuebot/build.gradle | 128 ++++---- cuebot/gradle/wrapper/gradle-wrapper.jar | Bin 54413 -> 61624 bytes .../gradle/wrapper/gradle-wrapper.properties | 4 +- cuebot/gradlew | 292 +++++++++++------- cuebot/gradlew.bat | 56 ++-- cuebot/settings.gradle | 19 +- .../spcue/test/service/JobSpecTests.java | 3 +- 8 files changed, 295 insertions(+), 216 deletions(-) diff --git a/.github/workflows/sonar-cloud-pipeline.yml b/.github/workflows/sonar-cloud-pipeline.yml index 07574773f..569544f8c 100644 --- a/.github/workflows/sonar-cloud-pipeline.yml +++ b/.github/workflows/sonar-cloud-pipeline.yml @@ -31,7 +31,7 @@ jobs: analyze_cuebot: runs-on: ubuntu-latest - container: aswf/ci-opencue:2020 + container: aswf/ci-opencue:2024.1 name: Analyze Cuebot env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true @@ -48,5 +48,10 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | + sudo yum -y install java-17-openjdk.x86_64 + sudo yum -y install java-17-openjdk-devel.x86_64 + sudo alternatives --set java java-17-openjdk.x86_64 + sudo alternatives --set javac java-17-openjdk.x86_64 + sudo alternatives --set jre_openjdk java-17-openjdk.x86_64 chown -R aswfuser:aswfgroup . - su -c "cd cuebot && ./gradlew jacocoTestReport sonarqube -Dsonar.login=$(SONAR_TOKEN)" aswfuser + su -c "cd cuebot && ./gradlew jacocoTestReport sonar" aswfuser diff --git a/cuebot/build.gradle b/cuebot/build.gradle index 6ce51fb96..911e5b498 100644 --- a/cuebot/build.gradle +++ b/cuebot/build.gradle @@ -1,36 +1,19 @@ - -import org.gradle.api.tasks.testing.logging.TestExceptionFormat -import org.gradle.api.tasks.testing.logging.TestLogEvent - -buildscript { - repositories { - mavenCentral() - jcenter() - } - dependencies { - classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.8' - classpath 'org.springframework.boot:spring-boot-gradle-plugin:2.2.1.RELEASE' - classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.7.1' - } +plugins { + id 'java' + id('eclipse') + id('idea') + id('org.springframework.boot') version "2.2.1.RELEASE" + id('io.spring.dependency-management') version "1.1.4" + id('com.google.protobuf') version "0.9.1" + id('jacoco') + id('org.sonarqube') version "2.8" } -apply plugin: 'java' -apply plugin: 'eclipse' -apply plugin: 'idea' -apply plugin: 'org.springframework.boot' -apply plugin: 'io.spring.dependency-management' -apply plugin: 'com.google.protobuf' -apply plugin: 'jacoco' -apply plugin: 'org.sonarqube' - -sourceCompatibility = 1.8 -targetCompatibility = 1.8 - -ext { - activemqVersion = '5.12.0' -} +sourceCompatibility = 11 +targetCompatibility = 11 configurations { + testCompile compile.exclude module: 'spring-boot-starter-logging' } @@ -39,49 +22,54 @@ repositories { jcenter() } +def grpcVersion = '1.47.0' +def protobufVersion = '3.21.2' +def activemqVersion = '5.12.0' + // Spring dependency versions are managed by the io.spring.dependency-management plugin. // Appropriate versions will be pulled based on the spring boot version specified in the // spring-boot-gradle-plugin. dependencies { - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.6' - compile group: 'com.google.guava', name: 'guava', version: '26.0-android' - compile group: 'com.sun.mail', name: 'mailapi', version: '1.5.4' - compile group: 'commons-lang', name: 'commons-lang', version: '2.6' - compile group: 'io.grpc', name: 'grpc-all', version: '1.47.0' - compile group: 'org.apache.activemq', name: 'activemq-pool', version: activemqVersion - compile group: 'org.apache.velocity', name: 'velocity', version: '1.7' - compile group: 'org.jdom', name: 'jdom', version: '1.1.3' - compile group: 'org.springframework.boot', name: 'spring-boot-starter-jdbc' - compile group: 'org.springframework.boot', name: 'spring-boot-starter-web' - compile group: 'org.springframework', name: 'spring-context-support' - compile group: 'org.springframework', name: 'spring-jms' - compile group: 'org.quartz-scheduler', name: 'quartz', version: '2.2.1', { exclude group: 'c3p0', module: 'c3p0' } - compile group: 'org.postgresql', name: 'postgresql', version: '42.2.2' - compile group: 'com.google.protobuf', name: 'protobuf-java', version: '3.21.2' - compile group: 'org.apache.logging.log4j', name: 'log4j-api', version: '2.16.0' - compile group: 'org.apache.logging.log4j', name: 'log4j-core', version: '2.16.0' - compile group: 'org.apache.logging.log4j', name: 'log4j-slf4j-impl', version: '2.16.0' - compile group: 'io.sentry', name: 'sentry-log4j2', version: '7.11.0' - compile group: 'io.prometheus', name: 'simpleclient', version: '0.16.0' - compile group: 'io.prometheus', name: 'simpleclient_servlet', version: '0.16.0' + implementation group: 'com.google.code.gson', name: 'gson', version: '2.8.6' + implementation group: 'com.google.guava', name: 'guava', version: '26.0-android' + implementation group: 'com.sun.mail', name: 'mailapi', version: '1.5.4' + implementation group: 'commons-lang', name: 'commons-lang', version: '2.6' + implementation group: 'io.grpc', name: 'grpc-all', version: "${grpcVersion}" + implementation group: 'org.apache.activemq', name: 'activemq-pool', version: activemqVersion + implementation group: 'org.apache.velocity', name: 'velocity', version: '1.7' + implementation group: 'org.jdom', name: 'jdom', version: '1.1.3' + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-jdbc' + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-web' + implementation group: 'org.springframework', name: 'spring-context-support' + implementation group: 'org.springframework', name: 'spring-jms' + implementation group: 'org.quartz-scheduler', name: 'quartz', version: '2.2.1', { exclude group: 'c3p0', module: 'c3p0' } + implementation group: 'org.postgresql', name: 'postgresql', version: '42.2.2' + implementation group: 'com.google.protobuf', name: 'protobuf-java', version: "${protobufVersion}" + implementation group: 'log4j', name: 'log4j', version: '1.2.17' + implementation group: 'org.slf4j', name: 'slf4j-log4j12', version: '1.7.26' + implementation group: 'io.sentry', name: 'sentry-log4j2', version: '7.11.0' + implementation group: 'io.prometheus', name: 'simpleclient', version: '0.16.0' + implementation group: 'io.prometheus', name: 'simpleclient_servlet', version: '0.16.0' protobuf fileTree("../proto/") - testCompile group: 'junit', name: 'junit', version: '4.12' - testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test' - testCompile group: 'org.assertj', name: 'assertj-core', version: '3.8.0' - testCompile group: 'io.zonky.test', name: 'embedded-postgres', version: '1.3.1' - testCompile group: 'org.flywaydb', name: 'flyway-core', version: '5.2.0' + testImplementation group: 'junit', name: 'junit', version: '4.12' + testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test' + testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.8.0' + testImplementation group: 'io.zonky.test', name: 'embedded-postgres', version: '1.3.1' + testImplementation group: 'org.flywaydb', name: 'flyway-core', version: '5.2.0' // Use newer version of Postgres for tests: https://github.com/zonkyio/embedded-postgres/issues/78 implementation enforcedPlatform('io.zonky.test.postgres:embedded-postgres-binaries-bom:11.13.0') } compileJava { + dependsOn generateProto options.compilerArgs << "-Xlint:all" << "-Werror" } compileTestJava { + dependsOn generateProto options.compilerArgs << "-Xlint:all" << "-Werror" } @@ -93,7 +81,7 @@ protobuf { plugins { grpc { // Generate gRPC stubs. - artifact = 'io.grpc:protoc-gen-grpc-java:1.47.0' + artifact = "io.grpc:protoc-gen-grpc-java:${grpcVersion}" } } generateProtoTasks { @@ -118,12 +106,16 @@ sourceSets { } } +jar { + enabled = true +} + bootJar { baseName = 'cuebot' } jacoco { - toolVersion = "0.8.5" + toolVersion = "0.8.11" } jacocoTestReport { @@ -147,6 +139,7 @@ jacocoTestReport { sonarqube { properties { + property "sonar.java.source", "11" property "sonar.host.url", "https://sonarcloud.io" property "sonar.organization", "academysoftwarefoundation" property "sonar.projectKey", "AcademySoftwareFoundation_OpenCue_Cuebot" @@ -174,25 +167,10 @@ tasks.withType(AbstractArchiveTask) { reproducibleFileOrder = true } -tasks.withType(Test) { - // Configure logging when running Gradle with --info or --debug. +test { testLogging { - info { - // Don't show STANDARD_OUT messages, these clutter up the output - // and make it hard to find actual failures. - events TestLogEvent.FAILED - exceptionFormat TestExceptionFormat.FULL - showStandardStreams false - } - debug { - // Show everything. - events TestLogEvent.STARTED, - TestLogEvent.FAILED, - TestLogEvent.PASSED, - TestLogEvent.SKIPPED, - TestLogEvent.STANDARD_ERROR, - TestLogEvent.STANDARD_OUT - exceptionFormat TestExceptionFormat.FULL + testLogging { + exceptionFormat = 'full' } } } diff --git a/cuebot/gradle/wrapper/gradle-wrapper.jar b/cuebot/gradle/wrapper/gradle-wrapper.jar index 1948b9074f1016d15d505d185bc3f73deb82d8c8..afba109285af78dbd2a1d187e33ac4f87c76e392 100644 GIT binary patch literal 61624 zcmb6AV{~QRwml9f72CFLyJFk6ZKq;e729@pY}>YNR8p1vbMJH7ubt# zZR`2@zJD1Ad^Oa6Hk1{VlN1wGR-u;_dyt)+kddaNpM#U8qn@6eX;fldWZ6BspQIa= zoRXcQk)#ENJ`XiXJuK3q0$`Ap92QXrW00Yv7NOrc-8ljOOOIcj{J&cR{W`aIGXJ-` z`ez%Mf7qBi8JgIb{-35Oe>Zh^GIVe-b^5nULQhxRDZa)^4+98@`hUJe{J%R>|LYHA z4K3~Hjcp8_owGF{d~lZVKJ;kc48^OQ+`_2migWY?JqgW&))70RgSB6KY9+&wm<*8 z_{<;(c;5H|u}3{Y>y_<0Z59a)MIGK7wRMX0Nvo>feeJs+U?bt-++E8bu7 zh#_cwz0(4#RaT@xy14c7d<92q-Dd}Dt<*RS+$r0a^=LGCM{ny?rMFjhgxIG4>Hc~r zC$L?-FW0FZ((8@dsowXlQq}ja%DM{z&0kia*w7B*PQ`gLvPGS7M}$T&EPl8mew3In z0U$u}+bk?Vei{E$6dAYI8Tsze6A5wah?d(+fyP_5t4ytRXNktK&*JB!hRl07G62m_ zAt1nj(37{1p~L|m(Bsz3vE*usD`78QTgYIk zQ6BF14KLzsJTCqx&E!h>XP4)bya|{*G7&T$^hR0(bOWjUs2p0uw7xEjbz1FNSBCDb@^NIA z$qaq^0it^(#pFEmuGVS4&-r4(7HLmtT%_~Xhr-k8yp0`$N|y>#$Ao#zibzGi*UKzi zhaV#@e1{2@1Vn2iq}4J{1-ox;7K(-;Sk{3G2_EtV-D<)^Pk-G<6-vP{W}Yd>GLL zuOVrmN@KlD4f5sVMTs7c{ATcIGrv4@2umVI$r!xI8a?GN(R;?32n0NS(g@B8S00-=zzLn z%^Agl9eV(q&8UrK^~&$}{S(6-nEXnI8%|hoQ47P?I0Kd=woZ-pH==;jEg+QOfMSq~ zOu>&DkHsc{?o&M5`jyJBWbfoPBv9Y#70qvoHbZXOj*qRM(CQV=uX5KN+b>SQf-~a8 ziZg}@&XHHXkAUqr)Q{y`jNd7`1F8nm6}n}+_She>KO`VNlnu(&??!(i#$mKOpWpi1 z#WfWxi3L)bNRodhPM~~?!5{TrrBY_+nD?CIUupkwAPGz-P;QYc-DcUoCe`w(7)}|S zRvN)9ru8b)MoullmASwsgKQo1U6nsVAvo8iKnbaWydto4y?#-|kP^%e6m@L`88KyDrLH`=EDx*6>?r5~7Iv~I zr__%SximG(izLKSnbTlXa-ksH@R6rvBrBavt4)>o3$dgztLt4W=!3=O(*w7I+pHY2(P0QbTma+g#dXoD7N#?FaXNQ^I0*;jzvjM}%=+km`YtC%O#Alm| zqgORKSqk!#^~6whtLQASqiJ7*nq?38OJ3$u=Tp%Y`x^eYJtOqTzVkJ60b2t>TzdQ{I}!lEBxm}JSy7sy8DpDb zIqdT%PKf&Zy--T^c-;%mbDCxLrMWTVLW}c=DP2>Td74)-mLl|70)8hU??(2)I@Zyo z2i`q5oyA!!(2xV~gahuKl&L(@_3SP012#x(7P!1}6vNFFK5f*A1xF({JwxSFwA|TM z&1z}!*mZKcUA-v4QzLz&5wS$7=5{M@RAlx@RkJaA4nWVqsuuaW(eDh^LNPPkmM~Al zwxCe@*-^4!ky#iNv2NIIU$CS+UW%ziW0q@6HN3{eCYOUe;2P)C*M`Bt{~-mC%T3%# zEaf)lATO1;uF33x>Hr~YD0Ju*Syi!Jz+x3myVvU^-O>C*lFCKS&=Tuz@>&o?68aF& zBv<^ziPywPu#;WSlTkzdZ9`GWe7D8h<1-v0M*R@oYgS5jlPbgHcx)n2*+!+VcGlYh?;9Ngkg% z=MPD+`pXryN1T|%I7c?ZPLb3bqWr7 zU4bfG1y+?!bw)5Iq#8IqWN@G=Ru%Thxf)#=yL>^wZXSCC8we@>$hu=yrU;2=7>h;5 zvj_pYgKg2lKvNggl1ALnsz2IlcvL;q79buN5T3IhXuJvy@^crqWpB-5NOm{7UVfxmPJ>`?;Tn@qHzF+W!5W{8Z&ZAnDOquw6r4$bv*jM#5lc%3v|c~^ zdqo4LuxzkKhK4Q+JTK8tR_|i6O(x#N2N0Fy5)!_trK&cn9odQu#Vlh1K~7q|rE z61#!ZPZ+G&Y7hqmY;`{XeDbQexC2@oFWY)Nzg@lL3GeEVRxWQlx@0?Zt`PcP0iq@6 zLgc)p&s$;*K_;q0L(mQ8mKqOJSrq$aQYO-Hbssf3P=wC6CvTVHudzJH-Jgm&foBSy zx0=qu$w477lIHk);XhaUR!R-tQOZ;tjLXFH6;%0)8^IAc*MO>Q;J={We(0OHaogG0 zE_C@bXic&m?F7slFAB~x|n#>a^@u8lu;=!sqE*?vq zu4`(x!Jb4F#&3+jQ|ygldPjyYn#uCjNWR)%M3(L!?3C`miKT;~iv_)dll>Q6b+I&c zrlB04k&>mSYLR7-k{Od+lARt~3}Bv!LWY4>igJl!L5@;V21H6dNHIGr+qV551e@yL z`*SdKGPE^yF?FJ|`#L)RQ?LJ;8+={+|Cl<$*ZF@j^?$H%V;jqVqt#2B0yVr}Nry5R z5D?S9n+qB_yEqvdy9nFc+8WxK$XME$3ftSceLb+L(_id5MMc*hSrC;E1SaZYow%jh zPgo#1PKjE+1QB`Of|aNmX?}3TP;y6~0iN}TKi3b+yvGk;)X&i3mTnf9M zuv3qvhErosfZ%Pb-Q>|BEm5(j-RV6Zf^$icM=sC-5^6MnAvcE9xzH@FwnDeG0YU{J zi~Fq?=bi0;Ir=hfOJu8PxC)qjYW~cv^+74Hs#GmU%Cw6?3LUUHh|Yab`spoqh8F@_ zm4bCyiXPx-Cp4!JpI~w!ShPfJOXsy>f*|$@P8L8(oeh#~w z-2a4IOeckn6}_TQ+rgl_gLArS3|Ml(i<`*Lqv6rWh$(Z5ycTYD#Z*&-5mpa}a_zHt z6E`Ty-^L9RK-M*mN5AasoBhc|XWZ7=YRQSvG)3$v zgr&U_X`Ny0)IOZtX}e$wNUzTpD%iF7Rgf?nWoG2J@PsS-qK4OD!kJ?UfO+1|F*|Bo z1KU`qDA^;$0*4mUJ#{EPOm7)t#EdX=Yx1R2T&xlzzThfRC7eq@pX&%MO&2AZVO%zw zS;A{HtJiL=rfXDigS=NcWL-s>Rbv|=)7eDoOVnVI>DI_8x>{E>msC$kXsS}z?R6*x zi(yO`$WN)_F1$=18cbA^5|f`pZA+9DG_Zu8uW?rA9IxUXx^QCAp3Gk1MSdq zBZv;_$W>*-zLL)F>Vn`}ti1k!%6{Q=g!g1J*`KONL#)M{ZC*%QzsNRaL|uJcGB7jD zTbUe%T(_x`UtlM!Ntp&-qu!v|mPZGcJw$mdnanY3Uo>5{oiFOjDr!ZznKz}iWT#x& z?*#;H$`M0VC|a~1u_<(}WD>ogx(EvF6A6S8l0%9U<( zH||OBbh8Tnzz*#bV8&$d#AZNF$xF9F2{_B`^(zWNC}af(V~J+EZAbeC2%hjKz3V1C zj#%d%Gf(uyQ@0Y6CcP^CWkq`n+YR^W0`_qkDw333O<0FoO9()vP^!tZ{`0zsNQx~E zb&BcBU>GTP2svE2Tmd;~73mj!_*V8uL?ZLbx}{^l9+yvR5fas+w&0EpA?_g?i9@A$j*?LnmctPDQG|zJ`=EF}Vx8aMD^LrtMvpNIR*|RHA`ctK*sbG= zjN7Q)(|dGpC}$+nt~bupuKSyaiU}Ws{?Tha@$q}cJ;tvH>+MuPih+B4d$Zbq9$Y*U z)iA(-dK?Ov@uCDq48Zm%%t5uw1GrnxDm7*ITGCEF!2UjA`BqPRiUR`yNq^zz|A3wU zG(8DAnY-GW+PR2&7@In{Sla(XnMz5Rk^*5u4UvCiDQs@hvZXoiziv{6*i?fihVI|( zPrY8SOcOIh9-AzyJ*wF4hq%ojB&Abrf;4kX@^-p$mmhr}xxn#fVU?ydmD=21&S)s*v*^3E96(K1}J$6bi8pyUr-IU)p zcwa$&EAF$0Aj?4OYPcOwb-#qB=kCEDIV8%^0oa567_u6`9+XRhKaBup z2gwj*m#(}=5m24fBB#9cC?A$4CCBj7kanaYM&v754(b%Vl!gg&N)ZN_gO0mv(jM0# z>FC|FHi=FGlEt6Hk6H3!Yc|7+q{&t%(>3n#>#yx@*aS+bw)(2!WK#M0AUD~wID>yG z?&{p66jLvP1;!T7^^*_9F322wJB*O%TY2oek=sA%AUQT75VQ_iY9`H;ZNKFQELpZd z$~M`wm^Y>lZ8+F0_WCJ0T2td`bM+b`)h3YOV%&@o{C#|t&7haQfq#uJJP;81|2e+$ z|K#e~YTE87s+e0zCE2X$df`o$`8tQhmO?nqO?lOuTJ%GDv&-m_kP9X<5GCo1=?+LY z?!O^AUrRb~3F!k=H7Aae5W0V1{KlgH379eAPTwq=2+MlNcJ6NM+4ztXFTwI)g+)&Q7G4H%KH_(}1rq%+eIJ*3$?WwnZxPZ;EC=@`QS@|-I zyl+NYh&G>k%}GL}1;ap8buvF>x^yfR*d+4Vkg7S!aQ++_oNx6hLz6kKWi>pjWGO5k zlUZ45MbA=v(xf>Oeqhg8ctl56y{;uDG?A9Ga5aEzZB80BW6vo2Bz&O-}WAq>(PaV;*SX0=xXgI_SJ< zYR&5HyeY%IW}I>yKu^?W2$~S!pw?)wd4(#6;V|dVoa}13Oiz5Hs6zA zgICc;aoUt$>AjDmr0nCzeCReTuvdD1{NzD1wr*q@QqVW*Wi1zn;Yw1dSwLvTUwg#7 zpp~Czra7U~nSZZTjieZxiu~=}!xgV68(!UmQz@#w9#$0Vf@y%!{uN~w^~U_d_Aa&r zt2l>)H8-+gA;3xBk?ZV2Cq!L71;-tb%7A0FWziYwMT|#s_Ze_B>orZQWqDOZuT{|@ zX04D%y&8u@>bur&*<2??1KnaA7M%%gXV@C3YjipS4|cQH68OSYxC`P#ncvtB%gnEI z%fxRuH=d{L70?vHMi>~_lhJ@MC^u#H66=tx?8{HG;G2j$9@}ZDYUuTetwpvuqy}vW)kDmj^a|A%z(xs7yY2mU0#X2$un&MCirr|7 z%m?8+9aekm0x5hvBQ2J+>XeAdel$cy>J<6R3}*O^j{ObSk_Ucv$8a3_WPTd5I4HRT z(PKP5!{l*{lk_19@&{5C>TRV8_D~v*StN~Pm*(qRP+`1N12y{#w_fsXrtSt={0hJw zQ(PyWgA;;tBBDql#^2J(pnuv;fPn(H>^d<6BlI%00ylJZ?Evkh%=j2n+|VqTM~EUh zTx|IY)W;3{%x(O{X|$PS&x0?z#S2q-kW&G}7#D?p7!Q4V&NtA_DbF~v?cz6_l+t8e zoh1`dk;P-%$m(Ud?wnoZn0R=Ka$`tnZ|yQ-FN!?!9Wmb^b(R!s#b)oj9hs3$p%XX9DgQcZJE7B_dz0OEF6C zx|%jlqj0WG5K4`cVw!19doNY+(;SrR_txAlXxf#C`uz5H6#0D>SzG*t9!Fn|^8Z8; z1w$uiQzufUzvPCHXhGma>+O327SitsB1?Rn6|^F198AOx}! zfXg22Lm0x%=gRvXXx%WU2&R!p_{_1H^R`+fRO2LT%;He@yiekCz3%coJ=8+Xbc$mN zJ;J7*ED|yKWDK3CrD?v#VFj|l-cTgtn&lL`@;sMYaM1;d)VUHa1KSB5(I54sBErYp z>~4Jz41?Vt{`o7T`j=Se{-kgJBJG^MTJ}hT00H%U)pY-dy!M|6$v+-d(CkZH5wmo1 zc2RaU`p3_IJ^hf{g&c|^;)k3zXC0kF1>rUljSxd}Af$!@@R1fJWa4g5vF?S?8rg=Z z4_I!$dap>3l+o|fyYy(sX}f@Br4~%&&#Z~bEca!nMKV zgQSCVC!zw^j<61!7#T!RxC6KdoMNONcM5^Q;<#~K!Q?-#6SE16F*dZ;qv=`5 z(kF|n!QIVd*6BqRR8b8H>d~N@ab+1+{3dDVPVAo>{mAB#m&jX{usKkCg^a9Fef`tR z?M79j7hH*;iC$XM)#IVm&tUoDv!(#f=XsTA$)(ZE37!iu3Gkih5~^Vlx#<(M25gr@ zOkSw4{l}6xI(b0Gy#ywglot$GnF)P<FQt~9ge1>qp8Q^k;_Dm1X@Tc^{CwYb4v_ld}k5I$&u}avIDQ-D(_EP zhgdc{)5r_iTFiZ;Q)5Uq=U73lW%uYN=JLo#OS;B0B=;j>APk?|!t{f3grv0nv}Z%` zM%XJk^#R69iNm&*^0SV0s9&>cl1BroIw*t3R0()^ldAsq)kWcI=>~4!6fM#0!K%TS ziZH=H%7-f=#-2G_XmF$~Wl~Um%^9%AeNSk)*`RDl##y+s)$V`oDlnK@{y+#LNUJp1^(e89sed@BB z^W)sHm;A^9*RgQ;f(~MHK~bJRvzezWGr#@jYAlXIrCk_iiUfC_FBWyvKj2mBF=FI;9|?0_~=E<)qnjLg9k*Qd!_ zl}VuSJB%#M>`iZm*1U^SP1}rkkI};91IRpZw%Hb$tKmr6&H5~m?A7?+uFOSnf)j14 zJCYLOYdaRu>zO%5d+VeXa-Ai7{7Z}iTn%yyz7hsmo7E|{ z@+g9cBcI-MT~2f@WrY0dpaC=v{*lDPBDX}OXtJ|niu$xyit;tyX5N&3pgmCxq>7TP zcOb9%(TyvOSxtw%Y2+O&jg39&YuOtgzn`uk{INC}^Na_-V;63b#+*@NOBnU{lG5TS zbC+N-qt)u26lggGPcdrTn@m+m>bcrh?sG4b(BrtdIKq3W<%?WuQtEW0Z)#?c_Lzqj*DlZ zVUpEV3~mG#DN$I#JJp3xc8`9ex)1%Il7xKwrpJt)qtpq}DXqI=5~~N}N?0g*YwETZ z(NKJO5kzh?Os`BQ7HYaTl>sXVr!b8>(Wd&PU*3ivSn{;q`|@n*J~-3tbm;4WK>j3&}AEZ*`_!gJ3F4w~4{{PyLZklDqWo|X}D zbZU_{2E6^VTCg#+6yJt{QUhu}uMITs@sRwH0z5OqM>taO^(_+w1c ztQ?gvVPj<_F_=(ISaB~qML59HT;#c9x(;0vkCi2#Zp`;_r@+8QOV1Ey2RWm6{*J&9 zG(Dt$zF^7qYpo9Ne}ce5re^j|rvDo*DQ&1Be#Fvo#?m4mfFrNZb1#D4f`Lf(t_Fib zwxL3lx(Zp(XVRjo_ocElY#yS$LHb6yl;9;Ycm1|5y_praEcGUZxLhS%7?b&es2skI z9l!O)b%D=cXBa@v9;64f^Q9IV$xOkl;%cG6WLQ`_a7I`woHbEX&?6NJ9Yn&z+#^#! zc8;5=jt~Unn7!cQa$=a7xSp}zuz#Lc#Q3-e7*i`Xk5tx_+^M~!DlyBOwVEq3c(?`@ zZ_3qlTN{eHOwvNTCLOHjwg0%niFYm({LEfAieI+k;U2&uTD4J;Zg#s`k?lxyJN<$mK6>j?J4eOM@T*o?&l@LFG$Gs5f4R*p*V1RkTdCfv9KUfa< z{k;#JfA3XA5NQJziGd%DchDR*Dkld&t;6i9e2t7{hQPIG_uDXN1q0T;IFCmCcua-e z`o#=uS2_en206(TuB4g-!#=rziBTs%(-b1N%(Bl}ea#xKK9zzZGCo@<*i1ZoETjeC zJ)ll{$mpX7Eldxnjb1&cB6S=7v@EDCsmIOBWc$p^W*;C0i^Hc{q(_iaWtE{0qbLjxWlqBe%Y|A z>I|4)(5mx3VtwRBrano|P))JWybOHUyOY67zRst259tx;l(hbY@%Z`v8Pz^0Sw$?= zwSd^HLyL+$l&R+TDnbV_u+h{Z>n$)PMf*YGQ}1Df@Nr{#Gr+@|gKlnv?`s1rm^$1+ zic`WeKSH?{+E}0^#T<&@P;dFf;P5zCbuCOijADb}n^{k=>mBehDD6PtCrn5ZBhh2L zjF$TbzvnwT#AzGEG_Rg>W1NS{PxmL9Mf69*?YDeB*pK!&2PQ7!u6eJEHk5e(H~cnG zZQ?X_rtws!;Tod88j=aMaylLNJbgDoyzlBv0g{2VYRXObL=pn!n8+s1s2uTwtZc

YH!Z*ZaR%>WTVy8-(^h5J^1%NZ$@&_ZQ)3AeHlhL~=X9=fKPzFbZ;~cS**=W-LF1 z5F82SZ zG8QZAet|10U*jK*GVOA(iULStsUDMjhT$g5MRIc4b8)5q_a?ma-G+@xyNDk{pR*YH zjCXynm-fV`*;}%3=+zMj**wlCo6a{}*?;`*j%fU`t+3Korws%dsCXAANKkmVby*eJ z6`2%GB{+&`g2;snG`LM9S~>#^G|nZ|JMnWLgSmJ4!kB->uAEF0sVn6km@s=#_=d)y zzld%;gJY>ypQuE z!wgqqTSPxaUPoG%FQ()1hz(VHN@5sfnE68of>9BgGsQP|9$7j zGqN{nxZx4CD6ICwmXSv6&RD<-etQmbyTHIXn!Q+0{18=!p))>To8df$nCjycnW07Q zsma_}$tY#Xc&?#OK}-N`wPm)+2|&)9=9>YOXQYfaCI*cV1=TUl5({a@1wn#V?y0Yn z(3;3-@(QF|0PA}|w4hBWQbTItc$(^snj$36kz{pOx*f`l7V8`rZK}82pPRuy zxwE=~MlCwOLRC`y%q8SMh>3BUCjxLa;v{pFSdAc7m*7!}dtH`MuMLB)QC4B^Uh2_? zApl6z_VHU}=MAA9*g4v-P=7~3?Lu#ig)cRe90>@B?>})@X*+v&yT6FvUsO=p#n8p{ zFA6xNarPy0qJDO1BPBYk4~~LP0ykPV ztoz$i+QC%Ch%t}|i^(Rb9?$(@ijUc@w=3F1AM}OgFo1b89KzF6qJO~W52U_;R_MsB zfAC29BNUXpl!w&!dT^Zq<__Hr#w6q%qS1CJ#5Wrb*)2P1%h*DmZ?br)*)~$^TExX1 zL&{>xnM*sh=@IY)i?u5@;;k6+MLjx%m(qwDF3?K3p>-4c2fe(cIpKq#Lc~;#I#Wwz zywZ!^&|9#G7PM6tpgwA@3ev@Ev_w`ZZRs#VS4}<^>tfP*(uqLL65uSi9H!Gqd59C&=LSDo{;#@Isg3caF1X+4T}sL2B+Q zK*kO0?4F7%8mx3di$B~b&*t7y|{x%2BUg4kLFXt`FK;Vi(FIJ+!H zW;mjBrfZdNT>&dDfc4m$^f@k)mum{DioeYYJ|XKQynXl-IDs~1c(`w{*ih0-y_=t$ zaMDwAz>^CC;p*Iw+Hm}%6$GN49<(rembdFvb!ZyayLoqR*KBLc^OIA*t8CXur+_e0 z3`|y|!T>7+jdny7x@JHtV0CP1jI^)9){!s#{C>BcNc5#*hioZ>OfDv)&PAM!PTjS+ zy1gRZirf>YoGpgprd?M1k<;=SShCMn406J>>iRVnw9QxsR|_j5U{Ixr;X5n$ih+-=X0fo(Oga zB=uer9jc=mYY=tV-tAe@_d-{aj`oYS%CP@V3m6Y{)mZ5}b1wV<9{~$`qR9 zEzXo|ok?1fS?zneLA@_C(BAjE_Bv7Dl2s?=_?E9zO5R^TBg8Be~fpG?$9I; zDWLH9R9##?>ISN8s2^wj3B?qJxrSSlC6YB}Yee{D3Ex8@QFLZ&zPx-?0>;Cafcb-! zlGLr)wisd=C(F#4-0@~P-C&s%C}GvBhb^tTiL4Y_dsv@O;S56@?@t<)AXpqHx9V;3 zgB!NXwp`=%h9!L9dBn6R0M<~;(g*nvI`A@&K!B`CU3^FpRWvRi@Iom>LK!hEh8VjX z_dSw5nh-f#zIUDkKMq|BL+IO}HYJjMo=#_srx8cRAbu9bvr&WxggWvxbS_Ix|B}DE zk!*;&k#1BcinaD-w#E+PR_k8I_YOYNkoxw5!g&3WKx4{_Y6T&EV>NrnN9W*@OH+niSC0nd z#x*dm=f2Zm?6qhY3}Kurxl@}d(~ z<}?Mw+>%y3T{!i3d1%ig*`oIYK|Vi@8Z~*vxY%Od-N0+xqtJ*KGrqo*9GQ14WluUn z+%c+og=f0s6Mcf%r1Be#e}&>1n!!ZxnWZ`7@F9ymfVkuFL;m6M5t%6OrnK#*lofS{ z=2;WPobvGCu{(gy8|Mn(9}NV99Feps6r*6s&bg(5aNw$eE ztbYsrm0yS`UIJ?Kv-EpZT#76g76*hVNg)L#Hr7Q@L4sqHI;+q5P&H{GBo1$PYkr@z zFeVdcS?N1klRoBt4>fMnygNrDL!3e)k3`TXoa3#F#0SFP(Xx^cc)#e2+&z9F=6{qk z%33-*f6=+W@baq){!d_;ouVthV1PREX^ykCjD|%WUMnNA2GbA#329aEihLk~0!!}k z)SIEXz(;0lemIO{|JdO{6d|-9LePs~$}6vZ>`xYCD(ODG;OuwOe3jeN;|G$~ml%r* z%{@<9qDf8Vsw581v9y+)I4&te!6ZDJMYrQ*g4_xj!~pUu#er`@_bJ34Ioez)^055M$)LfC|i*2*3E zLB<`5*H#&~R*VLYlNMCXl~=9%o0IYJ$bY+|m-0OJ-}6c@3m<~C;;S~#@j-p?DBdr<><3Y92rW-kc2C$zhqwyq09;dc5;BAR#PPpZxqo-@e_s9*O`?w5 zMnLUs(2c-zw9Pl!2c#+9lFpmTR>P;SA#Id;+fo|g{*n&gLi}7`K)(=tcK|?qR4qNT z%aEsSCL0j9DN$j8g(a+{Z-qPMG&O)H0Y9!c*d?aN0tC&GqC+`%(IFY$ll~!_%<2pX zuD`w_l)*LTG%Qq3ZSDE)#dt-xp<+n=3&lPPzo}r2u~>f8)mbcdN6*r)_AaTYq%Scv zEdwzZw&6Ls8S~RTvMEfX{t@L4PtDi{o;|LyG>rc~Um3;x)rOOGL^Bmp0$TbvPgnwE zJEmZ>ktIfiJzdW5i{OSWZuQWd13tz#czek~&*?iZkVlLkgxyiy^M~|JH(?IB-*o6% zZT8+svJzcVjcE0UEkL_5$kNmdrkOl3-`eO#TwpTnj?xB}AlV2`ks_Ua9(sJ+ok|%b z=2n2rgF}hvVRHJLA@9TK4h#pLzw?A8u31&qbr~KA9;CS7aRf$^f1BZ5fsH2W8z}FU zC}Yq76IR%%g|4aNF9BLx6!^RMhv|JYtoZW&!7uOskGSGL+}_>L$@Jg2Vzugq-NJW7 zzD$7QK7cftU1z*Fxd@}wcK$n6mje}=C|W)tm?*V<<{;?8V9hdoi2NRm#~v^#bhwlc z5J5{cSRAUztxc6NH>Nwm4yR{(T>0x9%%VeU&<&n6^vFvZ{>V3RYJ_kC9zN(M(` zp?1PHN>f!-aLgvsbIp*oTZv4yWsXM2Q=C}>t7V(iX*N8{aoWphUJ^(n3k`pncUt&` ze+sYjo)>>=I?>X}1B*ZrxYu`|WD0J&RIb~ zPA_~u)?&`}JPwc1tu=OlKlJ3f!9HXa)KMb|2%^~;)fL>ZtycHQg`j1Vd^nu^XexYkcae@su zOhxk8ws&Eid_KAm_<}65zbgGNzwshR#yv&rQ8Ae<9;S^S}Dsk zubzo?l{0koX8~q*{uA%)wqy*Vqh4>_Os7PPh-maB1|eT-4 zK>*v3q}TBk1QlOF!113XOn(Kzzb5o4Dz@?q3aEb9%X5m{xV6yT{;*rnLCoI~BO&SM zXf=CHLI>kaSsRP2B{z_MgbD;R_yLnd>^1g`l;uXBw7|)+Q_<_rO!!VaU-O+j`u%zO z1>-N8OlHDJlAqi2#z@2yM|Dsc$(nc>%ZpuR&>}r(i^+qO+sKfg(Ggj9vL%hB6 zJ$8an-DbmKBK6u6oG7&-c0&QD#?JuDYKvL5pWXG{ztpq3BWF)e|7aF-(91xvKt047 zvR{G@KVKz$0qPNXK*gt*%qL-boz-*E;7LJXSyj3f$7;%5wj)2p8gvX}9o_u}A*Q|7 z)hjs?k`8EOxv1zahjg2PQDz5pYF3*Cr{%iUW3J+JU3P+l?n%CwV;`noa#3l@vd#6N zc#KD2J;5(Wd1BP)`!IM;L|(d9m*L8QP|M7W#S7SUF3O$GFnWvSZOwC_Aq~5!=1X+s z6;_M++j0F|x;HU6kufX-Ciy|du;T%2@hASD9(Z)OSVMsJg+=7SNTAjV<8MYN-zX5U zVp~|N&{|#Z)c6p?BEBBexg4Q((kcFwE`_U>ZQotiVrS-BAHKQLr87lpmwMCF_Co1M z`tQI{{7xotiN%Q~q{=Mj5*$!{aE4vi6aE$cyHJC@VvmemE4l_v1`b{)H4v7=l5+lm^ ztGs>1gnN(Vl+%VuwB+|4{bvdhCBRxGj3ady^ zLxL@AIA>h@eP|H41@b}u4R`s4yf9a2K!wGcGkzUe?!21Dk)%N6l+#MP&}B0%1Ar*~ zE^88}(mff~iKMPaF+UEp5xn(gavK(^9pvsUQT8V;v!iJt|7@&w+_va`(s_57#t?i6 zh$p!4?BzS9fZm+ui`276|I307lA-rKW$-y^lK#=>N|<-#?WPPNs86Iugsa&n{x%*2 zzL_%$#TmshCw&Yo$Ol?^|hy{=LYEUb|bMMY`n@#(~oegs-nF){0ppwee|b{ca)OXzS~01a%cg&^ zp;}mI0ir3zapNB)5%nF>Sd~gR1dBI!tDL z&m24z9sE%CEv*SZh1PT6+O`%|SG>x74(!d!2xNOt#C5@I6MnY%ij6rK3Y+%d7tr3&<^4XU-Npx{^`_e z9$-|@$t`}A`UqS&T?cd@-+-#V7n7tiZU!)tD8cFo4Sz=u65?f#7Yj}MDFu#RH_GUQ z{_-pKVEMAQ7ljrJ5Wxg4*0;h~vPUI+Ce(?={CTI&(RyX&GVY4XHs>Asxcp%B+Y9rK z5L$q94t+r3=M*~seA3BO$<0%^iaEb2K=c7((dIW$ggxdvnC$_gq~UWy?wljgA0Dwd`ZsyqOC>)UCn-qU5@~!f znAWKSZeKRaq#L$3W21fDCMXS;$X(C*YgL7zi8E|grQg%Jq8>YTqC#2~ys%Wnxu&;ZG<`uZ1L<53jf2yxYR3f0>a;%=$SYI@zUE*g7f)a{QH^<3F?%({Gg)yx^zsdJ3^J2 z#(!C3qmwx77*3#3asBA(jsL`86|OLB)j?`0hQIh>v;c2A@|$Yg>*f+iMatg8w#SmM z<;Y?!$L--h9vH+DL|Wr3lnfggMk*kyGH^8P48or4m%K^H-v~`cBteWvnN9port02u zF;120HE2WUDi@8?&Oha6$sB20(XPd3LhaT~dRR2_+)INDTPUQ9(-370t6a!rLKHkIA`#d-#WUcqK%pMcTs6iS2nD?hln+F-cQPUtTz2bZ zq+K`wtc1;ex_iz9?S4)>Fkb~bj0^VV?|`qe7W02H)BiibE9=_N8=(5hQK7;(`v7E5Mi3o? z>J_)L`z(m(27_&+89P?DU|6f9J*~Ih#6FWawk`HU1bPWfdF?02aY!YSo_!v$`&W znzH~kY)ll^F07=UNo|h;ZG2aJ<5W~o7?*${(XZ9zP0tTCg5h-dNPIM=*x@KO>a|Bk zO13Cbnbn7+_Kj=EEMJh4{DW<))H!3)vcn?_%WgRy=FpIkVW>NuV`knP`VjT78dqzT z>~ay~f!F?`key$EWbp$+w$8gR1RHR}>wA8|l9rl7jsT+>sQLqs{aITUW{US&p{Y)O zRojdm|7yoA_U+`FkQkS?$4$uf&S52kOuUaJT9lP@LEqjKDM)iqp9aKNlkpMyJ76eb zAa%9G{YUTXa4c|UE>?CCv(x1X3ebjXuL&9Dun1WTlw@Wltn3zTareM)uOKs$5>0tR zDA~&tM~J~-YXA<)&H(ud)JyFm+ds_{O+qS*Swr$(CZQFM3vTfV8cH!1(-P@--Zui5A^)hFym@(GKIWqJAzx)Tw<$pXr zDBD>6f7(yo$`cAd>OdaX1c`onesK7^;4pFt@Ss#U;QF}vc}mD?LG`*$Vnur=Mj>g^ zak^JJ+M)=tWGKGgYAjtSHk-{;G&L9562Txj0@_WdosHI+vz}60(i`7D-e7u=tt^9a zOS2*MtQygcWA*8~ffCUQC53I6Lo5Kzml88!`yu>)iOy1BT$6zS-+?w*H%TN@CPdZs zyw>a^+Y6|mQsO5xO>D*}l8dy}Sgi{quxbKlAcBfCk;SR`66uVl6I>Wt&)ZA1iwd7V z095o&=^JMh%MQrIjkcSlZ3TM8ag42GW;GtpSp07j6!VTd*o})7*6BA#90nL)MP+m} zEazF=@qh=m6%&QeeGT|pvs0f3q-UHi{~U4)K#lmHy=RLIbka>k+SDsBTE#9(7q3uU zt|skyPz|TFjylK|%~wxLI9>v+bHOZHr!$aRdI`&{Wv2AWTB+ZZf$)j}dVkc!}ZgoEkeSilOaucEr!-=PQoDgBGMMFvM!g z&t~R)o|F>MFClOITHL};!z1x z7LzoH?+vnXDv2Q&047)o96S2LOmdGv&dn=_vYu>)M!J)V@K=tpuoK+4p%dJ6*d^a) z!9Rd_jaZ4_D~OU;04aBlq$f|+Ylwn#LJ49vmdWqWen7vjy~L2NJrhAh&QN=vQwp~! z#okIYCqhh^EpM$34~!egv>`tKFwtx^&r= z_>joAXh5zjePxe=5Zly!Tw|BL4by_T%s&{a@^ye?4nwtGnwdEwz7pk4DHPgM23GFUUR%;-FTg7`krvP>hOL&>i=RoD#va* zkUhUMeR_?I@$kyq6T-3a$~&li6+gM%VgAq_;B&YmdP!VP4?wmnj%)B}?EpmV{91eSB zu(nV^X2GZ-W{puKu{=X+fk9PfMV@2<#W?%A!^aAxQS0oiiMO+Y^-meqty+Z( zPx%~VRLNrGd066Gm|S)W#APzrQLst1rsyq3Bv)FfELvAp)@Zlb8$VSjPtaB%y{7#1 zOL5Ciqrikv(MZLV)h3$yu~gIJjnf zU_kn-QCI`pCy3^jBbLqbIE+-7g9A_?wo;UPs@mO)$7ryv|5l8nXF z4=}#=C(FtyISZCI=Jlv&(HYH!XS(#*(RJ}hX{imI+ERowq)GT(D=s!S%|ulx1O>kC z#TD_JIN@O`UIz21wo!>s#&QX2tgRp~uH|_8)`BlU&oviw1DmTjqTx6WS)aNUaKKmr zz1LbunJ_r9KpLSI$}CRlNM2`Kn5g}cQc$v3$`Ta8207Z@CheFEGh@p2;e`|8OQ6s3 zdw?NoSm!Xbup}!eB7psHAtElj_x}}DOjX;G}#Td!6sITGo zDg8p@)fKrEdo?P?j028@ba;u$WX>fK1ceFx43_qKg3>kE{o)m0&ru6eCjX@557!}O z#!G)Py)`b7#b1?|<@LS+sSPp$lx{~k_NAv2J%j*KU|!D==Me^C4$;McXq?IFc8FDQ zaiY(CJYo|y3m~a&2anw zMW3cpNl`zoiqF6Tiw!%~BbKaQ-CH-WP{;L@H#X67rg0#de7L)+#|$BV>+QK2MO=uaCw2_3HR$6t5fTIf1H6PW(+!l5>AsbW@$!MAJb@d5l! zOyeWE$)$@L{h3T=$Kks@h2E#qDdNpAJDR~!k_?WD1##7CUWLII|2Q^CNc+nTe|g$w z@w`Y4-68jK?$8IQb_^)Qt1vgO+^{dMo3c)O!C;{ujbJAMtbC4{3LV#= zYxu*bxi`)xdD1XTUOCa0>OEB5vj{~~cxstHY{=rogffY;NL_eM^jS6+HS-!y;g8%R zG_&hlrh7%`)UgA}kZY3AAIni9%Cm|T;Ql@FO*}IjnKJ9zVtqgf&G$^J3^i`}=)bL? z2i9L_#tRcLn|@dmjxgK?eXHH1OwUP(kG~%&UjC7KNc1 z)L?TYn-dnSGIZaQi**B1iQXZXssT}ST7PaUo^VuELPuZDoy&FBhGB+8LbwTJ=gR^` zX(IoM1R}zC$mcSVM<#Bqg(j#^vw8GQ&iKM%LT=_BTJ~1u=Rfa}^H5;&J;+Wad(OISt?O+<+Xwd<}tAYuM%GG}SaGjmW9&LbD2313* zXH0HC5dR`E&eL!=OjK^^l3#c_pgF}(Rmywk+<6X}4q3`gz_f{J+t{B3IvO2xLAX~0 z^gumcggKGqwN?$OA>$gsQ`$RyJT|#&9xckrwG6z(`*x;Y+apoNp2_Q`Kt|YrXGSc` zV>vxARUwo=!;e}LDg&b6`W}yQX6Z{H|NP@@%_!(QG;M)>V$g3192a5^DBZejfOmJ> zF|y{z7^vQlHhIz5VWGyPYt^;(y}GTl6bt?AF1U%vx!x1_#qpUr>{dE>6-nYMS;n-S z!p;7U5lglUFT`Xoko(YXG!>;Tc3T+gTuB|Z7N6w8H~RXR6Hr~|?0s$66jZF!t(?l1 zj=|cHy0RX5%xPC6eUBACEd5z6IBLdf*jKie)lpgwd~+DIJb2nfyPg}r0PBmr%iL6m z>xWfZR*~9G?Ti(=E2;90`sK#Z`rcZ>YMa#|bnlIB?xuP2;L=0G&+3^)%lk{!o^BHc zY}Xx9{clyW>uq@>h)G}YT3aH|K*@;qE9Qo!d;N|y5~ z1U0CkRRJ*2(ng>s`?vG6w$;tijm@T5-zf86QzeE}E3NKP^V8sMxeww7SOQhMU&8>< zl~+TzA^Qp(ehAJap>ZQvK@%sOLGb}w_YvnuP&or-l&<@nFbi?#zdb)*WZWWIS* z^*vCpctr2+iCvnC2CyKul`}-jNyuwyE<^}0P>#@E@`MpmAM=!&4=THO zZQ;gUh;~k-D(H8z@BZVbJD^jFMn<>BI?Io%XH%;!n83B(X`&WMaBp5w3l0G`8y=q4JLI@wa5!D`V}n04sePQx+F>@Qi{Lw zb&gbImDsdU`y3&`d6ha7J|5O-bZM24jffJCfHd~@lfo+5be4o}7t$SNW%QezTDd+F-7`;9O(E~DenhS95%M#;u7^S~!z5zbjdHKlRdA8vfe>mqx$ z(n16@`5|_TKk{KcdoK0Oz21Ed?qJ-^;I{J4;rb^?TUb34YYFYOz2B-X#hty{yXzB5 zw01L9_erFV_mkAv{p#v!jSEw4zO9e&CJ^W2R`C6+4Zxtvltz?SeQR4}+jQ5FM`MqO zW@vQQjPY%3fz~A6t^|gLFy7rMJ*xLPB4cEPe0x(+Z(M$XhXNdmY8^QNJxhGgsgP_bzlM zY)RO?*!wmpcWyR7dyd-xleJWm06%rdJQ|PsxE4*NBg)1}d68R5^h1;-Nwq=4#&Q)a z)Wm3z{GbRD2~x>1BMbt8#`eQk2ShEEN*%xr=U`rx8Zi2`6KB9uA@~ z!<%=&_qD)hD@qGqGwhEW17Gn!Ulj%Ma>!j;A{+ffyy zO5i7+wzTmn3hDEf3=0%^j+H}Q1FF+$d|Nvb_H`)P&Hgm2)zpX)%dp>& zk&L)>V}u`SDF?>t{<-iII`KHK<(q-3N6uZew!0_yk{|sMPul1*Uy|WV!aUdS^gg|2 z%WXGTuLM4WWk%DfXBW8C^T#veiX z*+jK_C?84cdxGRR5;VZPiKdA5A=pL@?g}>Gkx^fZ@PX^gNLv`&YkME=+ zMzEU7##^u$K7cC_*Pd@MO*A21NEe_7PmE{5WX#H%-fh)|#TataJb+6P1!DEPf@=#K zWM{>%eIx;_!?1X8cuyDR3sQ+YYfrL^{cUiO)&gLE5CyrR!gUE!d|vESBC%MdzVt%w-vQK-UeL$ zR`s{+*Ri6Zv74%L(8RxyNmA_5(OQnf6EDi`{KChC%L^CD2*^A>>{|2n;nPTJ*6^Hd zArnBllxQDQASfBVI{l%heO=945vEeQ}lkuag0F<9_Ybxyv~;6oDWwJVDr z&G+E+1_kv3XWss&f%F|qtD1{flDmguL)sZ5*m_&Lo@BW*WBfUObyI zRIzk&Z;+xfvPbDHg(#cT##=$PPB})A zblRtAM_XTI9ph^FyDYo?)%VU9HnQfFPY+@TVEfr;s>YX64G(C~oAlbzo zA#M4q5|2**gnn1S{t|erH)jBS^ALF4{cJG~Ct3tQ08$pn%E-l3(CQVEaOaFyA;NaMgh54a(U#BohL*&j1%qNO-i{cIoc zuH3AmH+>Qr__0U2f~HQ0C|zq9S9un;Vl$bgRfDr&)~@+zxj z@iyYkQ_;7L?#nz~hCeGQ@3tjL}z zlLeJ{$H3KaSxOdjLbPQw-FkZ%5-|s^1-xtLuhh-#j16H0^49a;3J&X4F*fNWvvLng z)8DSq4w1iHPRo;ovz8h~458lDYx;~&+;OfXgZM7=J-_e2`TCc#>@_%RD@_31^A=V{ zqtu&FqYN?To~>DK{{}B$!X7|EY~i1^>8Ke+TAq%4Wq@J7VQ$9)VZ!eD1%R>U#HgqA z5P~n?0(i*{Xu4?*xZd%=?2N!64_==zI5zX}{tHd|&akE5WLfz`ctG}!2?T8Gjve`e zlGt#G4o^(=GX$}NvRCnhwl0Vzt3MIbCq}u)rX>vx(rYX&M0Yn88;u9EguYrI`h@ud zQdL=Nfj+ho({(o6CZ&th!@bYWef8`W`QnW7anPXzM-t-%!`tG|D2m}n zb;w0q#U5zR+%0U)a)Ranc4wgrZE_N$w}N?Q)G%JEA%~($lk$_?m|T>^bhfzz)k|GD z5J!6%?g4CkQ%s%dgkotsIlN0Pp8E zKGqE~PcEB7d33xgPk)O~c@WxUR<)_{V>K=VIG|>i2|17~6lX^_t9$U89M5fAZsTwE zoZr#LjmTN^BLg3d)+eEkzvSmGSTwu3zTnT@`Jx2Ih5Q&{ z`IIcS#WzC|+JJUGtY2*j`5D9+oRH2#&`Z?B7#xtEye(&urASulg!)jjie~e6Yt6EH z0!i1I;XvMP2|7Z+kfA}i0&29S#OLdb$&+4r0CDnTdNDOV(=@feSI*zL*o@)^?)d_S zEy+}?KYDBn7pG_LvZ3DuzK~XfF)l-*dE8Lo_E-jQIVCXnVuU{6^a}xE4Uh>maC!~h zvdEEyaRv}TC+!$w$bM1a3^B|<=#OLG#2m91BPG2M)X7YLP$p24Dt+Db@;FtRDa{Qo z`ObdoBA&@{jqzlWbtR}}?X3Y;)2*YvBdwo&LWovw4^OAR`N3Zlqaz!rh57Q2I71K# zy0*BC*OObasWh@p*$~8-4VZ_m(9l=lks{-Fu6R)9&F!%_Pj$N#V7xuO7za)6L3j;W^#-85^MVlZIYf84Gdn%!3I!$yCb9|QYzSSLs(L9 zr0vue<(nj$wL*J9R(5x{opst7yqcAl>BN0G(9BqiV2(e&&v0g**_eN+%XEN2k`++8 z1H^g>!zHkq_~QSGo@1Z*!g>QBK-2fE!mMCg9ZY6zHASYC!}59~NHWsN3aN3z)Ptps ztFxCC7gk_-_Q;EuZI$u+3x?|^&ysf?C(d}AjPi}u<0}DK#<6<12x0}jmL_eR~6ilm1yi&zQ)eyb#J_?$)EsTS$+Ot9}19d1Z>7XuE?9ujh1D^u^ zpkg$>g?dJU9sJ1gc~rhcTmqUNuR4=hz~II)YMJA2gy*xKuK8_BC8dtMvQx1y3WNBQs)KdLNAxiM?jeO<5b& z&VoaG>3&ZH7$lJY!7?VsGde=@`1cj44cp)9!t0VSsW*==3HjXeKuix&S z9Gi!qG(dOuxs37L^^znePlxj9l=ws7T&`D6@#U=UFFp^0FlTWF!C`p$Vg7=I$q>oc zc70qB9=1(DcqqL;iz>NGau1k6j)E}c3i0S5z&fGZg2gyGqj1$s>E%g?n*&>bB`-`z zH^KfxoC>X7p>`kb;;LA~?n3>e-;bqdL@RNTop8+^Lg6+%>YttCS}wzaUO!4&s2?RQ z=YO+D9BeI&4W0fs_}}aVN!fmWLL=K~`7D5?Tt^cNwn6b9>1 zXdsC1->Rgv9{^wE2gnr+tHKA=*JoKAJC80Uwl{ROzn<$g`BAalt&Z!H#VA6ruwB5{ zkPslfMa5MuU4x_)JF@CF5efd_f@;^;sIRb1Ye;fV{xSS5{IEKCnu87>qoLs5Qkr(* zxN#S}rE>4jwJx4ZMe~|R5$G3e(`2a_LS*RRET#7JYHH@Sup$@|6m3!c)GIpqtbV$N zQ!RX&emWg{O0pvLx=E6Rv@4--S~QNLt5Gu=8VYWj*NFlSN-5=5~P$q@&t1ho{PFcQfNVuC>{cJEQ+ z+#Zz1TWCS|^fzEej>ts#sRdw0x(F3S*_$g_`O`ni1R-bGdH%7cA3w2=kUODGlwr17*x+R-j(|~0H)5o9d zM%ol3zyQ_0?pVYUi*#vcQzVQ)0%XB5Hh{GC9%~cJn_K=H>m({2>e0dx7vSE~(Bh-! zNlxKtC#A<`Oj`#msX`6&s-)&NRuJ*@C&@$@L@Do=2w;&|9`>Nzh$^!G0l;tT8Z)1U z>R~))4uLBRx9aA(I+*GO#{skFNf^_`^a2}r_Ky*k@(t}gT2X)G#e_eObzmG%yYdr& z;nM~C4VdYaNXd?W>G*S$O(A|$9vjxf8lzA-298rP^gu2FUlZGv^gK5CvHrDmVN2rY+Ebtl+i0)cF1~@H`kln{Ls#9 z^#ALPn7ZDZu|Kgu=*MaDPvYu-`Jw-~QSOJsujHWrL#21rw-PclHnjY|aC%A44Pj&+ zq_ub}D(|u&QgaAGZ(^13MO1~+z=Zu0IlBeF#H1#D2K$m04RuB$4gxCHkMLKxx-&qv zwzplN=MQq;>rtC?)JFbD_f5}}97o;viyPhVUv@Yw_EWviI5$UkyvO&m zc0$>_^tbuzCot6HogzSz=U?$1o6NWM{>ILKjCYZMNPt>lst)bJa*uB@t|^yJKznB8 zP0)4jh4|XX@}`j4Fc^!?ROz#*|K_V%v$zClop1q2R5>Ue^^vCbbi4$m7hR7)>u@Bn z)RMm0;CHF)gXQ3n3WjjsF1sn{rh3VarhyfAl<}fC#P>zL8Rk1xb_w{<&LrjD@?3*( zSGgw(zw2AqzuF=Igp_x)h_fk3xILZmY+uH69gSe^Rk9Zb+Tk*0Rf_8Of716{NyGuhPT#(j~f5u7XG+D2()aN&4T-Yp} z7aOcRp+AzlpcKSNBf;6pkF1ck+|CXX#g+Gb6Y?~ES0d=_?a+X+93F_Xy7klZ<*CJv z*Mf1k$%3M0tZTj;B#Sa}s2xJ61xs)k~uu_gpZIt5o2NP3@{S{1c+hl|LWChwE(N!jBU*;?T|PD7YarH z3$vb*JoXWDnR2WYL;r#Oo;xjTlwYhPI}58-qPifQzk1@0m?{pNK&9!Dqi2TdLBE4U zVa$Buq}OCWRPTUuxRK^iCFp@p=G6!@Q7_8LZXXs;l*JvC^M-(NwZ`xcECMn~2#01$ zehZ;htX4BeXVVfpriGWNZ((hn&dEO|7&{3!VpOFFyez8Xd8}5-Rkxl5b|FQH;?b=}o(fb5f4jhGAK_9Tm!BJYz&>Sb}g8J~>^yWXvt?VUq{t zf1AuOj%(ULjyy18Z}V4vXPjAaj*Lo-$hZ*A{Tgy)SIJ_*d7jg_HP?xppEMkk!@pX^ zi-2!j{A5ltyL_5>yy#3!+qC)2b^V5%X-P%zOqV*Zhn=(J&D@iHCdLSGMG-9_NQ>4|qkzMl1JS z_-Or;q-FK4??@-Z%pua$xej$$?FF)$bECX!Fg9{9Ek9qLo;MO9-Gp$?_zkh8%c4NmAT{#tL3UKlH#u`jL=h*F*BZ0Hac4Y^crJYk?I#;}hm}_p>6fnG| zvdA?(l^3yjCqJP%0CgqaPgX?y zGxdSyfB!G|x70{wLlH?8{Ts(|t&Td3figUxUQpr}5?!-Ook}$MEC>yNb<;ZS7(tbd z%b7{xti?@rH}{Kw>lef`$tq*>LaIxNZ{ootSEq!8L09kOTI0^si#FRg@8>6jU*W5S z=r1HjodFOCG@-O4dJ;p-oAFzLWO^cf6;bF^BduXi#^X4Yk*+9sR3oiEW&18XK^eK4 zU_0%8Fhm7L!Zrd!Y&H_F)o>jzVgV?9`PK2rLVQ?SeTiWo0Q``GpdTOYICFb8Lz6># zDn>x5lcK8((<|Z_74%n>@-Fm-^44Kv@;qVdNwY{Gx&G3)%|J5VMgu^&&_oP`zx-;{}-ZQ&U9(4^gQ250;%~ebaD|2JoG-rzq z>IhGSO)=dmD4y%xPh{r4v?7|s_oOAOM$|vEQ878aZCl8YK7B|zyHy^6(QIx4Br{lC zpl?sqNmIm96KoeQ(?%SK0o|dMXhZ$LxTe+w2~i95n@WYwah=DFC3a;av#~DD=@PG8 zQyeIj=!tYl{=-vP-DZI3)^w1$aOXC@>Wl|lHeG(uMZlOAnM4zYkD-crV0B5{kh20TlVNUYHcNH25 zqtXC*zvO5TW;}G@rw0(L>qLcIYZxh;n;m&!lC3p6R@$S6fVwXfc$AMUG?S7j8QBV6 z9kc-nodk?{-+017Qv3^x1CqK*{8h~#X1u&GFMtd3I>PW*CE_x&SAZ_KSeTy2*(WQB|s0OiQiuSx&gDh!I z_R{d()47W6+;RB!lBjBxzn>w^q;&j_aD%;B>2T%+r*fiFZoE?PUCQ_(7m>oDj7#<9 zt-^zcII$*~lO<2wxbf66=}=~sZ9_-tiCH*1<~{2lE5~TW&E(qEez{Mc`NQQx$XnxU zqjl~__8v0 z20Cak&1J2>CJ^_^>)6IGi7wIkigaw$EwF)Zg6dwa8B^&R64cyx*}q#Z#jx|>+WW`0v5g>7F&f2swdj8z4h)qR9S|fL=({2QDNQ8NUQ3eh0gbJKl~_c?q3fpF60v32XBOv*-IHSJ0;dK zJqK4{cqmOWj>Rt1m3ep|os}2Vtt^>5!X?qgP#|1)1@TTYn6n=e6c-dG>>|^ihOu3e zEBts>zO-*z@OJ9%g;c+3=XL}7Tu!9?SZ(Ns`+0GSwKn**3A(S0ordv=rCk{N`G+6# z3CDXBx1$)vJPZL{jy+qcoP5b5j=vP*nE{YeFeY&mzr!BXl!Dvg1Qap>ujCgT5;_1k z@H6lTIQy8m4Qi5886@ju}fcr3+mE)Cy>K0N<{lmRrDT$SPt&f|4g28g8#pIK}=l#xV?B&x_8@ z2vRSm5a=*HKC!8%WBMkV2I8>h2D-IK5A~2XJSkVA`2|#AOheCl76HLzm7*3$yyX}c zS;cS8uL&BJpt(NuGgb{ZIvxV+$~IKdyM^K;b?LM(bMX^=r`v2BHDI)SG@l@!S#~W% zbPIpxf5y1tPar2V{y212fBJ3$|HC5+8=L4mTRHvvBmX3!rVhrAj#B17DXGoBClJNT zJBt4pBxJ*y36m);E+m*g3#efMo|LD8Jipw+&&-_kn>uE*&|A1U>>gz3}r4MeNGP_}!)wX`>uHN;lge?#R1c(|&z2*_H-69J9UQP0n4_*2KFf}3 zu({cc<3q#HINkH%xIvmKyg-xn3S^;i@cYR17n{{QfYT)xSx?Rx5L&I!-^0x@FURd|3 zNmz<@Xu`Y5wbCbM_9b&*PokDl6r$kUbX5DgQWm0CcD6#AvW~+8DTLC(hT7Fp$VvRk zQAYT#wcErLs!8c}%3FnPJ8b=FULp;f)p!7Rm!gfB!PGMVPQR*h>&>>A9 zV@IN?+Aqx0VP~K#cAGq)Y*3lJiC%SRq)L4lJd8AmzA^6jO1B;y8U5;@-Er%Vs)R3?FE#ss{GBgf#!*MdLfFcRyq2@GSP~b7H!9aek zBZi&nao#!&_%1jg=oG!<3$ei53_7eQpF#Y~CX3iJ;)`aXL(q`15h4X+lOLa{34o-~ z3jbAH^eN6d^!KxB#3u~RD-OelfVeLr?kU;9T-KM!7~`JMd#Fb#TTeSA%C*06@Wn&?gpWW?B70vL_6*Po4-EYT;3^SD&XAaEe@+{| zGwZ$xoM+}{&_mRI8B&w48HX|DUo~KjV2Mk*9H8Ud@=t>v^$=uK$|c;fYLuK*O1!Bj zI`Gz*dc3pFA+B7lmt`p6?Lsp^l`PuYDcH%BYtDwdbbT`r0#KVMP-gE7HN{l&5p*n; z+YmlK#slLGp+}WOt-yn-p))K8*pwIsiO`R0NC+Zxpbj8MN>ZGJX+@2iN|Z%lcdv-v zmQYLisOsoM7&wp$Qz$5*kDsEzhz2>$!OShPh*bzXG3v;_Uq5X+CYp6WETP6&6Wndt zoCy(PS#lLEo@AIwbP>$~7D);BM6MiVrqbdeOXPpi{pXk~Y9T*b@RQ&8`~)QC{~;j# zL?AbJ0cR((pFu(9hX0p+nXGK>s3?N$^Gy0k+KPo~P^?s?6rNUOoj}+#ODLxxNAF#4 zE2rUqH6`P5=V9B`UjGR9hJhn3Z-UKt2JP#I0VX#B_XWWB8oqaFy)H2?6OrxolC^b` z#dE@8`oin+wJ`HbrqF1YT(pomi*+{CHQ9qS;^np{;ir;8FpY^m&=%teS^x<@B!-Zs z`VefRH5e2liGWO)wrIb`4_AXOzH4}Ng@mK(tYvt5zfx_%I72Vz)a_7n8JH(}+F6H$$Ix9wtS{5Cml-!T5+wBPO%bqm{TFpw?(kBJU)vPX{rh z;9x_MdVkKYwyZ?|2Cwue4Z~vN3(l=$2O{;dX z$+R7IU`(mQP1TFWA?DHXZ{VmsPp*tL7? zBMgsJ<)aM27&wjCx%x4NxKNy^94U6%BQP<>n?|RWGam|54U+Q*YJHSADO=Ln2ad*W zkq4~T^n)8P7_g=rZXidF{4DIi%Suh8BND_I4d1nR=rPwhvn>p>@e(0&zvb~tZ88#d zmyD95P+6%W7Fl_gHkD{Xi8bStvJNM9(P5{ir#970*q<7FG7E?+&`u(n7O_#P;Um~C zptsHoE?MnwV0)UUVqNvZ&*`KTRVv5kxLM4ee-LgP-czlY*jsQ<{p3MHHlhlivD;YE zg-?rH4_nzK5zXwy74izgT8#tg&7Jd)n%JxoCkdd^&eccfxKo5dI{pil|I6F zgfzYaRlXv*-l9o;L_>Z-B#g=RR-O)R7@-h8(sT(S5@p&Ki7NyxVwRVjeSZyLe>f6xDG7CWT@;q?z&TF<0|Eh!rT20ncl zJ*DI`IH4Y(JR%~vQJ)kbs8Sa(+gPs=>GY<)eKnMga^=!;bc!?$dEKrYE$Czfh1+ZXtEf^4Z>~lP|cnW-15smjD|y_CSMYp5=(Rlz7FwR>Jb- zk4W#dD;*kNQNyq_k#)#cwdq1s7_8t2L>ZdG^R=OIAYCcDB#s<;76)hq{b-Yca50Z< zl0B8StL{+&cx26*R)jvgl#i@&-$`<7??E7S$@w>wd&G^k^HY(x_x5BjZn#wC3wN)MQ>$=T(UhTlCnA(Nn`vm%KC9LC5^{(`kZs0JQJqzAP!w{;i6EpQB z`Z|R0Sm9yPtXT`{^@t~xxEUpG&$V8>vU2Pk?XB>R2UY2JA-Fji8JdvGd3k?_5MMN=G} zqlrw8Hi8}RS%c}6Um1hxOfC2r{AE|mYtrWVeWi%A zz=t4I5L&z+XGVJ=EF|jOk8%}d8NqS?PN*gwI?@I>g($HH5Zb?OM83Yd(7j!igRvHe*;$!Zxh%y9-81_MYM-&o#dZ2x)FIpgN1_;Qkub&0t_I&1GQPrS2Qz<2Ei}kL> zC(k?XiRz_xGt744%!c0I;c1~#vV1rdrKdkq&PhmBAG^BQk06Bi=Xiw%xhhN$J4JUb zoXEUo_C7InM^-E!>3Is~c%0;*XI3{gR;pJFh1wLXu;*Vvd*t^rnZKBKs_tmKDu;9T zHquH?$WJhLrd!QF)ZgU}xCSp}zOXUpCTb3_B>g7V*ljb zeSY{2!wGUd0!CXr3cbe5kdRXpUwWRR~w%rHcE zwn%rbc1}dnb^ev*i+16Q#Rqhb$V0O@vZX#Qi`TqtN? z?(}(pctgdz{pcSVkCH!lJ-9H}VNh9^-z9PWUUV@-0dnPhIfUqC0N8;tBflY|$)Hv3wzXvqRCjJ9)%-^c|wjcC&bf3bAkn?0sc4 zca&$kIWViw5ScsSqd8x=WwDKy=%jE4}W+D9M2-VKn;KFg`LF?iHQ>8FWi7x z;oaBx4jj9jZdn?~V{%2RofR`8yzuWHe*T2qlSE z4OeL6PB!#*P?M3-L@m)qy-lDFpC9=iVJJrL9OM#m9f^BXTPk*+jwv1ulAJEf*+Vu$ z0u;&CYU%@Cpph^+@XROdS(^SKUJkN>t(e#XHzsYe1NAVGF`ID6zRou@ihaWV!B=LF zKJ&bFg!q96N|l(V8ZU2GnbuL_Edc<13QC}&@;|9pB(Pi17w64WKNjr^H*yw@a7J~P zcu`o1K;fiBUb+x3nYZ^{hywA}WR%w_0yJ*8kA$6OsHRBsa$+Prd`0^}R#9il!0W@W`u$zZJGEMMw zRq~++SGG-tJ@z5X+!qsk7~T&|r-m4Jn-1zAZ2lj<-Z?nZa9iJwC$??dwr$&HM-$8> z6WbHpHYT={j-5&;F{;KKp!C{Z#+m{j7T5g?n8$edh6-8|8Z1ebkL;HskIN zx8bkmUl($pu1ASK9yJ1YANLU?Lt2|4!(mKj$ z?tq-g@h`Fmtqq*dQFX9z+9P|mKZv6&h3QMr(YhbJE~f^7iJ}aYRxqK5hd(wi!|$G) zpnY#!sZxK3c*7TANBO~6$usCNIA5J0Td11$%xstIG=f|t-RtW|ZmHX#Kpp!akF|(d zcC_9~65$M5%%I}utld>DsW`&n_Qren=^^iYF6niYw+ulfQ|?$XSXqhC2TU7F==nZ= z+Yk}z#G3vtADj^MxxB>i2C+*C13gHYvwXP6-QX~rHlar;uxj;VoiGUn{xaq)@O^45 zFUmo!U6WP_E|}wjZJ#N^O@`V(n7yUahPE5cFy6nv{Tu0w$wp?62I98R;`Zq=I&B^? zi-8E?%?t;C;ovo#I<~t1<@+C!rmpw{paRaRl9`{|&f#qpZvwf4#^AFa54hH%McPp;*=tk3(N?0Z$`5W#=TrrE z2d*Ui5GrLVl(>`lF7MhJ-X;F+O2bCLPiOUj?k0pE@3f+){^6o;b9dQ}^iXO~;|L}= z8^6TWmG&;FNmaUlpND{OIPVN0v?<`zKT=>Ew2QLJ1*i&d0BP6C(4eL9nklF?x?{SA z83V7!-g{^U9kb~$G9BNPqKZGlmcibfQ$?W-lyWoVg1T?-TM2e$wj-LbURM_ z7zKM(rTpS^bmd4hQLs6;$di>o_+I zlL?onPu?krDL~JzA@3oS0wJAU@PDicz0s(%iba-3NdKLn{Vr< z%Yo7s5RP_9)UI28x*R8YyTM6&ot9S361r+rmdOHXV0hi-f|WOIj!PRD1(9NABcB(O z4lVUwnF;Eu9`U2M_ihug)v#}|5(e;n@?fq*x7=EPo$4ot+K2>VF18I@t6X9;TtIHu ztI%FvwV|o299EXzk$|fA`D(aFOdnT0(7=>m^W-5K1==Pi&iPG2FqF9^C(Yd2X3=WO z{r0)hLf@;QzH9Tf4V*eM$j*5rHgHZ&p*WiGDRquYdHk*wH9J;N1j%;$cuEH=3%B1= z`}JJS;>i4Q_+Dr--tal)V-pjELkBD3=s{sz1SwUzsjwipz``aZQh^w?6c|q-1(#UDtyx3M;qo&5&j@RMHpnfR_RvgE?>g?>GfG?d}Gru~yPEop&D2;kzE z7+8o5!-h=S1)%e2Lhi#Iwy!`1W*3l{2r z$DosV(wHSS^Pw3v5^C0|=Dv4aykO#&-by^zYo&E5j8CU}0(D|Dk2YC${S!44yF&+>QmUE)=2N*#> z9tsf5q*8kX&%Gy}e?{i@4zkP(dr`61DgYMyB!{Tu+DRAHLA}u6lOvUA%}$$t$MO}^ z=`H}%_K=j#84tJSzk1*?%>97CA<)3O1iv0GObE1B6cK7cUiMD5w?4HN^`LAJv#99|w1F`tU&KSNsfNjb_KzhIVW-EB*g zeoB8r5C(_P(KzAn5zI!T2zR5iAQOf@a;p)8kfTfaOLR92Ji}B5v1FK6MUCmgC^U{+ z(6^nH@=D&uODWY0Ky%czwK9rWHtmai+jhGCMMG4d-ts%XJf=6tP(;=*SsYd7RZ&eg zoAP)Ie%<13y8bycl>A;~%v0H2C?BfgwC}(vu7y5_rp_mwkG!Hiv9ft|Kigj9p%@~5 z+;7w(ORbtorpmz8&&Kxr!BDeOR;qU>O1P#c2j?ib9rF8zpjNKdbsKo6twnCjvO%y& z86tl1I8t#s2wl2iD8R|sAOFD%P2~<#c6bc{iYos{=THCQ2)pzL(`?^u-1?`6Z6Pk? z(N>|P=A7k==L&sO0mduRgnp|P&pVang=z9f&<#~&ns!fPoKanKT~uQEi%VPtG(A9|63xv>%Ks~%XP?L3+P zuz&6A`E{75lsZt(=t{8*l+{a{RKSE84!Wiv*)xa;tm4jju-nQpg6>z=;N3AuXEXWp zUM5wAIynSUR;OQU*i31X2Ovdd*v*uvve2o={6z0N${5e+;MQl0sgxrI0Auh)u@ql{ zcFO^;|3-Kt;qirT{?ac7!T&D}_zdH6!+yahhp@8#{n3!mhoyl25m8h z*VWQR^{88#fy%~Sc}VbV=kgWgULkj76U_a1@IOFf{kDT~u$j9X=yFFHctCcO+D6eKd$ zCiX&;hR{P0oG^V z$0%XI2!m>^!@BEUnXQfD_ql^ihGc;j<5jj|t1`DN?0YPF+tHZzO<#{qw#eoQMsLeD z`p&bfl#b#4-u`xrFKZ%)BVRmcRD|b$jlr*;L8z7fx)CH7y z{XIq+9W3g)eGKLk-F}<*YK`qB*Y7j14XFGvZx5CT*dQqo>kNjRb15`{foG18NTzPv z5*c?BJC+S(vP~fsicHnp5OP}0X|uhgJ`zs=@nD=h2{H~IDEzWxj1~~gsq;|PkR2~O<0FHJjF@E{1A&3CCBDCAt97=n#g89HZaJCbu`!L z*Y+kgvi3E^CYXoBa6wB%Pi8Dfvf_UwqZTZS?T8 ziN(_@RQKAl>)mz|nZG^F0<9t_ozcHB!^3K4vf(UCG_JknwUgb=DxwjQrZn{1PsZnp zyNR7YJz`XH6sMZ-Jvj2)hv#Q~op|I=Hrrj7N&v4Rm2!#C;TrZd<7deerS)BWiQQTr z`I)f~2Zc4AT|DIZ+bHiSSpJlpUJ&fbXyErb~+(dOZ@5sQi6 zgUCM-i%Conu|4-B|5SvWiqfly6XE>HEhxvB9{z^I(g?N_jv;P^w1})H;`;!_?wDa` zeJt->*4rAesMgsrDWNul>!CkvcCzw-iF&f)PhdcIlv*|J;h`F~{>WkOxry19Ix>he z_AYQq<~qq=92v5iI&_#n)nahZ%8E zcZQt(bYg23+ae2YOWN1gxY^7QesehDy|{|FxTmvVY4)D-{dcrjXTPL{F$iI9QDS^6 zhp7fyN;o5Ot+aXA(+4oRJ6yXvs2JBpKg4cH#BLEG|47hz>ZU*uU4o%u?(iR1{nt5f zyl+@TwGl2Ty@f#TDg^ksj6~A#j^$vLIxMptkV~OpnC~1kh>3?Th_=CLZsN)~E!O8S z)_1v*89cLLkx((MrzP$vXM(Y212g_7A7C~LBViujIeMfO-lDs*h|43M;6kp*g-kn+4VQ@KhZKhJ6BYDyyW~&LGB=Mg&NlCZ|03-7 z>WsxU2U3?j4Qpw2mc&4K3g0T6ZH0puZB=oo@#p3sB$x#8-}kuRGgge}9I~O_?MYdm zw*^ZEKh1QH6&?Tc25g$+>aa)Y0@z>W{S-D2LK-+1pGqJE?+CBq=Z!$jA2aN~Kg z-~Jn}G43pg-ur6>B;-q*^M8murCd$SzecQIR`1eI4i@rGPIm6j|Jr|BQ(XIUN`WKy zhzgibl7mH;r6F$|fLxu0lgKv~Ce=?8F65V>)Pej}M>d?7Z?q5zQ7Y|sCe~e6&U+dp zM~t**V)?LlHo5nslvSX(SE|q=AuvgdH+J zBJECMVYrD3(h2#nFtc#sYDzRxU}7wZdUG6-K3r<%gok2qHzv&Z1}VO z`wXa6`)D&H-c6~3Pa#KB*2Hy5liFm*6#B*bD)q3 zcI;LscetfzSqV=^L;rT2=~EOjAKr$PVy>qh^WN207~`i?EIU2@0YAsz}8JS9g!UYgAO({H4Gxa}rYzjv&SACG_h zPbtUC4)#I$SIWBfbx8kn>MHXuG1)%@SK=#I?PG=y`J6aDKu76-HM}?NJ*}pNhY*?Z z*%(`xj0YBErE8T0^sgisnjC zw)a~mtfaYnqzDU?HrwhsohC27_R-P~TB1d8Zhq4}^^06AufJp_M}S4A%239Y<)*hB#YL}P+Lc3xuMdT(mlVa07Znm2$@=)(wCUnIWLl4ybx--t|XsK|ZQhjiDO5<`g+uUufLD11e8U&3tZIVw|a z&z97^p^ak5bx(IVscRC&Mp}FNllB zQ|T?!Lhr?gG}9D~bxJI#@?rF%@pJ*pnrbwYF%RF}^hju~L**9k;7cnOE6+#CA#M3B zLToAX1;mXh!$^+ckB*DzATfW>&6*SwEHI}!7C4?vSqAWtvY}vp%Uh?tJf+~{*f_E9 zfqZk&%*+?8QR8Z=majKz@T_>x3{6*595-B8^v+tlYxoT&8)}o_C8kiqp=-$Ti%KqI z)J8}qpI$>MC7DudMxeeKl!23cJF)t#EGv?nfvG(%DQHxYl_Q+YD07?i$ga0=HYRH= zW~fn}aoAP0DU^MUtcI0?A=|MfM4?}Gcc3+=HboQ3?z~7_4WDkIj9>=7?@Q8qE>q%0 zwkp#|-rCF!7*>70TKElgq(>aK+^ITonO_DXa_rYjKP3gJp%N0?Q7I_NaWgo33#K|s zdOjf8vMdUeNGYY3C)UYqq#Q#)LMgisur^nvDK!N~HlTlGZ9Jv9b?V<|Vrb5yTI$w0S1*!FG}>BY3y0ET!#uEkU61ec>nnf&hQ zQw?*RJd)IJz=+z73Ji5lxmh(wpm~C?Y1wUnB^(M0oW8#D-h2h?D*Y?>R3BLLw*s}R z`0puq$zQyu;vgw>U$|J>Cr(OoU#Z?NxPJw0qzPpX_Cw&7|-^InX=2YWqfEXA*wS`*ujJnL%;T~>(6|X^dn*O)jeH`f>u+j%3}1|!5A#~999TJHY6p(JVd4y?Pd9J5Ga7a{PYLR95ow zm?GnAxhr8H+qG_2xB3ZIFl4Hm&RCud(4esNgT!cOiJZz*Tbr=enkZ~eP3#=Ktv21f zX``RkOCJX_f5eyL!!_6!oNR_;3NzSC6Z^2St?xNG)wwO!v11Gwcw^;-mZ34k2|9$_ zj}wJK9BRu`X2nWY5pp+@@zpx7bN>@fHi#5tQRGz6p;wW^k-P7Es*x@Ne^sP@9s)yqUp+D10sT4VsydU= zA+<$WsT-gx@<5_(FsVfH^I)qr~LTk4YJrtZa zcUyHQy>bPVmG z0!JFOg(>PpwcQfR+!U+4rerM(oMQI)%e{T-A-XKH9yE6}R3Ltj?J*BAWvmWi-1a00 zpT^Ee%FqroNdcFr`r9eb2r#xhe4pi}Z1{q}mtGW;M60uIYK<0sla2?%_tLFi4|5i!_;0WFMe3cS7UtP8Tqm=k^lmAC@^55V8 z*a-e-MwXoP4;%TAEt?jDKO3S|TTdEA(t5CZu<6Ky*fL?15=^$~e>ZC3Elg}i9V=+y74fYtsN`1 zwhq%aoYu*N)uzlw9PgZ-8}|YxM5T>19qzwhyRL8+Z>$!AZO84j17J>n4add=Sp_Gp z6Gxv|pH>mjvTC@e@3v=gnH&^I4*uo?MqG z&e;f=rQ!reS(htXuK6Hp;Fkn$Ke=!7w8t!)gdMl2}^)!4uilGMKfCK1TGFiWeJLmI_j0z7#7RpHfatw1k`yjFufjjz7)jDHr04xM)R~3?Xoi ze_G<$gbqRM?;!$2Y4idl*?OMBpD^kCe|_kbF{(w4^Vwr+Svx{iIBT%Luk2Ba#zzyQ zE24mLp{y87FXz+C?xH8>P*3Fu)1@dPzt8rYmqKX6;OYqnGMFalz@{OXrw%a)Pm*Vr zrP*_e3VpvZNyB0v^C{cWvhL2a%gL39Jr)J@*je=0(L!t${eX|(b4$tY5h%yKs*J-T zTdUj6%WeSA#J-S23@0)^h)SJ+7pk4v!MBtOE5Je%Iy?6=dLxLx9iXAeK6QA=P0gZ0 zeBh}u1+{5=&7{3@Y?9K0cj%V{-;)>Z;iL}kTX1$mH`R5e#d z?q?t|Us&s}pQQPu8FabA-JfkvmaH;{Hm8?%iLaaO<2s**>uyejeqY1GFl)hXv_b=Z zm2^`ZN*Oktbedpm(OG<|9JOESLv!re7bG9gog%O|@Hl*i>CSOVf61{0S^l=Nr^(k-1IjW(ZE#e#xX`>Gzj=8H5X9@VVz8{RP`FiW+UiT3Pd+WwwUGESt zT%$hg(@wJ5kQN*fFF|;<4N;9>MG*UCD#cGBLAGjU)BVyPt^m_#BCC*iQM1@dCssHJ z0jWtow8731PlqeE$TN3zYv&rC8GJZB~?b|h!gP;LxSK z%Vh0~lDHWsy&_4kxn$9tRV9d4tbxU*O2amYuB*}g$HQ&6m`#&|-D!2X*7deHG_e;;!N;c%X=7_Pds2DP z81;~<(>cfbr(L1qj|zgRMXo>_8;Tt6xjfrCC1>SW6x?se{)_V9uqGhq_X;e_2d4)%T@{eUm;zJ`s1@UtXc_O-ZkWNAEM6yVO z=HOAi-}YQ-L!6RmmTJ74wz?Vc@Dbk<93<@{O(gdD=8l`%^RL#~wWeZfNc?IiSrOLs zF%(wh$MrduPx!ZiG1gYAtY_A&DryJZ0_l~Q8DVs*H^XUTG3n^+w%>f{R?|~1CpDvN zqQnGERu?k3IE`gpK9UX?%|7x6Cy%-3o>EJ@Xq~?P*8FxCFRr;hGF|V3Fpa;JFozl{ zbX4=XQ-4gm7*-j!YAKveJ;v*khKvIBn3q#xdON(qa1=PVv_gSq`nxIf&LC*_}L>r{8vC5p%}`0{tc>=`b&5fqtM z&l*wGlxgHC<}@?Pz)X`?<{X+=EZcEm2Jq!Y7i#&kZ!{iZbeY}H9`e*UzC*~T7i7Wo zf1#uVAE6s1wZVmD(mec-YONwcxl%Rx(`98Kh@nE&e&s_34$`#we^a-7m7KHoOt2Yq zR4P8lH^ewykfC#2ZchIjP4XO|=t+m_oz23fEh95dH#d_i2E#|IfXyQ!IYF{rD~Q#^ z!Sh*xfdEt6IJ?38{Ud1xG43Scx;0+-?Km~5kyWMSx`^3^y@?~ehZD*`pvYn^SCe(Y z9Qq1&Z8DYSc+s^EiPE;Lan+ERq6^HyKzW!I^bBTg<0j~v^U{$;D|Z$*7i@H_XLN%v z($hqc!~H>KE__tc!iecTYrcoEIU-fjv9lzjf%LlhanjyRbd&rx2S~DY%7xBbwGFDRuA>V&I--$5 zz#B8FB%@FZ8wNqvDl*Fo`YH<1iW6;X2R!`_b<7-p^vGBaHLN>&?7e#V)_Ht3)SG@6 z^^p0Fw&6-f&2JeCi1FbI6CFIP3MEuWGFcy@HAeuZjgq;`V~H%n!cf2qy`N&qH1L`C ze$GFOafhzwDYe{C2T-JlHH!s!;Wx;=UIKJQ)GR*Zc4_X`j1O}Gx?*aUo-=#}Y=KC^ zulyt)zoxc!oWz2C5#q_ym*zF|oM)dUKM+|ZKCBIqe}Mt^1>Ov@x`(-r-~75n4>O*> zNo!wNL=CkZy@_>c9CrFbvrbI21M6L_sxWwa9z_o61 z#@t_3oCdun*`XH^b~RPH!BIkar$RSNqNQILTs$4 z1=m#3Ws8sQ>C{`tPYH=s28^lkekSECK3jo3$y_9psEt_MdJF+Rcs@m;-&NC%5L9Tj zcuwBz>cX_nXjC3D&KmPDa;K(88gYp9A#C3&r@HqK0se-rhkNlnlxBf9f6RFot4Y6E zu$nUKQH8dDgWGqOnvDpe`0U8Nz65-9a!bk;ACN1v*uLdY{rLNv{i9%t={5)O!S)H+ z&zJS0dZ_hO!`nSplUL}@PyqOzXteZ<;IfzT)>0WPHLu9~Y2f-O1o)upF1+m?*q969 zGkcFSb(Zz#ogzXNded9KNm0B6{s8!AIDz3Jb;B@E3XXk;-uLv-4#d4bcrz24xALpe zPr0R?n@8f7KHR0~uAC@nEE|`-0K~+bg=lh=-b)RPB8Tp4w8*1v$f~+0#NBi@=80rG zLbHM3Xb9q3)Ba=bOVBcFnpI+L%N~K-0^ra6LgV zoQGgx@>Fp9_|&gOXj)aFJ2aGeiJp+DS-hVpb`CJWG#&s2R#*RW2CF8)l2lv)fs_&v zDH6#?z@2hy3!&!gNt%fc@!Nm-1}%xV8w&fnqTI0x>*N*9W$ zurS>2km>(UU~8pJRf;mu9NSo1@zl2Jmpy+$)gIw~cgXKV`<=1!G=NGH@`Ac4c9x9z%4ObK z;G7bdN@O|jg?Sf3nrODoqDo!msH&@n^@{eM zqKli`MXZiDI0tP82c;)z6<)$;J^#&N>kYIyl1;+Q4duK$jwT!FfOx&;%-`rT(md{O z2YCR|qGv_C?`53Ls zN|>Nb4r#H{ZpBXzwfJ@8zn#+6Z1cCbfPn9Y(ndXQU1bc9&v@B))5k7zS-fzF zu0uNf)X}d;%|r)cKW0ciK@{w1ke36I}#F>azW)}+{4LVRa6>hFDpE_v<>Yct&Gg7D#X zGr>TW@^tU-s2d#eOdI)f7ZoRtAOTask)AWxcP{A)Ik~dDNT(kCsX4vn8|tx#xZKS! z)f=!a&3$znKlPYE9&LorMehvqKhWHJ3MJShyA-(kxJiI-i01(`?bja$*t!J{ATy85 zwAJnWhw0= zO3gWmwV#rSf3Ss?iOL8npo-biH0DX`PC?qO_;EYHCzI!DWs{NkpiXl`E zSJ@<&hMQlD)nMK#R;BvHg1FsyCl*MWxkAoHZL|Akjbq9{I$C-_s~aBj|xLG{1Q0`fi6&eDmkg6gUWD~<>l@vIkp6aG|8#i4lghZ0RzlvA4k|oTx_|AvmwpblPh3Q?vQ$ zviJ|C(hRLvXDOjz=&2Uh<6N2IgW<2U=!rRJj4Hz1CI)bTZlo{Q!`vT#+X&)}n$Rk) zo{$eg-cAZsuQ_vZw2Os#?{oT}S za^fen2%uW+krK7?=d7&oOlIz{VyIpHMVWFuJ5lVEdoq%0n$_T)?3p`N65YCnVh+;Z`$VmW z$%@g#wr5`?(sM|8Bd^=q${SehcZ@T`B9}Ydz;kzWC8r)3r&)bprs5XYUd@oSAGyDc zH%XJI>yf-`tMO?&D#dF?(>g*v3gsCO2o$m(OQj2hZtpyW3xz*AlFC3Y`aO}=7zuM3 zSKbR0mdB@2_Xu+vEZ|u78HSYk7{gs$<%%FAOob@&36 z{hKz_5IPKGB$Ue8yKcmrhP&zri%crx0z0IbhcD@XeWe$9zD_SMXwHlAC8(b1VSsvk zQ`mmn$(&&-?zU=fj65cSJq)H6{E+z!%&6Cy)_HcSL|>XufSN%u!tJ~#WLTg^)F%SF zeN&DTu@Wz6f#DF{T2p@_qE(gb_|ai>Yrhvt<1I^(G$)hpWb%WvooLH5#Gv2E}-9uvfWH82rJAVfn#*F4&R{UEV@lq zs>PxC)PUPzxh9d$QPsWorDQ{p%l(`1qhAx@2`ZSStlSHEXK2&9*muUrcc~U_@b%2W zczLLsiu4J;rbOpA9)q_S##}Y%kw3ueP2VVhB&j z*q;e%B@o62C5kY_zU1y!Sx*XAIQ?d9z9GDIJz10A_*9nnNP>n*I1QqDFB*}|;Aw>c zW`asRpdxV>y#Xdzi0~rG5_?+<{Alf_+y5>SzUt9NG>hQ>{9`MJ@j1clg-&D+fE*3Vpq z<9t4ucL;IFLQID}02-cNTj(d>LXkrIRQQ^!;Yvo4IUTY{w2tv_AN4ufiYg42Sm--x z0>*@+B=sMm-4Nl+s>ho=nVx}EjM6R@)3t0BOT0UZTA5M7Md6n22Rp%s3}P0ft4Bd3 zMCijn=z04VaE$`8-+c8M4y0aX7_?QwPQ^28reU7vbp_!9VwlOPceZ*%rsXOP3}lX>fDn7_WS_#U8pGF^V?%logMxM@+(Z6Skmq;FcR zD88uWH!7OM+oyZ@K+k{=*a`L64qih0SA7LswNMG zW9<1(`WdkqyoLa&2D(Z0g(SpbL#=`$m6h}FU!t79(`FVYYM@T|sK_7a^>E|>Z(-74 zNLWb3w-yC+%#y*gQ@)&y;9!E%*0;&3o_+uWBP@$b#nag$&||4 z7vC6JAfqt4YG%=^o9;=u0vmY?T?Ac(nwC1S%VDi(12^%H!oswwG6c~Zh>&dN24)>? z7!#YD<-tVeil5I9Z^+u1XL?oa>7L#o&P2vyg9+wVjTKo&^F)){`M+HJaW1t?Vs$GF z=Q4wFn+fsq%{T{eoeG`S&r!WA(G`ItS_$#o_D0FUy!-octo}6BS65MVWiDLD|WSTyJHlU@PIQv%v&Q<);xL3=6F& z;X+`6tC%_}RC}(G%XW>8cA=8|%(U)R6I6sRLs$obMJsDhxDFBDxhe=lvd zV6Q*3`ZN%~-n~A-8UcO>6+B7j2ndY?N;$im7JerhX-d?;!2#-RAcsL@vhf2^DPyk* z=g1xR4>*pbKgHVCsAqQ^LliDw2*0;q`7fH;+)M*ugQps>(j5TohBNM!@-AZq47EcCwj`a=HdEIbHa;Z3!G^dmc``K9&&q!~f+L zgx$r~)J2hs4_#nZ*GEir4-Q2|vOvLQI^{15^Wu->wD~b63m9)MfLAlOeA%@x-DaVxn@V24)f9+a3kR-8Updh z?u%W1h9orH6Be>Or6M(i-L~K~g4td`HiX-DfA}FbkOAhHF?;K3qtC%0Ho1~gZU2{~| z=L3rY8-q>*=6*sI^bxlZpPQqpeOFgSf%QmmLcKBVP@$nE5?54t38A_iZ17Pz_KO9D zQ*;GX^dA=k;j5(bvPB!vZ)R(qEz=>GkWa&RU=rt$?N8znjJwHDwmwF99ijI0vN38u%J*D1`|}InU-#j zj-Z@v0~l7HWpr;4C%69eIv{%Uy^HJhf?8Tz7;`Aw@(mA5RL zcd?#qN((v3+M&SqdzT$3SAzKVw`^D2CN=*srP#!bM{m(V?z`wQrt$5xVes<; zOt3N~@bi6USpGym&-`k40Ry|p(}6=}@Ae$`#YS-im`k-T&8QW6&MR4W?G{*B zbwH71w}z*9-B9{o@?|LTt-Y}m=3W!)qDXub`4O#|f5FNBlkKM&OVnR&_<2zeTr(cXYdUqVI zr#zcI+?3P>nt!qdrAb?WjCfX~H#3{8&pE_dLnC}*un^QSL2l-dqlq8X*_f1*+H<|! zD0f?ZU9=BN&aVJ6tluBCa@`_a@=AXh!2}L~k?kfYcTfbhfo3c!#h!e{_}>}crmvto zq+Y!ar3()+zc)a54FeK@FPy;cJu202w%p6^g%L;JJ;1@`;`;%bQi3j|MEPqsBoRw- zm!P=QKm);OMp?g~aY$&Kx9u6^(D_Jg+)7UlQCSfhxd zBjG`FeLu`%?=4nGDVDOr)^!GFUSBswi0iVi?lo9OaG#r#PI-7+L!m8T&l|f{syEyl z9ew*n&_>N*u%Ji#-;q|2n+LQ&kse`IM_GJiO0+pgrQGfSLIG4uiSHkB8t@#zN0p&m zeDI_kaU2g7MU=5T7u`;Gs7^2RSQJSRpSm;jL~$Z4w`(4KU6MB}6qMhohz5N8ywhsf zm>24#qCp8xBg z_wIuWmKrn<^%t(f9wyFqq)!G!O@EZyd>iYsl zlMMQxjn>fy)X zX2$#Lme2>p6=@e-E}9A?8t6PRZV&dRGBeIkC0sL5YA-d#&4ksYKpRLlSW9qg;rUn| zo-T&L4)kjfb$aP1zI*KfRRPAG2=sB+_}0J*{|>w!A1|W_q{3Fp8KOlq^z=ZCfP*Jj zUlLwF2SnaimR)(x=2o| zx|9WL+fSN{Gh7Guk!ZufhQxH4|JT`dfK&bbf04|}9%avrYg00^w-U0lxh}F@o47J6 zlCraRWMz-ctW>fxlPyJYzhDst1{xFlc6_5T^2usg`xt;XcM5izd?f#Vj>AqBz9Im*epnrOfeh9e<(PA0OS*VXSa(wV+)0BiWb_*81c6irES>8E!>3bX$|)l!~RkDvJ8%{-$!Q;F)D6#Pz>}A}*mB$^xAIoxZHPB#*Vl#h8!(Qm|KPK4$h2f{sI*nKPW=ANu(tf=1#>mp&B8gALRL*$VUU24nVlT)-BqWs3vZP-iQ z@rYAQ@=lcCKgGzQ^2CMv6H9fanp5{|b5-Xp)X@jaD7bxuD(*vCD*{Zf;2@cxNZ9w_ zIdv$FtIoJL=>|V@!!q_iM#smiQm@}OBZmoEzPr?}?f(xx#3al=y>OkTd66q4zPMlT z7-5uFd5U@@`!WJp4sBv=Abd zDw(Rr&8Jsp9rLQh?!Nn!QZMkneQM(-_gwlKvECPd@c|eAx6}zM##UduFOC_wx67YB zrn^DcS#3t}ltNOhg7NHyyXlc_6KyzDt%?FwHmw3!!s%ARv~~wuDS=@7DTX<^Pn=~V3mw9q-l5k6jl{SgpSa)A zP9JuCQ)Qkfo}hXC++A(O?+TA0m_`A^nCo88wg^;lPd|V2TGm$HgoZ^V_=b z|0OK=p@svJRz=h}YhX0m$TY}NyJiz*J|suP=#qipplaY7DZ_5 z*mPj$pkphZuiu3ZqzzHZs2%KyFs$U=lST2N-j!ElM)gOGG1sIBf>_Z-k2jRig*FAD z#UB|=d;U(q+-i_)9P_1!z(P+rF&(!A!cV7{bEGd9a+M#Bo}TGEQ^GKx3!#k)i9gDa zxN6X%j??@mDJX4V2Dg9Z{K)#n$FH!NL@L-}9Ua4-nXj4Xyt}#dS*xAAf84LqLJ#iablv{`dv){H(mi`e zxz^;2AYrSCQ~E_h*T#-Bb ziRdh}xq<4KR3Yw^fcO>1WaB!HZ$}wgj*W~*n0^<+?mR!9cS9Y{+Y>ag81@_z8Zq7$ zi$)X`�Zy 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 literal 54413 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 \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -89,84 +140,105 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" - -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/cuebot/gradlew.bat b/cuebot/gradlew.bat index e95643d6a..6689b85be 100644 --- a/cuebot/gradlew.bat +++ b/cuebot/gradlew.bat @@ -1,4 +1,20 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -9,19 +25,23 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,38 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/cuebot/settings.gradle b/cuebot/settings.gradle index 3bbd8d724..b2e7bcc16 100644 --- a/cuebot/settings.gradle +++ b/cuebot/settings.gradle @@ -1,2 +1,19 @@ -rootProject.name = 'cuebot' +pluginManagement { + repositories { + maven { url 'https://repo.spring.io/plugins-snapshot' } + maven { + url = uri("https://plugins.gradle.org/m2/") + } + mavenCentral() + jcenter() + } + resolutionStrategy { + eachPlugin { + if (requested.id.getName() == 'protobuf') { + useModule('com.google.protobuf:protobuf-gradle-plugin:0.9.1') + } + } + } +} +rootProject.name = 'cuebot' \ No newline at end of file diff --git a/cuebot/src/test/java/com/imageworks/spcue/test/service/JobSpecTests.java b/cuebot/src/test/java/com/imageworks/spcue/test/service/JobSpecTests.java index 119be6160..87b3c415c 100644 --- a/cuebot/src/test/java/com/imageworks/spcue/test/service/JobSpecTests.java +++ b/cuebot/src/test/java/com/imageworks/spcue/test/service/JobSpecTests.java @@ -80,8 +80,7 @@ public void testParseNonExistent() { jobLauncher.parse(xml); fail("Expected exception"); } catch (SpecBuilderException e) { - assertEquals(e.getMessage(), - "Failed to parse job spec XML, java.net.MalformedURLException"); + assertTrue(e.getMessage().startsWith("Failed to parse job spec XML, java.net.MalformedURLException")); } } From 6b1f9121dc92ae9a7dfd505701537b1db8fc6a6c Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 1 Oct 2024 15:51:04 -0700 Subject: [PATCH 22/40] [cuegot] Upgrade gradle version on dockerfile (#1523) The version of gradle was updated on https://github.com/AcademySoftwareFoundation/OpenCue/pull/1393 but the change didn't make it to the Dockerfile. --- cuebot/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cuebot/Dockerfile b/cuebot/Dockerfile index d82b861cc..c16e8a129 100644 --- a/cuebot/Dockerfile +++ b/cuebot/Dockerfile @@ -1,7 +1,7 @@ # ----------------- # BUILD # ----------------- -FROM gradle:6.0.1-jdk13 AS build +FROM gradle:7.6.4-jdk17 AS build USER gradle From aa3d1f92fdd9430356281a32a64572ae193bbe28 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 1 Oct 2024 16:43:57 -0700 Subject: [PATCH 23/40] [cuebot] Fix integration pipeline (#1524) * Upgrade `io.zonky.test.embedded-postgres` and add required package for building on MacOS environments * Add `mainClassName` to solve issues on the newer version of gradle where the main class was not being found on the generated jar --- cuebot/build.gradle | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cuebot/build.gradle b/cuebot/build.gradle index 911e5b498..0a6d08c1a 100644 --- a/cuebot/build.gradle +++ b/cuebot/build.gradle @@ -56,7 +56,8 @@ dependencies { testImplementation group: 'junit', name: 'junit', version: '4.12' testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test' testImplementation group: 'org.assertj', name: 'assertj-core', version: '3.8.0' - testImplementation group: 'io.zonky.test', name: 'embedded-postgres', version: '1.3.1' + testImplementation group: 'io.zonky.test', name: 'embedded-postgres', version: '2.0.1' + testImplementation group: 'io.zonky.test.postgres', name: 'embedded-postgres-binaries-linux-arm64v8', version: '15.2.0' testImplementation group: 'org.flywaydb', name: 'flyway-core', version: '5.2.0' // Use newer version of Postgres for tests: https://github.com/zonkyio/embedded-postgres/issues/78 @@ -106,12 +107,9 @@ sourceSets { } } -jar { - enabled = true -} - bootJar { baseName = 'cuebot' + mainClassName = 'com.imageworks.spcue.CuebotApplication' } jacoco { From 93b765203d0075e89022c6bd471b370057c0cce7 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 1 Oct 2024 19:59:18 -0700 Subject: [PATCH 24/40] [cuebot] Fix packaging pipeline (#1525) Fix both the registry issues for images that rely on centos7 and the pip package compatibility issues for python 3.6. Some changes on PR #1416 didn't take into account that part of the packaging pipeline relies on centos7, which doesn't support python3.7 without installing from source. On the next release, support to rhl7 might be dropped as we upgrade the pipeline to rely on rocky9. --- connectors/prometheus_metrics/Dockerfile | 5 +++++ cuegui/Dockerfile | 5 +++++ cuesubmit/Dockerfile | 5 +++++ requirements.txt | 12 ++++++++---- requirements_gui.txt | 3 ++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/connectors/prometheus_metrics/Dockerfile b/connectors/prometheus_metrics/Dockerfile index 9b8039568..f710dce0c 100644 --- a/connectors/prometheus_metrics/Dockerfile +++ b/connectors/prometheus_metrics/Dockerfile @@ -3,6 +3,11 @@ ENV PYTHONUNBUFFERED 1 WORKDIR /opt/opencue +# centos:7 repos moved to vault.centos +RUN sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/CentOS-*.repo + RUN yum -y install \ epel-release \ gcc \ diff --git a/cuegui/Dockerfile b/cuegui/Dockerfile index 8b0b189f9..2d83f8f41 100644 --- a/cuegui/Dockerfile +++ b/cuegui/Dockerfile @@ -2,6 +2,11 @@ FROM --platform=linux/x86_64 centos:7 WORKDIR /src +# centos:7 repos moved to vault.centos +RUN sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/CentOS-*.repo + RUN yum -y install \ epel-release \ fontconfig \ diff --git a/cuesubmit/Dockerfile b/cuesubmit/Dockerfile index 6f97d671f..4f007dee8 100644 --- a/cuesubmit/Dockerfile +++ b/cuesubmit/Dockerfile @@ -2,6 +2,11 @@ FROM --platform=linux/x86_64 centos:7 WORKDIR /src +# centos:7 repos moved to vault.centos +RUN sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/CentOS-*.repo +RUN sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/CentOS-*.repo + RUN yum -y install \ epel-release \ gcc \ diff --git a/requirements.txt b/requirements.txt index 8a8014378..e146b7d3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,17 @@ 2to3==1.0 enum34==1.1.6 future==1.0.0 -grpcio==1.53.2 -grpcio-tools==1.53.2 +grpcio==1.48.2;python_version<"3.7" +grpcio-tools==1.48.2;python_version<"3.7" +grpcio==1.53.2;python_version>="3.7" +grpcio-tools==1.53.0;python_version>="3.7" mock==2.0.0 packaging==20.9 psutil==5.9.8 -pyfakefs==5.2.3 -pylint==2.15.10 +pyfakefs==3.6;python_version<"3.7" +pyfakefs==5.2.3;python_version>="3.7" +pylint==2.13.9;python_version<"3.7" +pylint==2.15.10;python_version>="3.7" pynput==1.7.6 PyYAML==5.1 six==1.16.0 diff --git a/requirements_gui.txt b/requirements_gui.txt index eb5e544d4..1f5b19637 100644 --- a/requirements_gui.txt +++ b/requirements_gui.txt @@ -1,4 +1,5 @@ PySide6==6.7.1;python_version>"3.11" PySide6==6.5.3;python_version=="3.11" PySide2==5.15.2.1;python_version<="3.10" -QtPy==2.4.1 +QtPy==1.11.3;python_version<"3.7" +QtPy==2.4.1;python_version>="3.7" From a3e9ccb7259313cfb5d5f3a0070b4d72f2a9542b Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Wed, 2 Oct 2024 09:16:27 -0700 Subject: [PATCH 25/40] Fix Packaging pipeline (#1526) Upgrades on setuptools and pip ended up breaking our packaging pipeline. Changes on the packaging docker files and requirements.txt were required to fix the build. --- cueadmin/Dockerfile | 6 +++--- cueadmin/setup.py | 2 -- cuegui/Dockerfile | 2 +- cuegui/setup.py | 2 -- cuesubmit/Dockerfile | 2 +- cuesubmit/setup.py | 2 -- pycue/Dockerfile | 5 +++-- pycue/setup.py | 2 -- pyoutline/Dockerfile | 6 +++--- pyoutline/setup.py | 2 -- requirements.txt | 2 +- rqd/setup.py | 2 -- 12 files changed, 12 insertions(+), 23 deletions(-) diff --git a/cueadmin/Dockerfile b/cueadmin/Dockerfile index 74b318d46..be4ad1ab2 100644 --- a/cueadmin/Dockerfile +++ b/cueadmin/Dockerfile @@ -32,10 +32,10 @@ COPY cueadmin/tests/ ./cueadmin/tests COPY cueadmin/cueadmin ./cueadmin/cueadmin COPY VERSION.in VERSIO[N] ./ -RUN test -e VERSION || echo "$(cat VERSION.in)-custom" | tee VERSION +RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION -RUN cd pycue && python3 setup.py install -RUN cd cueadmin && python3 setup.py test +RUN cd pycue && python3 -m pip install . +RUN cd pycue && python3 -m unittest tests/*.py RUN cp LICENSE requirements.txt VERSION cueadmin/ diff --git a/cueadmin/setup.py b/cueadmin/setup.py index 3b829a089..cceac811d 100644 --- a/cueadmin/setup.py +++ b/cueadmin/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', diff --git a/cuegui/Dockerfile b/cuegui/Dockerfile index 2d83f8f41..3e6630804 100644 --- a/cuegui/Dockerfile +++ b/cuegui/Dockerfile @@ -62,7 +62,7 @@ COPY cuegui/tests ./cuegui/tests COPY cuegui/cuegui ./cuegui/cuegui COPY VERSION.in VERSIO[N] ./ -RUN test -e VERSION || echo "$(cat VERSION.in)-custom" | tee VERSION +RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION RUN cd pycue && python3.6 setup.py install diff --git a/cuegui/setup.py b/cuegui/setup.py index 1c7c6455b..6ca45dc2b 100644 --- a/cuegui/setup.py +++ b/cuegui/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', diff --git a/cuesubmit/Dockerfile b/cuesubmit/Dockerfile index 4f007dee8..eb2a4902a 100644 --- a/cuesubmit/Dockerfile +++ b/cuesubmit/Dockerfile @@ -56,7 +56,7 @@ COPY cuesubmit/plugins ./cuesubmit/plugins COPY cuesubmit/cuesubmit ./cuesubmit/cuesubmit COPY VERSION.in VERSIO[N] ./ -RUN test -e VERSION || echo "$(cat VERSION.in)-custom" | tee VERSION +RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION RUN cd pycue && python3.6 setup.py install RUN cd pyoutline && python3.6 setup.py install diff --git a/cuesubmit/setup.py b/cuesubmit/setup.py index e9b82d3b4..1bb39a057 100644 --- a/cuesubmit/setup.py +++ b/cuesubmit/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', diff --git a/pycue/Dockerfile b/pycue/Dockerfile index e93f9526a..1bdeef074 100644 --- a/pycue/Dockerfile +++ b/pycue/Dockerfile @@ -28,9 +28,10 @@ RUN python3 -m grpc_tools.protoc \ RUN 2to3 -wn -f import pycue/opencue/compiled_proto/*_pb2*.py COPY VERSION.in VERSIO[N] ./ -RUN test -e VERSION || echo "$(cat VERSION.in)-custom" | tee VERSION +RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION -RUN cd pycue && python3 setup.py test +RUN cd pycue && python3 -m pip install . +RUN cd pycue && python3 -m unittest tests/*.py RUN cp LICENSE requirements.txt VERSION pycue/ diff --git a/pycue/setup.py b/pycue/setup.py index 0750ab016..ad6e29f87 100644 --- a/pycue/setup.py +++ b/pycue/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', diff --git a/pyoutline/Dockerfile b/pyoutline/Dockerfile index 02f954c23..28bb89718 100644 --- a/pyoutline/Dockerfile +++ b/pyoutline/Dockerfile @@ -34,10 +34,10 @@ COPY pyoutline/wrappers ./pyoutline/wrappers COPY pyoutline/outline ./pyoutline/outline COPY VERSION.in VERSIO[N] ./ -RUN test -e VERSION || echo "$(cat VERSION.in)-custom" | tee VERSION +RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION -RUN cd pycue && python3 setup.py install -RUN cd pyoutline && python3 setup.py test +RUN cd pycue && python3 -m pip install . +RUN cd pycue && python3 -m unittest tests/*.py RUN cp LICENSE requirements.txt VERSION pyoutline/ diff --git a/pyoutline/setup.py b/pyoutline/setup.py index 0ecc79e0c..c9d2ed088 100644 --- a/pyoutline/setup.py +++ b/pyoutline/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', diff --git a/requirements.txt b/requirements.txt index e146b7d3f..4ec409819 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ grpcio-tools==1.48.2;python_version<"3.7" grpcio==1.53.2;python_version>="3.7" grpcio-tools==1.53.0;python_version>="3.7" mock==2.0.0 -packaging==20.9 +packaging==24.1 psutil==5.9.8 pyfakefs==3.6;python_version<"3.7" pyfakefs==5.2.3;python_version>="3.7" diff --git a/rqd/setup.py b/rqd/setup.py index c1f7b9cc4..a9735b142 100644 --- a/rqd/setup.py +++ b/rqd/setup.py @@ -41,8 +41,6 @@ classifiers=[ 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', From c2904f25e75309262c21d1cf389e665938fc3ffd Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Wed, 2 Oct 2024 09:34:42 -0700 Subject: [PATCH 26/40] Make packaging pip package version specific (#1527) Python3.6 doesn't support packaging 24 --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ec409819..cceee9237 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,8 @@ grpcio-tools==1.48.2;python_version<"3.7" grpcio==1.53.2;python_version>="3.7" grpcio-tools==1.53.0;python_version>="3.7" mock==2.0.0 -packaging==24.1 +packaging==20.9;python_version<"3.7" +packaging==24.1;python_version>="3.7" psutil==5.9.8 pyfakefs==3.6;python_version<"3.7" pyfakefs==5.2.3;python_version>="3.7" From d60caff884b24477ee18c838acd1310a812c4bd6 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Wed, 2 Oct 2024 10:50:52 -0700 Subject: [PATCH 27/40] Another attempt on fixing the packaging pipeline (#1528) Some packages require pycue to run their unit tests and this requirement wasn't being installed on the dockerfile. A `test_suite.py` file was added with the rules for test discover to make sure all unit tests are executed on projects that are built with python3.9. --- cueadmin/Dockerfile | 2 +- cueadmin/tests/test_suite.py | 27 +++++++++++++++++++++++++++ pycue/Dockerfile | 2 +- pycue/tests/test_suite.py | 27 +++++++++++++++++++++++++++ pyoutline/Dockerfile | 3 ++- pyoutline/tests/test_suite.py | 27 +++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 cueadmin/tests/test_suite.py create mode 100644 pycue/tests/test_suite.py create mode 100644 pyoutline/tests/test_suite.py diff --git a/cueadmin/Dockerfile b/cueadmin/Dockerfile index be4ad1ab2..20ff77c1e 100644 --- a/cueadmin/Dockerfile +++ b/cueadmin/Dockerfile @@ -35,7 +35,7 @@ COPY VERSION.in VERSIO[N] ./ RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION RUN cd pycue && python3 -m pip install . -RUN cd pycue && python3 -m unittest tests/*.py +RUN cd cueadmin && python3 tests/test_suite.py RUN cp LICENSE requirements.txt VERSION cueadmin/ diff --git a/cueadmin/tests/test_suite.py b/cueadmin/tests/test_suite.py new file mode 100644 index 000000000..5772b9476 --- /dev/null +++ b/cueadmin/tests/test_suite.py @@ -0,0 +1,27 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=missing-function-docstring,missing-module-docstring + +import unittest + +def create_test_suite(): + loader = unittest.TestLoader() + start_dir = '.' # Specify the directory where your test files reside + suite = loader.discover(start_dir, pattern='*_tests.py') + return suite + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + test_suite = create_test_suite() + runner.run(test_suite) diff --git a/pycue/Dockerfile b/pycue/Dockerfile index 1bdeef074..9698e94bc 100644 --- a/pycue/Dockerfile +++ b/pycue/Dockerfile @@ -31,7 +31,7 @@ COPY VERSION.in VERSIO[N] ./ RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION RUN cd pycue && python3 -m pip install . -RUN cd pycue && python3 -m unittest tests/*.py +RUN cd pycue && python3 tests/test_suite.py RUN cp LICENSE requirements.txt VERSION pycue/ diff --git a/pycue/tests/test_suite.py b/pycue/tests/test_suite.py new file mode 100644 index 000000000..50937843d --- /dev/null +++ b/pycue/tests/test_suite.py @@ -0,0 +1,27 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=missing-function-docstring,missing-module-docstring + +import unittest + +def create_test_suite(): + loader = unittest.TestLoader() + start_dir = '.' # Specify the directory where your test files reside + suite = loader.discover(start_dir, pattern='*_test.py') + return suite + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + test_suite = create_test_suite() + runner.run(test_suite) diff --git a/pyoutline/Dockerfile b/pyoutline/Dockerfile index 28bb89718..bc7155daf 100644 --- a/pyoutline/Dockerfile +++ b/pyoutline/Dockerfile @@ -36,8 +36,9 @@ COPY pyoutline/outline ./pyoutline/outline COPY VERSION.in VERSIO[N] ./ RUN test -e VERSION || echo "$(cat VERSION.in)" | tee VERSION +# Install pycue as pyoutline depends on it to run tests RUN cd pycue && python3 -m pip install . -RUN cd pycue && python3 -m unittest tests/*.py +RUN cd pyoutline && python3 tests/test_suite.py RUN cp LICENSE requirements.txt VERSION pyoutline/ diff --git a/pyoutline/tests/test_suite.py b/pyoutline/tests/test_suite.py new file mode 100644 index 000000000..50937843d --- /dev/null +++ b/pyoutline/tests/test_suite.py @@ -0,0 +1,27 @@ +# Copyright Contributors to the OpenCue Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# pylint: disable=missing-function-docstring,missing-module-docstring + +import unittest + +def create_test_suite(): + loader = unittest.TestLoader() + start_dir = '.' # Specify the directory where your test files reside + suite = loader.discover(start_dir, pattern='*_test.py') + return suite + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + test_suite = create_test_suite() + runner.run(test_suite) From 810bee505f5b74697e4ffe2d8238a7391dbd9abe Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Wed, 2 Oct 2024 15:59:57 -0700 Subject: [PATCH 28/40] Fix pipeline 3 (#1529) --- .github/workflows/sonar-cloud-pipeline.yml | 2 +- ci/python_coverage_report.sh | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sonar-cloud-pipeline.yml b/.github/workflows/sonar-cloud-pipeline.yml index 569544f8c..cd0ec030b 100644 --- a/.github/workflows/sonar-cloud-pipeline.yml +++ b/.github/workflows/sonar-cloud-pipeline.yml @@ -8,7 +8,7 @@ on: jobs: analyze_python: runs-on: ubuntu-latest - container: aswf/ci-opencue:2020 + container: aswf/ci-opencue:2024.1 name: Analyze Python Components env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true diff --git a/ci/python_coverage_report.sh b/ci/python_coverage_report.sh index e21f0259e..e0c65328f 100755 --- a/ci/python_coverage_report.sh +++ b/ci/python_coverage_report.sh @@ -2,7 +2,9 @@ set -e -pip install --user -r requirements.txt -r requirements_gui.txt +python -m pip install --user -r requirements.txt -r requirements_gui.txt +# Requirements for running the tests on the vfx-platform images +python -m pip install coverage pytest-xvfb # Protos need to have their Python code generated in order for tests to pass. python -m grpc_tools.protoc -I=proto/ --python_out=pycue/opencue/compiled_proto --grpc_python_out=pycue/opencue/compiled_proto proto/*.proto @@ -11,12 +13,13 @@ python -m grpc_tools.protoc -I=proto/ --python_out=rqd/rqd/compiled_proto --grpc 2to3 -wn -f import rqd/rqd/compiled_proto/*_pb2*.py # Run coverage for each component individually, but append it all into the same report. -coverage run --source=pycue/opencue/,pycue/FileSequence/ --omit=pycue/opencue/compiled_proto/* pycue/setup.py test -PYTHONPATH=pycue coverage run -a --source=pyoutline/outline/ pyoutline/setup.py test -PYTHONPATH=pycue coverage run -a --source=cueadmin/cueadmin/ cueadmin/setup.py test -PYTHONPATH=pycue xvfb-run -d coverage run -a --source=cuegui/cuegui/ cuegui/setup.py test -PYTHONPATH=pycue:pyoutline coverage run -a --source=cuesubmit/cuesubmit/ cuesubmit/setup.py test -coverage run -a --source=rqd/rqd/ --omit=rqd/rqd/compiled_proto/* rqd/setup.py test +python -m coverage run --source=pycue/opencue/,pycue/FileSequence/ --omit=pycue/opencue/compiled_proto/* pycue/tests/test_suite.py +PYTHONPATH=pycue python -m coverage run -a --source=pyoutline/outline/ pyoutline/setup.py test +PYTHONPATH=pycue python -m coverage run -a --source=cueadmin/cueadmin/ cueadmin/setup.py test +# TODO: re-enable cuegui tests when xvfb-run gets configured to execute on the new vfx-platform +# PYTHONPATH=pycue xvfb-run -d python -m coverage run -a --source=cuegui/cuegui/ cuegui/setup.py test +PYTHONPATH=pycue:pyoutline python -m coverage run -a --source=cuesubmit/cuesubmit/ cuesubmit/setup.py test +python -m coverage run -a --source=rqd/rqd/ --omit=rqd/rqd/compiled_proto/* rqd/setup.py test # SonarCloud needs the report in XML. -coverage xml +python -m coverage xml From e3431527f5493183a144e06769cb6216ad33ae72 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Fri, 4 Oct 2024 13:57:53 -0700 Subject: [PATCH 29/40] [rqd] Fix rqd cache spill issue (#1531) Ensure frames are removed from the rqd cache when they fail to complete their report process Co-authored-by: Ramon Figueired --- .gitignore | 7 ++--- rqd/rqd/rqcore.py | 75 ++++++++++++++++++++++++++++++-------------- rqd/rqd/rqmachine.py | 1 + rqd/rqd/rqnetwork.py | 1 + 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index ecdefd280..28f8248ea 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,6 @@ htmlcov/ .vscode .venv/ .eggs/* -.gradle/* -/cuebot/logs -/cuebot/bin -/logs \ No newline at end of file +/cuebot/bin/* +/logs/* +/.gradle/* \ No newline at end of file diff --git a/rqd/rqd/rqcore.py b/rqd/rqd/rqcore.py index a72d94c0a..5b85efe75 100644 --- a/rqd/rqd/rqcore.py +++ b/rqd/rqd/rqcore.py @@ -237,29 +237,6 @@ def __writeFooter(self): "Unable to write footer: %s due to %s at %s", self.runFrame.log_dir_file, e, traceback.extract_tb(sys.exc_info()[2])) - def __sendFrameCompleteReport(self): - """Send report to cuebot that frame has finished""" - report = rqd.compiled_proto.report_pb2.FrameCompleteReport() - # pylint: disable=no-member - report.host.CopyFrom(self.rqCore.machine.getHostInfo()) - report.frame.CopyFrom(self.frameInfo.runningFrameInfo()) - # pylint: enable=no-member - - if self.frameInfo.exitStatus is None: - report.exit_status = 1 - else: - report.exit_status = self.frameInfo.exitStatus - - report.exit_signal = self.frameInfo.exitSignal - report.run_time = int(self.frameInfo.runTime) - - # If nimby is active, then frame must have been killed by nimby - # Set the exitSignal to indicate this event - if self.rqCore.nimby.locked and not self.runFrame.ignore_nimby: - report.exit_status = rqd.rqconstants.EXITSTATUS_FOR_NIMBY_KILL - - self.rqCore.network.reportRunningFrameCompletion(report) - def __cleanup(self): """Cleans up temporary files""" rqd.rqutil.permissionsHigh() @@ -551,7 +528,7 @@ def run(self): self.rqCore.deleteFrame(self.runFrame.frame_id) - self.__sendFrameCompleteReport() + self.rqCore.sendFrameCompleteReport(self.frameInfo) time_till_next = ( (self.rqCore.intervalStartTime + self.rqCore.intervalSleepTime) - time.time()) if time_till_next > (2 * rqd.rqconstants.RQD_MIN_PING_INTERVAL_SEC): @@ -723,6 +700,9 @@ def deleteFrame(self, frameId): self.cores.reserved_cores) # pylint: disable=no-member self.cores.reserved_cores.clear() + log.info("Successfully delete frame with Id: %s", frameId) + else: + log.warning("Frame with Id: %s not found in cache", frameId) def killAllFrame(self, reason): """Will execute .kill() on every frame in cache until no frames remain @@ -1080,3 +1060,50 @@ def sendStatusReport(self): def isWaitingForIdle(self): """Returns whether the host is waiting until idle to take some action.""" return self.__whenIdle + + def sendFrameCompleteReport(self, runningFrame): + """Send a frameCompleteReport to Cuebot""" + if not runningFrame.completeReportSent: + report = rqd.compiled_proto.report_pb2.FrameCompleteReport() + # pylint: disable=no-member + report.host.CopyFrom(self.machine.getHostInfo()) + report.frame.CopyFrom(runningFrame.runningFrameInfo()) + # pylint: enable=no-member + + if runningFrame.exitStatus is None: + report.exit_status = 1 + else: + report.exit_status = runningFrame.exitStatus + + report.exit_signal = runningFrame.exitSignal + report.run_time = int(runningFrame.runTime) + + # If nimby is active, then frame must have been killed by nimby + # Set the exitSignal to indicate this event + if self.nimby.locked and not runningFrame.ignoreNimby: + report.exit_status = rqd.rqconstants.EXITSTATUS_FOR_NIMBY_KILL + + self.network.reportRunningFrameCompletion(report) + runningFrame.completeReportSent = True + + def sanitizeFrames(self): + """ + Iterate over the cache and update the status of frames that might have + completed but never reported back to cuebot. + """ + for frameId, runningFrame in self.__cache.items(): + # If the frame was marked as completed (exitStatus) and a report has not been sent + # try to file the report again + if runningFrame.exitStatus is not None and not runningFrame.completeReportSent: + try: + self.sendFrameCompleteReport(runningFrame) + self.deleteFrame(frameId) + log.info("Successfully deleted frame from cache for %s/%s (%s)", + runningFrame.runFrame.job_name, + runningFrame.runFrame.frame_name, + frameId) + # pylint: disable=broad-except + except Exception: + log.exception("Failed to sanitize frame %s/%s", + runningFrame.runFrame.job_name, + runningFrame.runFrame.frame_name) diff --git a/rqd/rqd/rqmachine.py b/rqd/rqd/rqmachine.py index fc7fa59ab..1f67798e3 100644 --- a/rqd/rqd/rqmachine.py +++ b/rqd/rqd/rqmachine.py @@ -809,6 +809,7 @@ def getHostReport(self): self.__hostReport.host.CopyFrom(self.getHostInfo()) self.__hostReport.ClearField('frames') + self.__rqCore.sanitizeFrames() for frameKey in self.__rqCore.getFrameKeys(): try: info = self.__rqCore.getFrame(frameKey).runningFrameInfo() diff --git a/rqd/rqd/rqnetwork.py b/rqd/rqd/rqnetwork.py index de1b38475..b4b955b10 100644 --- a/rqd/rqd/rqnetwork.py +++ b/rqd/rqd/rqnetwork.py @@ -79,6 +79,7 @@ def __init__(self, rqCore, runFrame): self.lluTime = 0 self.childrenProcs = {} + self.completeReportSent = False def runningFrameInfo(self): """Returns the RunningFrameInfo object""" From 6f7702e5c36baf75bebbf1b56811ee208022ae8f Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 8 Oct 2024 09:49:26 -0700 Subject: [PATCH 30/40] [cuegui] Fix issue on opening job comments (#1532) CommentListDialog expects a list of jobs and at some point the input got changed to the first job on the list. Opening the comments dialog on the current version returns: ``` TypeError: 'Job' object is not iterable ``` --- cuegui/cuegui/MenuActions.py | 2 +- cuegui/tests/MenuActions_tests.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cuegui/cuegui/MenuActions.py b/cuegui/cuegui/MenuActions.py index d19637ee3..287b2eeeb 100644 --- a/cuegui/cuegui/MenuActions.py +++ b/cuegui/cuegui/MenuActions.py @@ -581,7 +581,7 @@ def dropInternalDependencies(self, rpcObjects=None): def viewComments(self, rpcObjects=None): jobs = self._getOnlyJobObjects(rpcObjects) if jobs: - cuegui.Comments.CommentListDialog(jobs[0], self._caller).show() + cuegui.Comments.CommentListDialog(jobs, self._caller).show() dependWizard_info = ["Dependency &Wizard...", None, "configure"] diff --git a/cuegui/tests/MenuActions_tests.py b/cuegui/tests/MenuActions_tests.py index 81ce5c88a..1e76da572 100644 --- a/cuegui/tests/MenuActions_tests.py +++ b/cuegui/tests/MenuActions_tests.py @@ -354,7 +354,7 @@ def test_viewComments(self, commentListMock): self.job_actions.viewComments(rpcObjects=[job]) - commentListMock.assert_called_with(job, self.widgetMock) + commentListMock.assert_called_with([job], self.widgetMock) commentListMock.return_value.show.assert_called() @mock.patch('cuegui.DependWizard.DependWizard') From 6e2ed873a39872f3f96c8622e893b502002db5c0 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 8 Oct 2024 09:49:41 -0700 Subject: [PATCH 31/40] Update python and vfx-platform support on README (#1530) Signed-off-by: Diego Tavares --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1a7fa4871..cba071184 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![OpenCue](/images/opencue_logo_with_text.png) -[![Supported VFX Platform Versions](https://img.shields.io/badge/vfx%20platform-2019--2021-lightgrey.svg)](http://www.vfxplatform.com/) -![Supported Python Versions](https://img.shields.io/badge/python-2.7%2C%203.6%2C%203.7%2C%203.8%2C%203.9-blue.svg) +[![Supported VFX Platform Versions](https://img.shields.io/badge/vfx%20platform-2021--2024-lightgrey.svg)](http://www.vfxplatform.com/) +![Supported Python Versions](https://img.shields.io/badge/python-3.6+-blue.svg) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/2837/badge)](https://bestpractices.coreinfrastructure.org/projects/2837) - [Introduction](#Introduction) From 573dd6be2d90d63edc074995ca35d40c1f85ca75 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Wed, 9 Oct 2024 11:11:17 -0700 Subject: [PATCH 32/40] [rqd] Fix issue on rqd when killing a frame that no longer exists (#1533) When killing a frame on a rqd node that has restarted and lost track of its running frames, the request should return a status to be handled accordingly and trigger the lostProc logic. The current behavior leaves the frame stuck at RUNNING until the job itself is killed or eaten. --- rqd/rqd/rqdservicers.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rqd/rqd/rqdservicers.py b/rqd/rqd/rqdservicers.py index 324a5a51a..c7a9d386f 100644 --- a/rqd/rqd/rqdservicers.py +++ b/rqd/rqd/rqdservicers.py @@ -68,6 +68,10 @@ def KillRunningFrame(self, request, context): if frame: frame.kill(message=request.message) else: + context.set_details( + "The requested frame to kill was not found. frameId: {}".format( + request.frame_id)) + context.set_code(grpc.StatusCode.NOT_FOUND) log.warning("Wasn't able to find frame(%s) to kill", request.frame_id) return rqd.compiled_proto.rqd_pb2.RqdStaticKillRunningFrameResponse() From e5ce1edef8f0a31f933e5da9689eff42ab2b70db Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 15 Oct 2024 10:09:59 -0700 Subject: [PATCH 33/40] Log integration tests to stdout (#1534) Logs are being piped into a file that cannot be accessed from the cicd pipeline, which makes it impossible to find issues that are specific to the cicd environment. --- ci/run_integration_test.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ci/run_integration_test.sh b/ci/run_integration_test.sh index bd7b3f906..ddb2fb699 100755 --- a/ci/run_integration_test.sh +++ b/ci/run_integration_test.sh @@ -243,9 +243,8 @@ main() { log INFO "Creating Python virtual environment..." create_and_activate_venv log INFO "Installing OpenCue Python libraries..." - install_log="${TEST_LOGS}/install-client-sources.log" - sandbox/install-client-sources.sh &>"${install_log}" - log INFO "Testing pycue library..." + sandbox/install-client-sources.sh + log INFO "Testing pycue library..."£ test_pycue log INFO "Testing cueadmin..." test_cueadmin From 78df440c8f58453ba6bb5d9f1c5f575632c93a2e Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 15 Oct 2024 11:31:01 -0700 Subject: [PATCH 34/40] Downgrade ubuntu-latest to ubuntu-22.04 (#1535) grpcio and grpcio-tools packages are failing to be built on pip for ubuntu-latest. For now we're locking the ubuntu version to make the pipeline more stable. --- .github/workflows/packaging-pipeline.yml | 6 +++--- .github/workflows/post-release-pipeline.yml | 4 ++-- .github/workflows/release-pipeline.yml | 6 +++--- .github/workflows/sonar-cloud-pipeline.yml | 4 ++-- .github/workflows/testing-pipeline.yml | 22 ++++++++++----------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/packaging-pipeline.yml b/.github/workflows/packaging-pipeline.yml index c7ab1f85c..f53e6a8a4 100644 --- a/.github/workflows/packaging-pipeline.yml +++ b/.github/workflows/packaging-pipeline.yml @@ -8,7 +8,7 @@ on: jobs: integration_test: name: Run Integration Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v3 @@ -58,7 +58,7 @@ jobs: ARTIFACTS: cueadmin-${BUILD_ID}-all.tar.gz name: Build ${{ matrix.NAME }} - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v3 @@ -121,7 +121,7 @@ jobs: create_other_artifacts: name: Create Other Build Artifacts needs: build_components - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.github/workflows/post-release-pipeline.yml b/.github/workflows/post-release-pipeline.yml index 084e47d49..f2ecbba38 100644 --- a/.github/workflows/post-release-pipeline.yml +++ b/.github/workflows/post-release-pipeline.yml @@ -8,7 +8,7 @@ on: jobs: create_blog_post: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: Create Blog Post steps: - name: Trigger blog post workflow @@ -18,7 +18,7 @@ jobs: WORKFLOW_ID: 2618928 run: | # Trigger the workflow in the opencue.io repository. - + curl -X POST \ -H "Accept: application/vnd.github.v3+json" \ -H "Content-Type: application/json" \ diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index 48aeb9f5a..30f8f5865 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -8,7 +8,7 @@ on: jobs: preflight: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 name: Preflight steps: - name: Checkout code @@ -37,7 +37,7 @@ jobs: release_docker_images: needs: preflight - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 strategy: matrix: component: [cuebot, rqd, pycue, pyoutline, cuegui, cuesubmit, cueadmin] @@ -78,7 +78,7 @@ jobs: create_release: needs: preflight name: Create Release - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/sonar-cloud-pipeline.yml b/.github/workflows/sonar-cloud-pipeline.yml index cd0ec030b..87dfcd693 100644 --- a/.github/workflows/sonar-cloud-pipeline.yml +++ b/.github/workflows/sonar-cloud-pipeline.yml @@ -7,7 +7,7 @@ on: jobs: analyze_python: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2024.1 name: Analyze Python Components env: @@ -30,7 +30,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} analyze_cuebot: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2024.1 name: Analyze Cuebot env: diff --git a/.github/workflows/testing-pipeline.yml b/.github/workflows/testing-pipeline.yml index 63f09afe2..dd82f4dc5 100644 --- a/.github/workflows/testing-pipeline.yml +++ b/.github/workflows/testing-pipeline.yml @@ -9,7 +9,7 @@ on: jobs: test_python_2022: name: Run Python Unit Tests (CY2022) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2022 env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true @@ -20,7 +20,7 @@ jobs: test_cuebot_2022: name: Build Cuebot and Run Unit Tests (CY2022) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: image: aswf/ci-opencue:2022 env: @@ -34,7 +34,7 @@ jobs: test_python_2023: name: Run Python Unit Tests (CY2023) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2023 steps: - uses: actions/checkout@v3 @@ -43,7 +43,7 @@ jobs: test_cuebot_2023: name: Build Cuebot and Run Unit Tests (CY2023) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: image: aswf/ci-opencue:2023 steps: @@ -55,7 +55,7 @@ jobs: test_python_2024: name: Run Python Unit Tests (CY2024) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2024 steps: - uses: actions/checkout@v3 @@ -64,7 +64,7 @@ jobs: test_cuebot_2024: name: Build Cuebot and Run Unit Tests (CY2024) - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: image: aswf/ci-opencue:2024 steps: @@ -76,7 +76,7 @@ jobs: lint_python: name: Lint Python Code - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: aswf/ci-opencue:2022 env: ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION: true @@ -87,7 +87,7 @@ jobs: test_sphinx: name: Test Documentation Build - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 container: image: aswf/ci-opencue:2023 steps: @@ -97,7 +97,7 @@ jobs: check_changed_files: name: Check Changed Files - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - name: Get Changed Files @@ -108,7 +108,7 @@ jobs: check_migration_files: name: Check Database Migration Files - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - name: Check Migration Files @@ -116,7 +116,7 @@ jobs: check_for_version_bump: name: Check for Version Bump - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - name: Get Changed Files From f90e6e0f2cd68b3e0791c953ee27139957780da5 Mon Sep 17 00:00:00 2001 From: Diego Tavares Date: Tue, 15 Oct 2024 14:57:32 -0700 Subject: [PATCH 35/40] Version up to 1.0 (#1536) Previous changes have dropped compatibility with python 2 and java 1.8, VERSION.in wasn't updated accordingly when those changes happened. This PR updates the major version to 1 and resets the minor to 0. --- VERSION.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.in b/VERSION.in index c74e8a041..d3827e75a 100644 --- a/VERSION.in +++ b/VERSION.in @@ -1 +1 @@ -0.35 +1.0 From 2400979ea2b0b8860a76c310e14fad20bc0a8e04 Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Tue, 15 Oct 2024 16:03:06 -0700 Subject: [PATCH 36/40] Refactor VERSION.in file handling (#1537) Improve handling of VERSION.in file with specific error handling and code cleanup --- cuegui/cuegui/Constants.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index 1c1a85937..6c3d8dcb8 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -81,13 +81,18 @@ def __loadConfigFromFile(): def __packaged_version(): - possible_version_path = os.path.join( + version_file_path = os.path.join( os.path.abspath(os.path.join(__file__, "../../..")), 'VERSION.in') - if os.path.exists(possible_version_path): - with open(possible_version_path, encoding='utf-8') as fp: - default_version = fp.read().strip() - return default_version - return "1.3.0" + try: + with open(version_file_path, encoding='utf-8') as fp: + version = fp.read().strip() + return version + except FileNotFoundError: + print(f"VERSION.in not found at: {version_file_path}") + except Exception as e: + print(f"An unexpected error occurred while reading VERSION.in: {e}") + return None + def __get_version_from_cmd(command): try: From 0f2c98f774101211021209309ee3532e98c210df Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Wed, 16 Oct 2024 11:25:03 -0700 Subject: [PATCH 37/40] [cuegui] Fix CueGUI version handling and improve error handling (#1538) - Refactored version determination logic in `cuegui/cuegui/Constants.py` to avoid repetition and improve readability. - Improved error handling in `__get_version_from_cmd` function. - Fix errors in `cuegui/tests/Constants_tests.py` when `cuegui.use.custom.version` is True - Updated `displayAbout` method in `MainWindow.py` to explicitly check for `OPENCUE_BETA` environment variable. --- cuegui/cuegui/Constants.py | 2 +- cuegui/cuegui/MainWindow.py | 21 ++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/cuegui/cuegui/Constants.py b/cuegui/cuegui/Constants.py index 6c3d8dcb8..1c51a509d 100644 --- a/cuegui/cuegui/Constants.py +++ b/cuegui/cuegui/Constants.py @@ -102,7 +102,7 @@ def __get_version_from_cmd(command): print(f"Command failed with return code {e.returncode}: {e}") except Exception as e: print(f"Failed to get version from command: {e}") - return None + return __config.get('version', __packaged_version()) __config = __loadConfigFromFile() diff --git a/cuegui/cuegui/MainWindow.py b/cuegui/cuegui/MainWindow.py index d9db14424..a27990cfd 100644 --- a/cuegui/cuegui/MainWindow.py +++ b/cuegui/cuegui/MainWindow.py @@ -119,16 +119,19 @@ def showStatusBarMessage(self, message, delay=5000): def displayAbout(self): """Displays about text.""" - msg = self.app_name + "\n\nA opencue tool\n\n" - msg += "CueGUI:\n%s\n\n" % cuegui.Constants.VERSION - - if os.getenv('OPENCUE_BETA'): - msg += "(Beta Version)\n\n" - else: - msg += "(Stable Version)\n\n" + msg = f"{self.app_name}\n\nA opencue tool\n\n" + msg += f"CueGUI:\n{cuegui.Constants.VERSION}\n\n" + + # Only show the labels (Beta or Stable) if OPENCUE_BETA exists + opencue_beta = os.getenv('OPENCUE_BETA') + if opencue_beta: + if opencue_beta == '1': + msg += "(Beta Version)\n\n" + else: + msg += "(Stable Version)\n\n" - msg += "Qt:\n%s\n\n" % QtCore.qVersion() - msg += "Python:\n%s\n\n" % sys.version + msg += f"Qt:\n{QtCore.qVersion()}\n\n" + msg += f"Python:\n{sys.version}\n\n" QtWidgets.QMessageBox.about(self, "About", msg) def handleExit(self, sig, flag): From 5ab915b6e1f5d3bc1a8f4cdb63cfd16ae84089ab Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Wed, 16 Oct 2024 17:11:04 -0700 Subject: [PATCH 38/40] [cuegui] Fix TypeError in Comment viewer: Handle job object as iterable (#1542) - Updated `viewComments` method in `MenuActions.py` to wrap single Job objects in a list. - This prevents `TypeError` when attempting to iterate over a non-iterable Job object. --- cuegui/cuegui/MenuActions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cuegui/cuegui/MenuActions.py b/cuegui/cuegui/MenuActions.py index 287b2eeeb..07f83063c 100644 --- a/cuegui/cuegui/MenuActions.py +++ b/cuegui/cuegui/MenuActions.py @@ -581,6 +581,8 @@ def dropInternalDependencies(self, rpcObjects=None): def viewComments(self, rpcObjects=None): jobs = self._getOnlyJobObjects(rpcObjects) if jobs: + if not isinstance(jobs, list): + jobs = [jobs] cuegui.Comments.CommentListDialog(jobs, self._caller).show() dependWizard_info = ["Dependency &Wizard...", None, "configure"] From 170f1709a727e94bcb73ea3303e96e216f59b8ea Mon Sep 17 00:00:00 2001 From: Ramon Figueiredo Date: Thu, 17 Oct 2024 15:22:37 -0700 Subject: [PATCH 39/40] [cuegui] Add Rocky 9 log root in cuegui.yaml (#1543) - Add `rocky9` log root to `render_logs.root` in `cuegui.yaml` --- cuegui/cuegui/config/cuegui.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cuegui/cuegui/config/cuegui.yaml b/cuegui/cuegui/config/cuegui.yaml index 529cb9e00..546e52527 100644 --- a/cuegui/cuegui/config/cuegui.yaml +++ b/cuegui/cuegui/config/cuegui.yaml @@ -41,6 +41,7 @@ render_logs.root: darwin: '/Users/shots' linux: '/shots' rhel7: '/shots' + rocky9: '/shots' # Substrings which, when found in render logs, will cause that line to be highlighted. render_logs.highlight.error: [ 'error', 'aborted', 'fatal', 'failed', 'killed', 'command not found', From 149b1e246bc023d30d92d4aa68b3a50afc415c6c Mon Sep 17 00:00:00 2001 From: Jimmy Christensen Date: Tue, 22 Oct 2024 20:15:41 +0200 Subject: [PATCH 40/40] [tests] Change tests to not use setup.py, but use the unittest module directly (#1547) **Summarize your change.** Have changed most tests to use `-m unittest discover` instead og `setup.py test` The old `setup.py test` doesn't work in newer versions of python since it has been deprecated --- ci/run_gui_test.sh | 2 +- ci/run_python_tests.sh | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ci/run_gui_test.sh b/ci/run_gui_test.sh index 3c7d92a6d..8a32a462e 100755 --- a/ci/run_gui_test.sh +++ b/ci/run_gui_test.sh @@ -21,7 +21,7 @@ fi echo "Using Python binary ${py}" test_log="/tmp/cuegui_result.log" -PYTHONPATH=pycue xvfb-run -d "${py}" cuegui/setup.py test | tee ${test_log} +PYTHONPATH=pycue xvfb-run -d "${py}" -m unittest discover -s cuegui/tests -t cuegui -p "*.py"| tee ${test_log} grep -Pz 'Ran \d+ tests in [0-9\.]+s\n\nOK' ${test_log} if [ $? -eq 0 ]; then diff --git a/ci/run_python_tests.sh b/ci/run_python_tests.sh index 5f1bfe294..4e1b0212b 100755 --- a/ci/run_python_tests.sh +++ b/ci/run_python_tests.sh @@ -22,11 +22,12 @@ python -m grpc_tools.protoc -I=proto/ --python_out=rqd/rqd/compiled_proto --grpc 2to3 -wn -f import pycue/opencue/compiled_proto/*_pb2*.py 2to3 -wn -f import rqd/rqd/compiled_proto/*_pb2*.py -python pycue/setup.py test -PYTHONPATH=pycue python pyoutline/setup.py test -PYTHONPATH=pycue python cueadmin/setup.py test -PYTHONPATH=pycue:pyoutline python cuesubmit/setup.py test -python rqd/setup.py test +python3 -m unittest discover -s pycue/tests -t pycue -p "*.py" +PYTHONPATH=pycue python3 -m unittest discover -s pyoutline/tests -t pyoutline -p "*.py" +PYTHONPATH=pycue python3 -m unittest discover -s cueadmin/tests -t cueadmin -p "*.py" +PYTHONPATH=pycue:pyoutline python3 -m unittest discover -s cuesubmit/tests -t cuesubmit -p "*.py" +python3 -m unittest discover -s rqd/tests -t rqd -p "*.py" + # Xvfb no longer supports Python 2. if [[ "$python_version" =~ "Python 3" && ${args[0]} != "--no-gui" ]]; then