From fa72a1d8ac8e9453bf0988e098d7e040d2b29ccf Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 20 Dec 2020 11:04:34 +0100 Subject: [PATCH 01/13] START OVER: Remove all Java code --- .dockerignore | 21 -- .idea/codeStyleSettings.xml | 22 --- .idea/codeStyles/Project.xml | 24 --- .idea/codeStyles/codeStyleConfig.xml | 5 - .idea/runConfigurations/Integration_Tests.xml | 29 --- .idea/runConfigurations/Startup.xml | 24 --- .idea/runConfigurations/Unit_Tests.xml | 26 --- .travis.yml | 38 ---- .travis/build.sh | 15 -- .travis/release-docker.sh | 41 ---- DEVELOPING.md | 33 ---- Dockerfile | 63 ------ build.gradle | 168 ---------------- builder.Dockerfile | 14 -- clustercode.api.cleanup/build.gradle | 6 - .../api/cleanup/CleanupContext.java | 21 -- .../api/cleanup/CleanupProcessor.java | 7 - .../api/cleanup/CleanupService.java | 9 - clustercode.api.cluster/build.gradle | 8 - .../api/cluster/ClusterService.java | 45 ----- .../clustercode/api/cluster/ClusterTask.java | 37 ---- .../messages/CancelTaskApiRequest.java | 13 -- .../messages/CancelTaskRpcRequest.java | 19 -- .../api/cluster/messages/ClusterMessage.java | 7 - .../ClusterTaskCollectionChanged.java | 26 --- clustercode.api.config/build.gradle | 10 - .../clustercode/api/config/ConfigLoader.java | 41 ---- .../api/config/converter/PathConverter.java | 16 -- .../api/config/ConfigLoaderTest.java | 76 ------- .../clustercode/api/config/TestConfig.java | 21 -- .../java/clustercode/api/config/TestEnum.java | 8 - .../resources/ConfigLoaderTest.properties | 5 - .../src/test/resources/log4j2-debug.xml | 14 -- clustercode.api.domain/build.gradle | 4 - .../clustercode/api/domain/Activator.java | 11 -- .../api/domain/ActivatorContext.java | 8 - .../clustercode/api/domain/Constraint.java | 20 -- .../java/clustercode/api/domain/Media.java | 24 --- .../api/domain/OutputFrameTuple.java | 42 ---- .../java/clustercode/api/domain/Profile.java | 33 ---- .../api/domain/TranscodeResult.java | 17 -- .../clustercode/api/domain/TranscodeTask.java | 26 --- clustercode.api.event/build.gradle | 7 - .../clustercode/api/event/RxEventBus.java | 46 ----- .../clustercode/api/event/RxEventBusImpl.java | 51 ----- .../messages/CancelTranscodeMessage.java | 14 -- .../messages/CleanupFinishedMessage.java | 5 - .../event/messages/ClusterConnectMessage.java | 17 -- .../event/messages/MediaInClusterMessage.java | 15 -- .../event/messages/MediaScannedMessage.java | 24 --- .../event/messages/MediaSelectedMessage.java | 21 -- .../messages/ProfileSelectedMessage.java | 25 --- .../api/event/messages/ScanMediaCommand.java | 5 - .../event/messages/StartupCompletedEvent.java | 15 -- .../api/event/messages/TaskAddedEvent.java | 25 --- .../event/messages/TaskCompletedEvent.java | 16 -- .../event/messages/TranscodeBeginEvent.java | 18 -- .../messages/TranscodeFinishedEvent.java | 43 ---- .../api/event/RxEventBusImplTest.java | 117 ----------- clustercode.api.process/build.gradle | 5 - .../api/process/ExternalProcessService.java | 14 -- .../api/process/ProcessConfiguration.java | 27 --- .../api/process/RunningExternalProcess.java | 46 ----- .../api/process/ScriptInterpreter.java | 9 - clustercode.api.rest.v1/build.gradle | 36 ---- .../api/rest/v1/ProgressReport.java | 5 - .../api/rest/v1/RestServiceConfig.java | 16 -- .../api/rest/v1/RestServicesActivator.java | 60 ------ .../clustercode/api/rest/v1/dto/ApiError.java | 17 -- .../clustercode/api/rest/v1/dto/Task.java | 46 ----- .../api/rest/v1/dto/VersionInfo.java | 16 -- .../api/rest/v1/hook/ProgressHook.java | 12 -- .../api/rest/v1/hook/ProgressHookImpl.java | 41 ---- .../api/rest/v1/hook/TaskHook.java | 29 --- .../api/rest/v1/hook/TaskHookImpl.java | 44 ----- .../api/rest/v1/rest/AbstractRestApi.java | 59 ------ .../api/rest/v1/rest/ProgressApi.java | 86 -------- .../api/rest/v1/rest/TasksApi.java | 87 -------- .../api/rest/v1/rest/VersionApi.java | 47 ----- clustercode.api.scan/build.gradle | 6 - .../clustercode/api/scan/FileScanner.java | 103 ---------- .../api/scan/MediaScanService.java | 51 ----- .../clustercode/api/scan/ProfileMatcher.java | 9 - .../clustercode/api/scan/ProfileParser.java | 20 -- .../api/scan/ProfileScanService.java | 20 -- .../api/scan/SelectionService.java | 12 -- clustercode.api.transcode/build.gradle | 10 - .../api/transcode/TranscodeProgress.java | 15 -- .../api/transcode/TranscodeReport.java | 7 - .../api/transcode/TranscodingService.java | 39 ---- clustercode.impl.cleanup/build.gradle | 8 - .../impl/cleanup/CleanupActivator.java | 44 ----- .../impl/cleanup/CleanupConfig.java | 65 ------ .../impl/cleanup/CleanupMessageHandler.java | 28 --- .../impl/cleanup/CleanupServiceImpl.java | 36 ---- .../AbstractMarkSourceProcessor.java | 48 ----- .../AbstractOutputDirectoryProcessor.java | 63 ------ .../processor/ChangeOwnerProcessor.java | 100 ---------- .../cleanup/processor/CleanupProcessors.java | 13 -- .../processor/DeleteSourceProcessor.java | 51 ----- .../processor/MarkSourceDirProcessor.java | 70 ------- .../processor/MarkSourceProcessor.java | 46 ----- .../StructuredOutputDirectoryProcessor.java | 69 ------- .../UnifiedOutputDirectoryProcessor.java | 42 ---- .../processor/DeleteSourceProcessorTest.java | 65 ------ .../processor/MarkSourceDirProcessorTest.java | 84 -------- .../processor/MarkSourceProcessorTest.java | 71 ------- ...tructuredOutputDirectoryProcessorTest.java | 121 ------------ .../UnifiedOutputDirectoryProcessorTest.java | 97 --------- clustercode.impl.cluster.jgroups/build.gradle | 11 -- .../cluster/jgroups/JGroupsClusterFacade.java | 51 ----- .../jgroups/JgroupsClusterActivator.java | 103 ---------- .../cluster/jgroups/JgroupsClusterImpl.java | 44 ----- .../jgroups/SingleNodeClusterImpl.java | 40 ---- .../src/main/resources/fork.xml | 13 -- .../src/main/resources/tcp.xml | 39 ---- .../src/main/resources/udp.xml | 50 ----- clustercode.impl.constraint/build.gradle | 12 -- .../impl/constraint/AbstractConstraint.java | 43 ---- .../impl/constraint/ClusterConstraint.java | 27 --- .../impl/constraint/ConstraintConfig.java | 51 ----- .../impl/constraint/Constraints.java | 12 -- .../impl/constraint/FileNameConstraint.java | 30 --- .../impl/constraint/FileSizeConstraint.java | 91 --------- .../impl/constraint/NoConstraint.java | 11 -- .../impl/constraint/TimeConstraint.java | 64 ------ .../constraint/FileNameConstraintTest.java | 45 ----- .../constraint/FileSizeConstraintTest.java | 150 -------------- .../impl/constraint/TimeConstraintTest.java | 92 --------- clustercode.impl.process/build.gradle | 6 - .../process/AutoResolvableInterpreter.java | 14 -- .../impl/process/BourneAgainShell.java | 14 -- .../impl/process/ExternalProcess.java | 95 --------- .../process/ExternalProcessServiceImpl.java | 34 ---- .../impl/process/RunningProcessImpl.java | 55 ------ .../java/clustercode/impl/process/Shell.java | 14 -- clustercode.impl.scan/build.gradle | 12 -- .../impl/scan/FileScannerImpl.java | 167 ---------------- .../impl/scan/MediaScanConfig.java | 58 ------ .../impl/scan/MediaScanServiceImpl.java | 111 ----------- .../impl/scan/ProfileParserImpl.java | 110 ----------- .../impl/scan/ProfileScanConfig.java | 56 ------ .../impl/scan/ProfileScanServiceImpl.java | 39 ---- .../impl/scan/ScanServicesActivator.java | 103 ---------- .../impl/scan/ScanServicesMessageHandler.java | 84 -------- .../impl/scan/SelectionServiceImpl.java | 43 ---- .../scan/matcher/CompanionProfileMatcher.java | 45 ----- .../scan/matcher/DefaultProfileMatcher.java | 44 ----- .../matcher/DirectoryStructureMatcher.java | 72 ------- .../impl/scan/matcher/ProfileMatchers.java | 9 - .../impl/scan/FileScannerImplTest.java | 165 ---------------- .../impl/scan/MediaScanServiceImplTest.java | 137 ------------- .../impl/scan/ProfileParserImplTest.java | 140 ------------- .../impl/scan/ProfileScanServiceImplTest.java | 80 -------- .../matcher/CompanionProfileMatcherTest.java | 76 ------- .../matcher/DefaultProfileMatcherTest.java | 62 ------ .../DirectoryStructureMatcherTest.java | 113 ----------- clustercode.impl.transcode/build.gradle | 10 - .../impl/transcode/TranscodeActivator.java | 69 ------- .../impl/transcode/TranscoderConfig.java | 33 ---- .../transcode/TranscodingMessageHandler.java | 34 ---- .../transcode/TranscodingServiceImpl.java | 186 ------------------ .../transcode/TranscodingServiceImplTest.java | 145 -------------- .../src/test/resources/log4j2.xml | 17 -- clustercode.impl.util/build.gradle | 5 - .../java/clustercode/impl/util/FileUtil.java | 123 ------------ .../impl/util/FilesystemProvider.java | 35 ---- .../util/InvalidConfigurationException.java | 31 --- .../impl/util/OptionalFunction.java | 48 ----- .../java/clustercode/impl/util/Platform.java | 33 ---- .../clustercode/impl/util/PredicateUtil.java | 15 -- .../clustercode/impl/util/UnsafeCastUtil.java | 23 --- .../impl/util/di/ModuleHelper.java | 73 ------- clustercode.main/build.gradle | 12 -- .../clustercode/main/ComponentActivator.java | 36 ---- .../main/ConfigurableLegModule.java | 10 - .../java/clustercode/main/GuiceManager.java | 74 ------- .../main/java/clustercode/main/Startup.java | 47 ----- .../main/config/ClusterConfig.java | 12 -- .../clustercode/main/config/ScanConfig.java | 7 - .../main/modules/CleanupModule.java | 64 ------ .../main/modules/ClusterModule.java | 30 --- .../clustercode/main/modules/ClusterType.java | 5 - .../main/modules/ConfigurableModule.java | 14 -- .../main/modules/ConstraintModule.java | 53 ----- .../main/modules/GlobalModule.java | 25 --- .../main/modules/JGroupsModule.java | 28 --- .../main/modules/ProcessModule.java | 35 ---- .../main/modules/RestApiModule.java | 51 ----- .../clustercode/main/modules/ScanModule.java | 74 ------- .../main/modules/TranscodeModule.java | 29 --- .../src/main/resources/log4j2.xml | 29 --- .../src/test/resources/log4j2-debug.xml | 17 -- clustercode.test.integration/build.gradle | 32 --- clustercode.test.integration/input/.gitignore | 2 - .../output/.gitignore | 2 - .../profiles/default.ffmpeg | 23 --- .../integration/AbstractDockerTestBase.java | 33 ---- .../integration/ClustercodeContainer.java | 56 ------ .../test/integration/HttpContainer.java | 67 ------- .../test/integration/RunOnceWaitStrategy.java | 36 ---- .../test/integration/TaskListIT.java | 49 ----- .../src/test/resources/blank_video.mp4 | Bin 3729 -> 0 bytes .../src/test/resources/log4j2.xml | 17 -- clustercode.test.util/build.gradle | 5 - .../test/util/ClockBasedUnitTest.java | 159 --------------- .../test/util/CompletableUnitTest.java | 28 --- .../test/util/FileBasedUnitTest.java | 93 --------- .../test/util/MockedFileBasedUnitTest.java | 44 ----- .../clustercode/test/util/TestUtility.java | 38 ---- docker/default/config/log4j2.xml | 31 --- docker/default/profiles/default.ffmpeg | 52 ----- docker/default/profiles/x265.ffmpeg | 52 ----- docker/docker-entrypoint.sh | 20 -- docker/nginx.conf | 85 -------- docs/nodes.vsdx | Bin 182180 -> 0 bytes docs/statemachine.vsdx | Bin 29312 -> 0 bytes gradle/wrapper/gradle-wrapper.jar | Bin 54788 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 6 - gradlew | 172 ---------------- gradlew.bat | 84 -------- lombok.config | 2 - settings.gradle | 23 --- .../clustercode-admin/nginx/nginx.conf | 120 ----------- .../windows/start-clustercode-admin.cmd | 8 - .../windows/stop-clustercode-admin.cmd | 7 - .../resources/config/log4j2-debug.xml | 25 --- .../clustercode/resources/config/udp.xml | 49 ----- .../resources/profiles/default.ffmpeg | 33 ---- .../resources/profiles/x265.ffmpeg | 35 ---- .../clustercode/windows/clustercode.cmd | 12 -- .../windows/config/clustercode.properties | 157 --------------- .../clustercode/windows/config/fork.xml | 14 -- .../clustercode/windows/config/tcp.xml | 39 ---- .../clustercode/windows/done/README.md | 1 - src/assembly/clustercode/windows/log4j2.xml | 27 --- .../windows/profiles/default.handbrake | 45 ----- .../windows/profiles/x265.handbrake | 48 ----- .../clustercode/windows/tmp/README.md | 1 - .../cluster/impl/CancelTaskIT.java | 83 -------- .../cluster/impl/JgroupsClusterImplIT.java | 63 ------ .../impl/ExternalProcessServiceImplIT.java | 129 ------------ .../resources/Echo Arguments.cmd | 6 - .../resources/Echo Arguments.sh | 5 - src/integration-test/resources/Sleep.cmd | 8 - src/integration-test/resources/Sleep.sh | 8 - src/integration-test/resources/log4j2.xml | 30 --- src/swagger/README.md | 3 - src/swagger/markdown.hbs | 108 ---------- src/swagger/operation.hbs | 80 -------- src/swagger/security.hbs | 88 --------- src/swagger/strapdown.html.hbs | 10 - 252 files changed, 10598 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .idea/codeStyleSettings.xml delete mode 100644 .idea/codeStyles/Project.xml delete mode 100644 .idea/codeStyles/codeStyleConfig.xml delete mode 100644 .idea/runConfigurations/Integration_Tests.xml delete mode 100644 .idea/runConfigurations/Startup.xml delete mode 100644 .idea/runConfigurations/Unit_Tests.xml delete mode 100644 .travis.yml delete mode 100755 .travis/build.sh delete mode 100755 .travis/release-docker.sh delete mode 100644 DEVELOPING.md delete mode 100644 Dockerfile delete mode 100644 build.gradle delete mode 100644 builder.Dockerfile delete mode 100644 clustercode.api.cleanup/build.gradle delete mode 100644 clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupContext.java delete mode 100644 clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupProcessor.java delete mode 100644 clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupService.java delete mode 100644 clustercode.api.cluster/build.gradle delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterService.java delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterTask.java delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskApiRequest.java delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskRpcRequest.java delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterMessage.java delete mode 100644 clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterTaskCollectionChanged.java delete mode 100644 clustercode.api.config/build.gradle delete mode 100644 clustercode.api.config/src/main/java/clustercode/api/config/ConfigLoader.java delete mode 100644 clustercode.api.config/src/main/java/clustercode/api/config/converter/PathConverter.java delete mode 100644 clustercode.api.config/src/test/java/clustercode/api/config/ConfigLoaderTest.java delete mode 100644 clustercode.api.config/src/test/java/clustercode/api/config/TestConfig.java delete mode 100644 clustercode.api.config/src/test/java/clustercode/api/config/TestEnum.java delete mode 100644 clustercode.api.config/src/test/resources/ConfigLoaderTest.properties delete mode 100644 clustercode.api.config/src/test/resources/log4j2-debug.xml delete mode 100644 clustercode.api.domain/build.gradle delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/Activator.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/ActivatorContext.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/Constraint.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/Media.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/OutputFrameTuple.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/Profile.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeResult.java delete mode 100644 clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeTask.java delete mode 100644 clustercode.api.event/build.gradle delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/RxEventBus.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/RxEventBusImpl.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/CancelTranscodeMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/CleanupFinishedMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/ClusterConnectMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaInClusterMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaScannedMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaSelectedMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/ProfileSelectedMessage.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/ScanMediaCommand.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/StartupCompletedEvent.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskAddedEvent.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskCompletedEvent.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeBeginEvent.java delete mode 100644 clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeFinishedEvent.java delete mode 100644 clustercode.api.event/src/test/java/clustercode/api/event/RxEventBusImplTest.java delete mode 100644 clustercode.api.process/build.gradle delete mode 100644 clustercode.api.process/src/main/java/clustercode/api/process/ExternalProcessService.java delete mode 100644 clustercode.api.process/src/main/java/clustercode/api/process/ProcessConfiguration.java delete mode 100644 clustercode.api.process/src/main/java/clustercode/api/process/RunningExternalProcess.java delete mode 100644 clustercode.api.process/src/main/java/clustercode/api/process/ScriptInterpreter.java delete mode 100644 clustercode.api.rest.v1/build.gradle delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/ProgressReport.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServiceConfig.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServicesActivator.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/ApiError.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/Task.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/VersionInfo.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHook.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHookImpl.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHook.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHookImpl.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/AbstractRestApi.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/ProgressApi.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/TasksApi.java delete mode 100644 clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/VersionApi.java delete mode 100644 clustercode.api.scan/build.gradle delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/FileScanner.java delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/MediaScanService.java delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileMatcher.java delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileParser.java delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileScanService.java delete mode 100644 clustercode.api.scan/src/main/java/clustercode/api/scan/SelectionService.java delete mode 100644 clustercode.api.transcode/build.gradle delete mode 100644 clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeProgress.java delete mode 100644 clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeReport.java delete mode 100644 clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodingService.java delete mode 100644 clustercode.impl.cleanup/build.gradle delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupActivator.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupConfig.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupMessageHandler.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupServiceImpl.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractMarkSourceProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractOutputDirectoryProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/ChangeOwnerProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/CleanupProcessors.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/DeleteSourceProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessor.java delete mode 100644 clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessor.java delete mode 100644 clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/DeleteSourceProcessorTest.java delete mode 100644 clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessorTest.java delete mode 100644 clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceProcessorTest.java delete mode 100644 clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessorTest.java delete mode 100644 clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessorTest.java delete mode 100644 clustercode.impl.cluster.jgroups/build.gradle delete mode 100644 clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JGroupsClusterFacade.java delete mode 100644 clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterActivator.java delete mode 100644 clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterImpl.java delete mode 100644 clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/SingleNodeClusterImpl.java delete mode 100644 clustercode.impl.cluster.jgroups/src/main/resources/fork.xml delete mode 100644 clustercode.impl.cluster.jgroups/src/main/resources/tcp.xml delete mode 100644 clustercode.impl.cluster.jgroups/src/main/resources/udp.xml delete mode 100644 clustercode.impl.constraint/build.gradle delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/AbstractConstraint.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ClusterConstraint.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ConstraintConfig.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/Constraints.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileNameConstraint.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileSizeConstraint.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/NoConstraint.java delete mode 100644 clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/TimeConstraint.java delete mode 100644 clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileNameConstraintTest.java delete mode 100644 clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileSizeConstraintTest.java delete mode 100644 clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/TimeConstraintTest.java delete mode 100644 clustercode.impl.process/build.gradle delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/AutoResolvableInterpreter.java delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/BourneAgainShell.java delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcess.java delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcessServiceImpl.java delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/RunningProcessImpl.java delete mode 100644 clustercode.impl.process/src/main/java/clustercode/impl/process/Shell.java delete mode 100644 clustercode.impl.scan/build.gradle delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/FileScannerImpl.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanConfig.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanServiceImpl.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileParserImpl.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanConfig.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanServiceImpl.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesActivator.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesMessageHandler.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/SelectionServiceImpl.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/CompanionProfileMatcher.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DefaultProfileMatcher.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DirectoryStructureMatcher.java delete mode 100644 clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/ProfileMatchers.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/FileScannerImplTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/MediaScanServiceImplTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileParserImplTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileScanServiceImplTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/CompanionProfileMatcherTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DefaultProfileMatcherTest.java delete mode 100644 clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DirectoryStructureMatcherTest.java delete mode 100644 clustercode.impl.transcode/build.gradle delete mode 100644 clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodeActivator.java delete mode 100644 clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscoderConfig.java delete mode 100644 clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingMessageHandler.java delete mode 100644 clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingServiceImpl.java delete mode 100644 clustercode.impl.transcode/src/test/java/clustercode/impl/transcode/TranscodingServiceImplTest.java delete mode 100644 clustercode.impl.transcode/src/test/resources/log4j2.xml delete mode 100644 clustercode.impl.util/build.gradle delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/FileUtil.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/FilesystemProvider.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/InvalidConfigurationException.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/OptionalFunction.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/Platform.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/PredicateUtil.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/UnsafeCastUtil.java delete mode 100644 clustercode.impl.util/src/main/java/clustercode/impl/util/di/ModuleHelper.java delete mode 100644 clustercode.main/build.gradle delete mode 100644 clustercode.main/src/main/java/clustercode/main/ComponentActivator.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/ConfigurableLegModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/GuiceManager.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/Startup.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/config/ClusterConfig.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/config/ScanConfig.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/CleanupModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ClusterModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ClusterType.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ConfigurableModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ConstraintModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/GlobalModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/JGroupsModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ProcessModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/RestApiModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/ScanModule.java delete mode 100644 clustercode.main/src/main/java/clustercode/main/modules/TranscodeModule.java delete mode 100644 clustercode.main/src/main/resources/log4j2.xml delete mode 100644 clustercode.main/src/test/resources/log4j2-debug.xml delete mode 100644 clustercode.test.integration/build.gradle delete mode 100644 clustercode.test.integration/input/.gitignore delete mode 100644 clustercode.test.integration/output/.gitignore delete mode 100644 clustercode.test.integration/profiles/default.ffmpeg delete mode 100644 clustercode.test.integration/src/test/java/clustercode/test/integration/AbstractDockerTestBase.java delete mode 100644 clustercode.test.integration/src/test/java/clustercode/test/integration/ClustercodeContainer.java delete mode 100644 clustercode.test.integration/src/test/java/clustercode/test/integration/HttpContainer.java delete mode 100644 clustercode.test.integration/src/test/java/clustercode/test/integration/RunOnceWaitStrategy.java delete mode 100644 clustercode.test.integration/src/test/java/clustercode/test/integration/TaskListIT.java delete mode 100644 clustercode.test.integration/src/test/resources/blank_video.mp4 delete mode 100644 clustercode.test.integration/src/test/resources/log4j2.xml delete mode 100644 clustercode.test.util/build.gradle delete mode 100644 clustercode.test.util/src/test/java/clustercode/test/util/ClockBasedUnitTest.java delete mode 100644 clustercode.test.util/src/test/java/clustercode/test/util/CompletableUnitTest.java delete mode 100644 clustercode.test.util/src/test/java/clustercode/test/util/FileBasedUnitTest.java delete mode 100644 clustercode.test.util/src/test/java/clustercode/test/util/MockedFileBasedUnitTest.java delete mode 100644 clustercode.test.util/src/test/java/clustercode/test/util/TestUtility.java delete mode 100644 docker/default/config/log4j2.xml delete mode 100644 docker/default/profiles/default.ffmpeg delete mode 100644 docker/default/profiles/x265.ffmpeg delete mode 100755 docker/docker-entrypoint.sh delete mode 100644 docker/nginx.conf delete mode 100644 docs/nodes.vsdx delete mode 100644 docs/statemachine.vsdx delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100755 gradlew delete mode 100644 gradlew.bat delete mode 100644 lombok.config delete mode 100644 settings.gradle delete mode 100644 src/assembly/clustercode-admin/nginx/nginx.conf delete mode 100644 src/assembly/clustercode-admin/windows/start-clustercode-admin.cmd delete mode 100644 src/assembly/clustercode-admin/windows/stop-clustercode-admin.cmd delete mode 100644 src/assembly/clustercode/resources/config/log4j2-debug.xml delete mode 100644 src/assembly/clustercode/resources/config/udp.xml delete mode 100644 src/assembly/clustercode/resources/profiles/default.ffmpeg delete mode 100644 src/assembly/clustercode/resources/profiles/x265.ffmpeg delete mode 100644 src/assembly/clustercode/windows/clustercode.cmd delete mode 100644 src/assembly/clustercode/windows/config/clustercode.properties delete mode 100644 src/assembly/clustercode/windows/config/fork.xml delete mode 100644 src/assembly/clustercode/windows/config/tcp.xml delete mode 100644 src/assembly/clustercode/windows/done/README.md delete mode 100644 src/assembly/clustercode/windows/log4j2.xml delete mode 100644 src/assembly/clustercode/windows/profiles/default.handbrake delete mode 100644 src/assembly/clustercode/windows/profiles/x265.handbrake delete mode 100644 src/assembly/clustercode/windows/tmp/README.md delete mode 100644 src/integration-test/java/net/chrigel/clustercode/cluster/impl/CancelTaskIT.java delete mode 100644 src/integration-test/java/net/chrigel/clustercode/cluster/impl/JgroupsClusterImplIT.java delete mode 100644 src/integration-test/java/net/chrigel/clustercode/process/impl/ExternalProcessServiceImplIT.java delete mode 100644 src/integration-test/resources/Echo Arguments.cmd delete mode 100644 src/integration-test/resources/Echo Arguments.sh delete mode 100644 src/integration-test/resources/Sleep.cmd delete mode 100644 src/integration-test/resources/Sleep.sh delete mode 100644 src/integration-test/resources/log4j2.xml delete mode 100644 src/swagger/README.md delete mode 100644 src/swagger/markdown.hbs delete mode 100644 src/swagger/operation.hbs delete mode 100644 src/swagger/security.hbs delete mode 100644 src/swagger/strapdown.html.hbs diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 9cf16ede..00000000 --- a/.dockerignore +++ /dev/null @@ -1,21 +0,0 @@ -.git -.idea/**/* -.gradle/* - -docs/* -config/* -tmp/* - -build/* -dist/* -out - -output/**/* -input/**/* -input - -.gitignore -.editorconfig -README.md -lombok.config -LICENSE diff --git a/.idea/codeStyleSettings.xml b/.idea/codeStyleSettings.xml deleted file mode 100644 index a1188484..00000000 --- a/.idea/codeStyleSettings.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 29265c43..00000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml deleted file mode 100644 index 79ee123c..00000000 --- a/.idea/codeStyles/codeStyleConfig.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Integration_Tests.xml b/.idea/runConfigurations/Integration_Tests.xml deleted file mode 100644 index b93fea5a..00000000 --- a/.idea/runConfigurations/Integration_Tests.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Startup.xml b/.idea/runConfigurations/Startup.xml deleted file mode 100644 index 21871c9c..00000000 --- a/.idea/runConfigurations/Startup.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_Tests.xml deleted file mode 100644 index ac6a239b..00000000 --- a/.idea/runConfigurations/Unit_Tests.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9753ecbc..00000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -dist: xenial -sudo: true -language: generic - -services: - - docker -os: - - linux - -stages: -- name: build pr - if: type = pull_request -- name: devel - if: (branch = master) AND (type != pull_request) -- name: release branch - if: (branch =~ /^[0-9\.]+$/) AND (type != pull_request) - -jobs: - include: - - stage: build pr - script: .travis/build.sh - - - stage: devel - script: .travis/build.sh - deploy: - - provider: script - script: .travis/release-docker.sh dev - on: - branch: master - - - stage: release branch - script: .travis/build.sh - deploy: - - provider: script - script: .travis/release-docker.sh $TRAVIS_BRANCH - on: - all_branches: true diff --git a/.travis/build.sh b/.travis/build.sh deleted file mode 100755 index 15a341fe..00000000 --- a/.travis/build.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -repo="${DOCKER_REPOSITORY}" - -# Install cross-build libraries -docker run --rm --privileged multiarch/qemu-user-static:register --reset - -# Build builder image -docker build --tag "${repo}:builder" --file ./builder.Dockerfile ./ - -# Build runtime images -for ARCH in armhf amd64 i386 aarch64; do - tag="${ARCH}" - docker build --build-arg ARCH="${ARCH}-edge" --tag "${repo}:${tag}" --file ./Dockerfile ./ -done diff --git a/.travis/release-docker.sh b/.travis/release-docker.sh deleted file mode 100755 index 2866d1b3..00000000 --- a/.travis/release-docker.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash - -repo="${DOCKER_REPOSITORY}" - -# Extract version number from release branch -if [[ ${1} = *"."* ]]; then - version="${1#*origin/}" -else - version="${1:-}" -fi - -# Check if we need to push images -if [ -z ${DOCKER_USERNAME+x} ]; then - echo "No docker hub username specified. Exiting without pushing images to registry" - exit 1 -fi -if [ -z ${DOCKER_PASSWORD+x} ]; then - echo "No docker hub password specified. Exiting without pushing images to registry" - exit 1 -fi - -# Push versioned runtime images -echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin -for ARCH in amd64 armhf i386 aarch64; do - old_tag="${ARCH}" - new_tag="${version}-${ARCH}" - docker tag "${repo}:${old_tag}" "${repo}:${new_tag}" - docker push "${repo}:${new_tag}" -done - -# Push latest images if eligible -latest_branch=$(git branch --remote | grep "\." | sort -r | head -n 1) -if [[ "${TRAVIS_BRANCH}" = "${latest_branch#*origin/}" ]]; then - echo We are on latest release branch, push latest tag - docker tag "${repo}:amd64" "${repo}:latest" - docker push "${repo}:latest" - docker push "${repo}:${version}" - for ARCH in amd64 armhf i386 aarch64; do - docker push "${repo}:${ARCH}" - done -fi diff --git a/DEVELOPING.md b/DEVELOPING.md deleted file mode 100644 index 500f560f..00000000 --- a/DEVELOPING.md +++ /dev/null @@ -1,33 +0,0 @@ -# DEVELOPING - -This file should cover the installation and guide on how to setup a -development environment. - -## IDE - -Though work has undergone to make this as generic as possible using -Gradle tasks, clustercode was built with JetBrain's IntellJ (Community). - -## CI - -Not a real CI/CD pipeline, but this project is configured and optimized -for Docker usage. As such, it is built and distributed on Docker Cloud. - -The Docker build is always executing the unit tests. If you contribute -code, make sure the unit tests are all green when executing -`gradle test` before committing. - -## Docker - -On a Linux box with docker installed, run - - export DOCKER_REPOSITORY=braindoctor/clustercode; ./.travis/build.sh - -which builds the builder image (a cache image that has all the Java -dependencies installed) and the multi-arch runtime images. - -After you've built the builder image, you can just run - - docker build --build-arg ARCH="amd64-edge" --tag "${DOCKER_REPOSITORY}" . - -to re-build the image locally for amd64. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 58e6822e..00000000 --- a/Dockerfile +++ /dev/null @@ -1,63 +0,0 @@ -#______________________________________________________________________________ -#### Base Image, to save build time on local dev machine -ARG ARCH -FROM multiarch/alpine:${ARCH} as base - -ENTRYPOINT ["/bin/bash"] - -ARG SRC_DIR="/usr/local/src/clustercode" -ARG TGT_DIR="/opt/clustercode" - -WORKDIR ${TGT_DIR} - -RUN \ - apk update && \ - apk upgrade && \ - apk add --no-cache openjdk8-jre ffmpeg nano curl bash - -COPY docker/docker-entrypoint.sh ${TGT_DIR}/docker-entrypoint.sh -COPY docker/default ${TGT_DIR}/default/ - -#______________________________________________________________________________ -#### Builder Image -FROM braindoctor/clustercode:builder as builder - -COPY / . - -RUN \ - sh ./gradlew shadowJar - -#______________________________________________________________________________ -#### Runtime Image -FROM base - -ARG SRC_DIR="/usr/local/src/clustercode" -ARG TGT_DIR="/opt/clustercode" - -WORKDIR ${TGT_DIR} - -ENV \ - CC_DEFAULT_DIR="${TGT_DIR}/default" \ - CC_CONFIG_DIR="${TGT_DIR}/config" \ - CC_LOG_CONFIG_FILE="default/config/log4j2.xml" \ - JAVA_ARGS="" \ - CC_CLUSTER_JGROUPS_BIND_PORT=7600 - -RUN \ - # Let's create the directories first so we can apply the permissions: - mkdir -m 664 /input /output /profiles /var/tmp/clustercode ${CC_CONFIG_DIR} - -VOLUME \ - /input \ - /output \ - /profiles \ - /var/tmp/clustercode \ - $CC_CONFIG_DIR - -EXPOSE \ - $CC_CLUSTER_JGROUPS_BIND_PORT/tcp \ - $CC_CLUSTER_JGROUPS_BIND_PORT/udp - -CMD ["/opt/clustercode/docker-entrypoint.sh"] - -COPY --from=builder ${SRC_DIR}/build/libs/clustercode.jar ${TGT_DIR}/ diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 493827fa..00000000 --- a/build.gradle +++ /dev/null @@ -1,168 +0,0 @@ -plugins { - id "java" - id "idea" - id 'com.github.johnrengelman.shadow' version '2.0.4' -} - -group "clustercode" -version "2.0.0" -/* -sourceCompatibility = 1.8 -targetCompatibility = 1.8 -*/ - -repositories { - mavenCentral() -} - -ext { - guiceVersion = "4.2.2" - log4jVersion = "2.8.+" - jacksonVersion = "2.9.+" - ver_junit = "5.3.1" - - dep_owner = "org.aeonbits.owner:owner:1.0.10" - dep_rxjava = "io.reactivex.rxjava2:rxjava:2.1.+" - dep_jgroups = "org.jgroups:jgroups:4.0.13.Final" - dep_inject = "javax.inject:javax.inject:1" - dep_rabbitmq = "com.rabbitmq:amqp-client:5.5.0" - dep_guice = "com.google.inject.extensions:guice-multibindings:${guiceVersion}" - dep_testcontainers_junit = "org.testcontainers:junit-jupiter:1.10.1" - dep_testcontainers = "org.testcontainers:testcontainers:1.10.1" - - proj_test_util = "clustercode.test.util" - proj_api_transcode = "clustercode.api.transcode" - proj_api_cluster = "clustercode.api.cluster" - proj_api_config = "clustercode.api.config" - proj_api_domain = "clustercode.api.domain" - proj_api_scan = "clustercode.api.scan" - proj_api_process = "clustercode.api.process" - proj_api_event = "clustercode.api.event" - proj_api_cleanup = "clustercode.api.cleanup" - proj_api_rest = "clustercode.api.rest.v1" - proj_impl_util = "clustercode.impl.util" - proj_impl_scan = "clustercode.impl.scan" - proj_impl_transcode = "clustercode.impl.transcode" - proj_impl_cluster_jgroups = "clustercode.impl.cluster.jgroups" - proj_impl_process = "clustercode.impl.process" - proj_impl_cleanup = "clustercode.impl.cleanup" - proj_impl_constraint = "clustercode.impl.constraint" - proj_main = "clustercode.main" -} - -allprojects { - idea { - module { - inheritOutputDirs = false - outputDir = compileJava.destinationDir - testOutputDir = compileTestJava.destinationDir - } - } -} - -subprojects { - group = 'clustercode' - version = '1.0' - - targetCompatibility = 1.10 - sourceCompatibility = 1.10 - - apply plugin: "java" - - repositories { - mavenCentral() - } - - gradle.projectsEvaluated { - tasks.withType(JavaCompile) { - options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" - } - } - - dependencies { - compileOnly "org.projectlombok:lombok:1.18.+" - testCompileOnly "org.projectlombok:lombok:1.18.+" - - // logging - compile "org.slf4j:slf4j-ext:1.7.+" - runtime "org.apache.logging.log4j:log4j-api:${log4jVersion}" - runtime "org.apache.logging.log4j:log4j-core:${log4jVersion}" - runtime "org.apache.logging.log4j:log4j-slf4j-impl:${log4jVersion}" - - // testing - testRuntime "org.apache.logging.log4j:log4j-slf4j-impl:${log4jVersion}" - testRuntime "org.junit.jupiter:junit-jupiter-engine:${ver_junit}" - testCompile "org.junit.jupiter:junit-jupiter-api:${ver_junit}" - testCompile "org.mockito:mockito-all:2.0.+" - testCompile "com.google.jimfs:jimfs:1.+" - testCompile "org.assertj:assertj-core:3.8.0" - - //testCompile project(":clustercode.test.util").sourceSets.test.output - } - - test { - systemProperty 'log4j.configurationFile', '../clustercode.main/src/test/resources/log4j2-debug.xml' - } - -} - -dependencies { - compile project(":${proj_main}") -} - - -shadowJar { - dependencies { - exclude(project(":${proj_test_util}")) - } - manifest { - attributes "Main-Class": "clustercode.main.Startup", "Implementation-Version": version - } - baseName = "clustercode" - classifier = null - version = null -} - -task fullBuild(type: Jar) { - manifest { - attributes "Main-Class": "clustercode.main.Startup", - "Implementation-Title": project.name - } - archiveName = "clustercode.jar" - baseName = project.name - from { configurations.compile.collect { it.isDirectory() ? it : zipTree(it) } } - from { configurations.runtime.collect { it.isDirectory() ? it : zipTree(it) } } - with jar -} - -task downloadDependenciesCustom(type: Exec) { - configurations.testRuntime.files - //commandLine "echo", "Downloaded all dependencies" - commandLine "print", "Downloaded all dependencies" -} - -task resolveDependencies { - doLast { - project.rootProject.allprojects.each { subProject -> - subProject.buildscript.configurations.each { configuration -> - if (configuration.isCanBeResolved()) configuration.resolve() - } - subProject.configurations.each { configuration -> - if (configuration.isCanBeResolved()) configuration.resolve() - } - } - } -} - -task githubRelease() - -fullBuild.dependsOn test - -//check.dependsOn integrationTest -//integrationTest.mustRunAfter test - -/*jacoco { - reportsDir file("${project.buildDir}/reports/jacoco") - toolVersion "0.7.6.201602180812" -}*/ - diff --git a/builder.Dockerfile b/builder.Dockerfile deleted file mode 100644 index 687aba7d..00000000 --- a/builder.Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM openjdk:8-jdk - -WORKDIR /usr/local/src/clustercode - -RUN \ - apt-get update && \ - apt-get install tree - -COPY / . - -RUN \ - sh ./gradlew resolveDependencies && \ - # Don't need sources, it will get added anyway - rm -r * \ No newline at end of file diff --git a/clustercode.api.cleanup/build.gradle b/clustercode.api.cleanup/build.gradle deleted file mode 100644 index 212da20b..00000000 --- a/clustercode.api.cleanup/build.gradle +++ /dev/null @@ -1,6 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_event}") - compile project(":${proj_impl_util}") -} diff --git a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupContext.java b/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupContext.java deleted file mode 100644 index 0828130d..00000000 --- a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupContext.java +++ /dev/null @@ -1,21 +0,0 @@ -package clustercode.api.cleanup; - -import clustercode.api.event.messages.TranscodeFinishedEvent; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.nio.file.Path; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class CleanupContext { - - private TranscodeFinishedEvent transcodeFinishedEvent; - - private Path outputPath; - -} diff --git a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupProcessor.java b/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupProcessor.java deleted file mode 100644 index 56dd2c34..00000000 --- a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupProcessor.java +++ /dev/null @@ -1,7 +0,0 @@ -package clustercode.api.cleanup; - -public interface CleanupProcessor { - - CleanupContext processStep(CleanupContext context); - -} diff --git a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupService.java b/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupService.java deleted file mode 100644 index 858ae009..00000000 --- a/clustercode.api.cleanup/src/main/java/clustercode/api/cleanup/CleanupService.java +++ /dev/null @@ -1,9 +0,0 @@ -package clustercode.api.cleanup; - -import clustercode.api.event.messages.TranscodeFinishedEvent; - -public interface CleanupService { - - void performCleanup(TranscodeFinishedEvent result); - -} diff --git a/clustercode.api.cluster/build.gradle b/clustercode.api.cluster/build.gradle deleted file mode 100644 index c95e525e..00000000 --- a/clustercode.api.cluster/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":clustercode.api.domain") - compile "${dep_jgroups}" - //compile 'com.google.guava:guava:23.5-jre' - compile "${dep_rxjava}" -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterService.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterService.java deleted file mode 100644 index 310930cd..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterService.java +++ /dev/null @@ -1,45 +0,0 @@ -package clustercode.api.cluster; - -import clustercode.api.domain.Media; - -import java.util.Optional; - -public interface ClusterService { - - /** - * Joins the cluster. If this Java process is the first member, it will create a new cluster. If a cluster cannot be - * created, it will downgrade to a single-node cluster. - */ - void joinCluster(); - - /** - * Removes the currently active task from the cluster, if there was one set. - */ - void removeTask(); - - /** - * Sets the cleanup which is being executed by this Java process. Replaces the old cleanup if present, only one task - * can be active. - * This method does nothing if not connected to the cluster. - * - * @param candidate the candidate, not null. - */ - void setTask(Media candidate); - - /** - * Returns true if the candidate is known across the cluster. If this Java process is the only member or not at all - * in the cluster, it returns false. - * - * @param candidate the candidate, not null. - * @return true if queued. - */ - boolean isQueuedInCluster(Media candidate); - - /** - * Gets the name of the cluster node. - * - * @return the name, otherwise empty. - */ - Optional getName(); - -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterTask.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterTask.java deleted file mode 100644 index 2027093a..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/ClusterTask.java +++ /dev/null @@ -1,37 +0,0 @@ -package clustercode.api.cluster; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serializable; -import java.time.ZonedDateTime; - -@AllArgsConstructor -@NoArgsConstructor -@Builder -@Data -public class ClusterTask implements Serializable { - - /** - * This is the relative path to the base input dir which a node is currently converting. - */ - private String sourceName; - - /** - * This represents the priority of the media file. - */ - private int priority; - - /** - * The absolute time when this task was created for scheduling. - */ - private ZonedDateTime dateAdded; - - /** - * The progress in percentage of the task. - */ - private double percentage; - -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskApiRequest.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskApiRequest.java deleted file mode 100644 index 8585e68d..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskApiRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package clustercode.api.cluster.messages; - -import lombok.AllArgsConstructor; -import lombok.Data; - -@Data -@AllArgsConstructor -public class CancelTaskApiRequest implements ClusterMessage { - - private String hostname; - - private boolean isCancelled; -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskRpcRequest.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskRpcRequest.java deleted file mode 100644 index d50a510a..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/CancelTaskRpcRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package clustercode.api.cluster.messages; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serializable; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class CancelTaskRpcRequest implements Serializable { - - private boolean cancelled; - - private String hostname; -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterMessage.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterMessage.java deleted file mode 100644 index ebbc15c9..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterMessage.java +++ /dev/null @@ -1,7 +0,0 @@ -package clustercode.api.cluster.messages; - -import java.io.Serializable; - -public interface ClusterMessage extends Serializable { - -} diff --git a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterTaskCollectionChanged.java b/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterTaskCollectionChanged.java deleted file mode 100644 index e31419ef..00000000 --- a/clustercode.api.cluster/src/main/java/clustercode/api/cluster/messages/ClusterTaskCollectionChanged.java +++ /dev/null @@ -1,26 +0,0 @@ -package clustercode.api.cluster.messages; - -import clustercode.api.cluster.ClusterTask; -import lombok.*; - -import java.util.Collection; - -@Data -@Builder -@AllArgsConstructor(access = AccessLevel.PACKAGE) -public class ClusterTaskCollectionChanged { - - @Singular("added") - private Collection added; - - /** - * Gets the tasks that are scheduled in the cluster. - */ - @Singular("tasks") - private Collection tasks; - - private boolean removed; - - private boolean cleared; - -} diff --git a/clustercode.api.config/build.gradle b/clustercode.api.config/build.gradle deleted file mode 100644 index 46fcd285..00000000 --- a/clustercode.api.config/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -version '1.0.0' - -dependencies { - compile "${dep_owner}" - compile project(":${proj_impl_util}") -} - -test { - environment "test.environment", "fromEnv" -} diff --git a/clustercode.api.config/src/main/java/clustercode/api/config/ConfigLoader.java b/clustercode.api.config/src/main/java/clustercode/api/config/ConfigLoader.java deleted file mode 100644 index 9dcc35af..00000000 --- a/clustercode.api.config/src/main/java/clustercode/api/config/ConfigLoader.java +++ /dev/null @@ -1,41 +0,0 @@ -package clustercode.api.config; - -import lombok.extern.slf4j.Slf4j; -import org.aeonbits.owner.Config; -import org.aeonbits.owner.ConfigFactory; - -import java.io.BufferedReader; -import java.io.FileReader; -import java.io.IOException; -import java.util.*; - -@Slf4j -public class ConfigLoader { - - private List> props = new ArrayList<>(Collections.singleton(System.getenv())); - - public T getConfig(Class type) { - T config = ConfigFactory.create(type, props.toArray(new Map[0])); - log.debug("{}: {}", type.getSimpleName(), config); - return config; - } - - public ConfigLoader loadDefaultsFromPropertiesFile(String filename) { - try { - props.add(loadFromFile(filename)); - return this; - } catch (IOException ex) { - log.warn("Could not find or read '{}'. If properties are missing, it will revert to hardcoded defaults" + - ".\n{}", filename, ex); - return this; - } - } - - private Properties loadFromFile(String filename) throws IOException { - Properties props = new Properties(); - try (BufferedReader reader = new BufferedReader(new FileReader(filename))) { - props.load(reader); - return props; - } - } -} diff --git a/clustercode.api.config/src/main/java/clustercode/api/config/converter/PathConverter.java b/clustercode.api.config/src/main/java/clustercode/api/config/converter/PathConverter.java deleted file mode 100644 index 6ccdd751..00000000 --- a/clustercode.api.config/src/main/java/clustercode/api/config/converter/PathConverter.java +++ /dev/null @@ -1,16 +0,0 @@ -package clustercode.api.config.converter; - -import clustercode.impl.util.FilesystemProvider; -import org.aeonbits.owner.Converter; - -import java.lang.reflect.Method; -import java.nio.file.Path; - -public class PathConverter implements Converter { - - @Override - public Path convert(Method method, String input) { - return FilesystemProvider.getInstance().getPath(input); - } - -} diff --git a/clustercode.api.config/src/test/java/clustercode/api/config/ConfigLoaderTest.java b/clustercode.api.config/src/test/java/clustercode/api/config/ConfigLoaderTest.java deleted file mode 100644 index 97ac1985..00000000 --- a/clustercode.api.config/src/test/java/clustercode/api/config/ConfigLoaderTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package clustercode.api.config; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ConfigLoaderTest { - - private ConfigLoader subject; - - @BeforeEach - public void setUp() { - subject = new ConfigLoader(); - } - - @Test - public void getConfig_ShouldLoadDefaultsFromAnnotations() { - TestConfig result = subject.getConfig(TestConfig.class); - - assertThat(result.variable()).isEqualTo("unassigned"); - } - - @Test - public void getConfig_ShouldOverrideDefaultsFromFile() { - - TestConfig result = subject - .loadDefaultsFromPropertiesFile("src/test/resources/ConfigLoaderTest.properties") - .getConfig(TestConfig.class); - - assertThat(result.variable()).isEqualTo("fromFile"); - } - - @Test - public void getConfig_ShouldLoadDefaults_IfFileNotFound() { - TestConfig result = subject - .loadDefaultsFromPropertiesFile("src/test/resources/ConfigLoaderTest.inexistent") - .getConfig(TestConfig.class); - - assertThat(result.variable()).isEqualTo("unassigned"); - } - - @Test - public void getConfig_ShouldLoadEnumFromFile_InOrder() { - - TestConfig result = subject - .loadDefaultsFromPropertiesFile("src/test/resources/ConfigLoaderTest.properties") - .getConfig(TestConfig.class); - - assertThat(result.enums()).containsSequence(TestEnum.VALUE_2, TestEnum.VALUE_1); - } - - @Test - public void getConfig_ShouldThrowException_IfEnumInexistent() { - Assertions.assertThrows(UnsupportedOperationException.class, () -> { - TestConfig result = subject - .loadDefaultsFromPropertiesFile("src/test/resources/ConfigLoaderTest.properties") - .getConfig(TestConfig.class); - - result.enum_inexistent(); - }); - } - - - @Disabled("This test works only if the env var test.environment is defined") - public void getConfig_ShouldOverrideFromFileWithEnv() { - - TestConfig result = subject - .loadDefaultsFromPropertiesFile("src/test/resources/ConfigLoaderTest.properties") - .getConfig(TestConfig.class); - - assertThat(result.environment()).isEqualTo("fromEnv"); - } -} diff --git a/clustercode.api.config/src/test/java/clustercode/api/config/TestConfig.java b/clustercode.api.config/src/test/java/clustercode/api/config/TestConfig.java deleted file mode 100644 index 837d37a1..00000000 --- a/clustercode.api.config/src/test/java/clustercode/api/config/TestConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package clustercode.api.config; - -import org.aeonbits.owner.Config; - -import java.util.List; - -public interface TestConfig extends Config { - - @Key("test.variable") - @DefaultValue("unassigned") - String variable(); - - @Key("test.environment") - String environment(); - - @Key("test.enum") - List enums(); - - @Key("test.enum2") - List enum_inexistent(); -} diff --git a/clustercode.api.config/src/test/java/clustercode/api/config/TestEnum.java b/clustercode.api.config/src/test/java/clustercode/api/config/TestEnum.java deleted file mode 100644 index 59fdfd27..00000000 --- a/clustercode.api.config/src/test/java/clustercode/api/config/TestEnum.java +++ /dev/null @@ -1,8 +0,0 @@ -package clustercode.api.config; - -public enum TestEnum { - - VALUE_1, - VALUE_2 - -} diff --git a/clustercode.api.config/src/test/resources/ConfigLoaderTest.properties b/clustercode.api.config/src/test/resources/ConfigLoaderTest.properties deleted file mode 100644 index 931068d0..00000000 --- a/clustercode.api.config/src/test/resources/ConfigLoaderTest.properties +++ /dev/null @@ -1,5 +0,0 @@ -test.variable = fromFile - -test.enum = VALUE_2,VALUE_1 - -test.enum2 = NOPE diff --git a/clustercode.api.config/src/test/resources/log4j2-debug.xml b/clustercode.api.config/src/test/resources/log4j2-debug.xml deleted file mode 100644 index 182b348c..00000000 --- a/clustercode.api.config/src/test/resources/log4j2-debug.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/clustercode.api.domain/build.gradle b/clustercode.api.domain/build.gradle deleted file mode 100644 index d48cca5a..00000000 --- a/clustercode.api.domain/build.gradle +++ /dev/null @@ -1,4 +0,0 @@ -version '1.0.0' - -dependencies { -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/Activator.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/Activator.java deleted file mode 100644 index c81d38dc..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/Activator.java +++ /dev/null @@ -1,11 +0,0 @@ -package clustercode.api.domain; - -public interface Activator { - - void preActivate(ActivatorContext context); - - void activate(ActivatorContext context); - - void deactivate(ActivatorContext context); - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/ActivatorContext.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/ActivatorContext.java deleted file mode 100644 index 2b2bdf1d..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/ActivatorContext.java +++ /dev/null @@ -1,8 +0,0 @@ -package clustercode.api.domain; - -/** - * Reserved for future use - */ -public interface ActivatorContext { - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/Constraint.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/Constraint.java deleted file mode 100644 index 0db9d799..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/Constraint.java +++ /dev/null @@ -1,20 +0,0 @@ -package clustercode.api.domain; - -/** - * Represents a matcher with which a media candidate can be excluded or included for job scheduling. The implementing - * class should throw a runtime exception in the constructor if there is a configuration error. The order of - * constraints is unspecified if there are more than one. As soon as one constraint returns false, the candidate is - * being excluded from scheduling. - */ -public interface Constraint { - - /** - * Tests whether the given candidate is viable for scheduling. The parameter must not be modified - * (non-interfering). If the constraint encountered an error, a warning is being logged and false is returned. - * - * @param candidate the candidate to test, not null. - * @return true if viable for scheduling, false otherwise. - */ - boolean accept(Media candidate); - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/Media.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/Media.java deleted file mode 100644 index bb4e2297..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/Media.java +++ /dev/null @@ -1,24 +0,0 @@ -package clustercode.api.domain; - -import lombok.*; - -import java.nio.file.Path; - -@AllArgsConstructor -@NoArgsConstructor -@Data -@Builder -@ToString(exclude = "priority") -public class Media { - - /** - * The file sourcePath which is relative to the base input dir. - */ - private Path sourcePath; - - /** - * The priority of the media candidate, {@literal >= 0}, where 0 means lowest priority. - */ - private int priority; - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/OutputFrameTuple.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/OutputFrameTuple.java deleted file mode 100644 index 84a38629..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/OutputFrameTuple.java +++ /dev/null @@ -1,42 +0,0 @@ -package clustercode.api.domain; - -import lombok.Getter; - -public class OutputFrameTuple { - - @Getter - public final String line; - @Getter - public final OutputType type; - - public OutputFrameTuple(OutputType type, String line) { - this.type = type; - this.line = line; - } - - public boolean isStdErrLine() { - return this.type == OutputType.STDERR; - } - - public boolean isStdOutLine() { - return this.type == OutputType.STDOUT; - } - - public enum OutputType { - STDERR, - STDOUT - } - - public static OutputFrameTuple fromStdOut(String line) { - return new OutputFrameTuple(OutputType.STDOUT, line); - } - - public static OutputFrameTuple fromStdErr(String line) { - return new OutputFrameTuple(OutputType.STDERR, line); - } - - @Override - public String toString() { - return type.name() + ": " + line; - } -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/Profile.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/Profile.java deleted file mode 100644 index 440cbcfa..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/Profile.java +++ /dev/null @@ -1,33 +0,0 @@ -package clustercode.api.domain; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class Profile { - - /** - * The location of the profile file. - */ - private Path location; - - /** - * The arguments that are parsed from the file. - */ - private List arguments; - - /** - * Any additional fields read during parsing. - */ - private Map fields; - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeResult.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeResult.java deleted file mode 100644 index 2d1545a3..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeResult.java +++ /dev/null @@ -1,17 +0,0 @@ -package clustercode.api.domain; - -import lombok.*; - -@ToString -@EqualsAndHashCode -@Builder -@AllArgsConstructor -public class TranscodeResult { - - @Getter - private final Media media; - - @Getter - private final Profile profile; - -} diff --git a/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeTask.java b/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeTask.java deleted file mode 100644 index 5d8c7b47..00000000 --- a/clustercode.api.domain/src/main/java/clustercode/api/domain/TranscodeTask.java +++ /dev/null @@ -1,26 +0,0 @@ -package clustercode.api.domain; - -import lombok.*; -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; - -import java.util.List; -import java.util.function.Consumer; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TranscodeTask { - - /** - * The source media object. - */ - private Media media; - - /** - * The profile to use for transcoding. - */ - private Profile profile; - -} diff --git a/clustercode.api.event/build.gradle b/clustercode.api.event/build.gradle deleted file mode 100644 index bda04835..00000000 --- a/clustercode.api.event/build.gradle +++ /dev/null @@ -1,7 +0,0 @@ -version '1.0.0' - -dependencies { - compile "${dep_rxjava}" - compile project(":${proj_api_domain}") - testCompile project(":clustercode.test.util").sourceSets.test.output -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBus.java b/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBus.java deleted file mode 100644 index 54d9aa9d..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBus.java +++ /dev/null @@ -1,46 +0,0 @@ -package clustercode.api.event; - -import io.reactivex.Observable; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; - -import java.util.concurrent.CompletableFuture; - -public interface RxEventBus { - - /** - * Registers a new event listener that gets notified if an event of the given type is being emitted. The - * filtering is done via {@link Class#isInstance(Object)}, so subclasses of eventType will be included as well. - * - * @param eventClass - * @param onNext - * @param - * @return - */ - Disposable listenFor(Class eventClass, Consumer onNext); - - Disposable listenFor(Class eventClass, Consumer onNext, Consumer onError); - - Observable listenFor(Class eventClass); - - /** - * Inserts the given object into the underlying event stream. This method blocks until all subscribers have - * processed the value. Use this if you do not care about the state of the event object after processing. - * - * @param event an object of any type. It will be lost after processing. - * @param the type of object (optional). - * @return the exact same event parameter, for fluent programming. - */ - T emit(T event); - - /** - * Emits the given event object in another thread. Useful for long running subscribers and you expect the state - * of the object to be changed after processing (this is useful for implementing return values). - * - * @param event the object. - * @param the type of object. - * @return the exact same event parameter wrapped in a completable future, for fluent programming. - */ - CompletableFuture emitAsync(T event); - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBusImpl.java b/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBusImpl.java deleted file mode 100644 index 8a7b4afe..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/RxEventBusImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.api.event; - -import io.reactivex.Observable; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.subjects.PublishSubject; -import io.reactivex.subjects.Subject; - -import java.util.concurrent.CompletableFuture; - -public class RxEventBusImpl implements RxEventBus { - - private final Subject bus = PublishSubject.create().toSerialized(); - - @Override - public Disposable listenFor(Class eventClass, - Consumer onNext) { - return listenFor(eventClass) - .subscribe(onNext); - } - - @Override - public Disposable listenFor(Class eventClass, - Consumer onNext, - Consumer onError) { - return listenFor(eventClass) - .subscribe(onNext, onError); - } - - @Override - public Observable listenFor(Class eventClass) { - return bus - .ofType(eventClass); - } - - - @Override - public T emit(T event) { - bus.onNext(event); - return event; - } - - @Override - public CompletableFuture emitAsync(T event) { - return CompletableFuture.supplyAsync(() ->{ - bus.onNext(event); - return event; - }); - } - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/CancelTranscodeMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/CancelTranscodeMessage.java deleted file mode 100644 index bc0ed245..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/CancelTranscodeMessage.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.api.event.messages; - -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - -@ToString -@Getter -@Setter -public class CancelTranscodeMessage { - - private boolean cancelled; - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/CleanupFinishedMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/CleanupFinishedMessage.java deleted file mode 100644 index 61533a7d..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/CleanupFinishedMessage.java +++ /dev/null @@ -1,5 +0,0 @@ -package clustercode.api.event.messages; - -public class CleanupFinishedMessage { - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ClusterConnectMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/ClusterConnectMessage.java deleted file mode 100644 index 41d40d3b..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ClusterConnectMessage.java +++ /dev/null @@ -1,17 +0,0 @@ -package clustercode.api.event.messages; - -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class ClusterConnectMessage { - - private String hostname; - - private int clusterSize; - - public boolean isConnected() { - return clusterSize > 1; - } -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaInClusterMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaInClusterMessage.java deleted file mode 100644 index 59654399..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaInClusterMessage.java +++ /dev/null @@ -1,15 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.Media; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class MediaInClusterMessage { - - private Media media; - - private boolean inCluster; - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaScannedMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaScannedMessage.java deleted file mode 100644 index 347755ca..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaScannedMessage.java +++ /dev/null @@ -1,24 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.Media; -import lombok.Builder; -import lombok.Data; -import lombok.NonNull; - -import java.util.List; - -@Data -@Builder -public class MediaScannedMessage { - - @NonNull - private List mediaList; - - public boolean listIsEmpty() { - return mediaList.isEmpty(); - } - - public boolean listHasEntries() { - return !mediaList.isEmpty(); - } -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaSelectedMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaSelectedMessage.java deleted file mode 100644 index 7f622169..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/MediaSelectedMessage.java +++ /dev/null @@ -1,21 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.Media; -import lombok.Builder; -import lombok.Data; - -@Data -@Builder -public class MediaSelectedMessage { - - private Media media; - - public boolean isSelected() { - return media != null; - } - - public boolean isNotSelected() { - return media == null; - } - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ProfileSelectedMessage.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/ProfileSelectedMessage.java deleted file mode 100644 index 5e214d29..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ProfileSelectedMessage.java +++ /dev/null @@ -1,25 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import lombok.Builder; -import lombok.Data; -import lombok.NonNull; - -@Data -@Builder -public class ProfileSelectedMessage { - - @NonNull - private Media media; - - private Profile profile; - - public boolean isSelected() { - return profile != null; - } - - public boolean isNotSelected() { - return profile == null; - } -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ScanMediaCommand.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/ScanMediaCommand.java deleted file mode 100644 index 0a62fe27..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/ScanMediaCommand.java +++ /dev/null @@ -1,5 +0,0 @@ -package clustercode.api.event.messages; - -public class ScanMediaCommand { - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/StartupCompletedEvent.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/StartupCompletedEvent.java deleted file mode 100644 index aa397542..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/StartupCompletedEvent.java +++ /dev/null @@ -1,15 +0,0 @@ -package clustercode.api.event.messages; - -import lombok.*; - -@EqualsAndHashCode -@ToString -@Builder -@AllArgsConstructor -public class StartupCompletedEvent { - - @Getter - @NonNull - private String mainVersion; - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskAddedEvent.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskAddedEvent.java deleted file mode 100644 index ba850965..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskAddedEvent.java +++ /dev/null @@ -1,25 +0,0 @@ -package clustercode.api.event.messages; - -import lombok.*; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class TaskAddedEvent { - - private UUID jobID; - - private int priority; - - private int sliceSize; - - @Builder.Default - private List args = new ArrayList<>(); - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskCompletedEvent.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskCompletedEvent.java deleted file mode 100644 index ed90b862..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TaskCompletedEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package clustercode.api.event.messages; - -import lombok.*; - -import java.util.UUID; - -@Getter -@Setter -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class TaskCompletedEvent { - - private UUID jobID; - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeBeginEvent.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeBeginEvent.java deleted file mode 100644 index f085979e..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeBeginEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.TranscodeTask; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -/** - * This event indicates that a trancoding job has begun. - */ -@Data -@Builder -@AllArgsConstructor -public class TranscodeBeginEvent { - - private TranscodeTask task; - -} diff --git a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeFinishedEvent.java b/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeFinishedEvent.java deleted file mode 100644 index 5e500a8d..00000000 --- a/clustercode.api.event/src/main/java/clustercode/api/event/messages/TranscodeFinishedEvent.java +++ /dev/null @@ -1,43 +0,0 @@ -package clustercode.api.event.messages; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.nio.file.Path; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TranscodeFinishedEvent { - - /** - * The original media instance. - */ - private Media media; - - /** - * The profile used for transcoding. - */ - private Profile profile; - - /** - * Whether transcoding was successful (exit code == 0). - */ - private boolean successful; - - /** - * The output file written during transcoding. - */ - private Path temporaryPath; - - /** - * Whether the task was cancelled. - */ - private boolean cancelled; - -} diff --git a/clustercode.api.event/src/test/java/clustercode/api/event/RxEventBusImplTest.java b/clustercode.api.event/src/test/java/clustercode/api/event/RxEventBusImplTest.java deleted file mode 100644 index c11b1c24..00000000 --- a/clustercode.api.event/src/test/java/clustercode/api/event/RxEventBusImplTest.java +++ /dev/null @@ -1,117 +0,0 @@ -package clustercode.api.event; - -import clustercode.test.util.CompletableUnitTest; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Duration; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -public class RxEventBusImplTest implements CompletableUnitTest { - - private RxEventBusImpl subject; - - @BeforeEach - public void setUp() throws Exception { - subject = new RxEventBusImpl(); - } - - @Test - public void shouldNotifySubscriber() { - Assertions.assertTimeoutPreemptively(Duration.ofMillis(1000), () -> { - - String message = "hello"; - - subject.listenFor(String.class, value -> { - assertThat(value).isEqualTo(message); - completeOne(); - }); - subject.emit(message); - - waitForCompletion(); - }); - } - - @Test - public void shouldThrowException_FromSubscriber() { - String message = "hello"; - - subject.listenFor(String.class, value -> { - throw new RuntimeException(message); - }, ex -> assertThat(ex).hasMessage(message)); - subject.emit(message); - - } - - @Test - public void register_ShouldFilter_AndNotifySubscriber() { - Assertions.assertTimeoutPreemptively(Duration.ofMillis(1000), () -> { - String message = "hello"; - Object ignore = new Object(); - - subject.listenFor(String.class, value -> { - assertThat(value).isEqualTo(message); - completeOne(); - }); - - subject.emit(message); - subject.emit(ignore); - - waitForCompletion(); - }); - } - - @Test - public void register_ShouldIgnoreMessage_FromSubscribers_ThatAreRemoved() { - String message = "hello"; - AtomicInteger called = new AtomicInteger(); - - subject.listenFor(String.class, value -> fail("This should not be called.")) - .dispose(); - - subject.emit(message); - - assertThat(called).hasValue(0); - } - - @Test - public void emit_ShouldEmitSynchronously() { - Message message = new Message(); - - subject.listenFor(Message.class, Message::increment); - subject.listenFor(Message.class, Message::increment); - subject.emit(message); - - assertThat(message.getValue()).isEqualTo(2); - } - - @Test - public void emitAsync_ShouldEmitAsynchronously() throws Exception { - Message message = new Message(); - - subject.listenFor(Message.class, Message::increment); - subject.listenFor(Message.class, Message::increment); - CompletableFuture result = subject.emitAsync(message); - - assertThat(result.get().getValue()).isEqualTo(2); - } - - private static class Message { - - private int value; - - void increment() { - this.value += 1; - } - - int getValue() { - return value; - } - } - -} diff --git a/clustercode.api.process/build.gradle b/clustercode.api.process/build.gradle deleted file mode 100644 index 9d14aca3..00000000 --- a/clustercode.api.process/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -version '1.0.0' - -dependencies { - compile "${dep_rxjava}" -} diff --git a/clustercode.api.process/src/main/java/clustercode/api/process/ExternalProcessService.java b/clustercode.api.process/src/main/java/clustercode/api/process/ExternalProcessService.java deleted file mode 100644 index bc8f14b1..00000000 --- a/clustercode.api.process/src/main/java/clustercode/api/process/ExternalProcessService.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.api.process; - -import io.reactivex.Single; - -import java.util.function.Consumer; - -public interface ExternalProcessService { - - Single start(ProcessConfiguration configuration); - - Single start(ProcessConfiguration configuration, - Consumer processHandler); - -} diff --git a/clustercode.api.process/src/main/java/clustercode/api/process/ProcessConfiguration.java b/clustercode.api.process/src/main/java/clustercode/api/process/ProcessConfiguration.java deleted file mode 100644 index 14d7ce44..00000000 --- a/clustercode.api.process/src/main/java/clustercode/api/process/ProcessConfiguration.java +++ /dev/null @@ -1,27 +0,0 @@ -package clustercode.api.process; - -import lombok.*; - -import java.nio.file.Path; -import java.util.List; -import java.util.function.Consumer; - -@Data -@Builder -@AllArgsConstructor -public class ProcessConfiguration { - - @NonNull - private Path executable; - - private Path workingDir; - - @Singular - private List arguments; - - @Singular - private List> errorObservers; - - @Singular - private List> stdoutObservers; -} diff --git a/clustercode.api.process/src/main/java/clustercode/api/process/RunningExternalProcess.java b/clustercode.api.process/src/main/java/clustercode/api/process/RunningExternalProcess.java deleted file mode 100644 index 1f989991..00000000 --- a/clustercode.api.process/src/main/java/clustercode/api/process/RunningExternalProcess.java +++ /dev/null @@ -1,46 +0,0 @@ -package clustercode.api.process; - -import java.util.concurrent.TimeUnit; - -/** - * Represents and wraps a started external process. - */ -public interface RunningExternalProcess { - - /** - * Causes the current thread to wait for the given time. - * - * @param millis the timeout in milliseconds, {@literal > 0}. - * @return this. - * @throws IllegalArgumentException if timeout is negative. - */ - RunningExternalProcess sleep(long millis); - - /** - * Causes the current thread to wait for the given time. - * - * @param timeout the timeout, {@literal > 0}. - * @param unit the time unit for the timeout parameter, not null. - * @return this. - * @throws IllegalArgumentException if timeout is negative. - */ - RunningExternalProcess sleep(long timeout, TimeUnit unit); - - /** - * Waits (indefinitly) for the termination of the subprocess. This method returns immediately if no process is - * running. - */ - void awaitDestruction(); - - /** - * Terminates the process with a timeout. This method returns earlier if the process terminated before the - * timeout occurred. - * - * @param timeout the time to wait for termination ({@literal > 0}). - * @param unit the unit of {@code timeout}, not null. - * @return true if the process terminated within the given timeout. False if the timeout occurred and the process - * may still be running. A warning will be logged if that happens. Also returns true if no process is active. - */ - boolean destroyNowWithTimeout(long timeout, TimeUnit unit); - -} diff --git a/clustercode.api.process/src/main/java/clustercode/api/process/ScriptInterpreter.java b/clustercode.api.process/src/main/java/clustercode/api/process/ScriptInterpreter.java deleted file mode 100644 index a71197d6..00000000 --- a/clustercode.api.process/src/main/java/clustercode/api/process/ScriptInterpreter.java +++ /dev/null @@ -1,9 +0,0 @@ -package clustercode.api.process; - -import java.nio.file.Path; - -public interface ScriptInterpreter { - - Path getPath(); - -} diff --git a/clustercode.api.rest.v1/build.gradle b/clustercode.api.rest.v1/build.gradle deleted file mode 100644 index 766eebc8..00000000 --- a/clustercode.api.rest.v1/build.gradle +++ /dev/null @@ -1,36 +0,0 @@ -plugins { - id "com.benjaminsproule.swagger" version "1.0.4" -} - -version '1.4.0' - -dependencies { - compile project(":${proj_api_transcode}") - compile project(":${proj_api_cluster}") - compile project(":${proj_api_event}") - - compileOnly "io.swagger:swagger-annotations:1.5.+" - - // REST dependencies - compile "io.logz:guice-jersey:1.0.+" - compile "com.owlike:genson:1.+" - -} - -swagger { - apiSource { - locations = ["clustercode.api.rest"] - schemes = ["http"] - host = "your.clustercode.domain:8080" - basePath = "/api" - info { - title = "Clustercode REST API" - version = "1.3.0" - description = "Convert your videos in a cluster!" - } - - templatePath = "${project.rootDir}/src/swagger/strapdown.html.hbs" - outputPath = "${project.buildDir}/swagger/swagger.html" - swaggerDirectory = "${project.buildDir}/swagger" - } -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/ProgressReport.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/ProgressReport.java deleted file mode 100644 index 11fb3a96..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/ProgressReport.java +++ /dev/null @@ -1,5 +0,0 @@ -package clustercode.api.rest.v1; - -public interface ProgressReport { - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServiceConfig.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServiceConfig.java deleted file mode 100644 index 03012787..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServiceConfig.java +++ /dev/null @@ -1,16 +0,0 @@ -package clustercode.api.rest.v1; - -import org.aeonbits.owner.Config; - -public interface RestServiceConfig extends Config { - - String REST_API_CONTEXT_PATH = "/v1"; - - @Key("CC_REST_API_ENABLED") - @DefaultValue("true") - boolean rest_enabled(); - - @Key("CC_REST_API_PORT") - @DefaultValue("7700") - int rest_api_port(); -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServicesActivator.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServicesActivator.java deleted file mode 100644 index c6d3756a..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/RestServicesActivator.java +++ /dev/null @@ -1,60 +0,0 @@ -package clustercode.api.rest.v1; - -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.StartupCompletedEvent; -import clustercode.api.rest.v1.rest.VersionApi; -import io.logz.guice.jersey.JerseyServer; -import io.reactivex.disposables.Disposable; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.util.LinkedList; -import java.util.List; - -@XSlf4j -public class RestServicesActivator implements Activator { - - private final JerseyServer jerseyServer; - private final RxEventBus eventBus; - private final List handlers = new LinkedList<>(); - - @Inject - RestServicesActivator(JerseyServer jerseyServer, - RxEventBus eventBus) { - this.jerseyServer = jerseyServer; - this.eventBus = eventBus; - } - - @Override - public void preActivate(ActivatorContext context) { - - } - - @Override - public void activate(ActivatorContext context) { - log.debug("Starting REST services..."); - try { - jerseyServer.start(); - handlers.add(eventBus - .listenFor(StartupCompletedEvent.class) - .subscribe(VersionApi::setVersion)); - } catch (Exception e) { - log.catching(e); - log.error("Disabled the REST API."); - } - } - - @Override - public void deactivate(ActivatorContext context) { - handlers.forEach(Disposable::dispose); - handlers.clear(); - try { - jerseyServer.stop(); - } catch (Exception e) { - log.catching(e); - log.error("Could not properly stop REST API."); - } - } -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/ApiError.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/ApiError.java deleted file mode 100644 index 50b5f9f8..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/ApiError.java +++ /dev/null @@ -1,17 +0,0 @@ -package clustercode.api.rest.v1.dto; - -import io.swagger.annotations.ApiModelProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class ApiError { - - @ApiModelProperty(value = "The error message. Defaults to the exception message.") - private String message; -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/Task.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/Task.java deleted file mode 100644 index 7be639e3..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/Task.java +++ /dev/null @@ -1,46 +0,0 @@ -package clustercode.api.rest.v1.dto; - -import com.owlike.genson.annotation.JsonDateFormat; -import com.owlike.genson.annotation.JsonProperty; -import io.swagger.annotations.ApiModelProperty; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.util.Date; - -@Data -@AllArgsConstructor -@NoArgsConstructor -@Builder -public class Task { - - @ApiModelProperty(value = "The path under the root input directory.", - example = "0/movies/amovie.mp4", required = true) - private String source; - - @ApiModelProperty(value = "The priority of the source file.", - example = "1", required = true) - private Integer priority; - - @JsonProperty - @JsonDateFormat(value = "yyyy-MM-dd'T'HH:mm:ssZ") - @ApiModelProperty(value = "The timestamp at which the task has been added.", - example = "2017-08-27T05:45:12+0200") - private Date added; - - @JsonProperty - @JsonDateFormat(value = "yyyy-MM-dd'T'HH:mm:ssZ") - @ApiModelProperty(value = "The timestamp at which the task was last updated.", - example = "2017-08-27T05:46:52+0200") - private Date updated; - - @ApiModelProperty(value = "The progress in percentage.", - example = "22.54") - private double progress; - - @ApiModelProperty(value = "The name of the node which processes this task.", example = "linux-24356") - private String nodename; -} - diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/VersionInfo.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/VersionInfo.java deleted file mode 100644 index d2b5ff3a..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/dto/VersionInfo.java +++ /dev/null @@ -1,16 +0,0 @@ -package clustercode.api.rest.v1.dto; - -import lombok.*; - -@EqualsAndHashCode -@ToString -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class VersionInfo { - - @NonNull - @Getter - private String mainVersion; - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHook.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHook.java deleted file mode 100644 index bf7abdec..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHook.java +++ /dev/null @@ -1,12 +0,0 @@ -package clustercode.api.rest.v1.hook; - -public interface ProgressHook { - - /** - * Gets the most recent percentage from the transcoding progress. - * - * @return a decimal value between 0 and 100, -1 if not job active. - */ - double getPercentage(); - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHookImpl.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHookImpl.java deleted file mode 100644 index 8abc1b25..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/ProgressHookImpl.java +++ /dev/null @@ -1,41 +0,0 @@ -package clustercode.api.rest.v1.hook; - -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.api.transcode.TranscodeReport; -import com.google.inject.Inject; -import lombok.Synchronized; -import lombok.extern.slf4j.XSlf4j; - -@XSlf4j -public class ProgressHookImpl implements ProgressHook { - - private TranscodeReport latestProgressOutput; - - @Inject - ProgressHookImpl(RxEventBus eventBus) { - - eventBus.listenFor(TranscodeReport.class, this::onProgressUpdated); - - eventBus.listenFor(TranscodeFinishedEvent.class, this::onTranscodingFinished); - } - - @Synchronized - private void onProgressUpdated(TranscodeReport output) { - log.entry(output); - this.latestProgressOutput = output; - } - - @Synchronized - private void onTranscodingFinished(TranscodeFinishedEvent event) { - log.entry(event); - this.latestProgressOutput = null; - } - - @Override - public double getPercentage() { - if (latestProgressOutput == null) return -1d; - return latestProgressOutput.getPercentage(); - } - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHook.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHook.java deleted file mode 100644 index 7bf8765f..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHook.java +++ /dev/null @@ -1,29 +0,0 @@ -package clustercode.api.rest.v1.hook; - -import clustercode.api.cluster.ClusterTask; - -import java.util.Collection; - -/** - * Represents an interface that hooks into the lifecycle of the state machine. - */ -public interface TaskHook { - - /** - * Gets the most recent collection of tasks that are scheduled in the cluster. The implementation listens for - * {@link clustercode.api.cluster.messages.ClusterTaskCollectionChanged} messages. Multiple invocations - * may return the same list, though it can be changed at arbitrary times. - * - * @return the collection (not null). May be empty. - */ - Collection getClusterTasks(); - - /** - * Tries to cancel the task that is running on the given host. This method runs synchronously and may take some - * time depending on network usage. - * - * @return true if a node confirmed cancellation, false if unknown or failed. - */ - boolean cancelTask(); - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHookImpl.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHookImpl.java deleted file mode 100644 index 2fd12cd5..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/hook/TaskHookImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -package clustercode.api.rest.v1.hook; - -import com.google.inject.Inject; -import lombok.Synchronized; -import lombok.extern.slf4j.XSlf4j; -import clustercode.api.cluster.ClusterTask; -import clustercode.api.cluster.messages.CancelTaskApiRequest; -import clustercode.api.cluster.messages.ClusterTaskCollectionChanged; -import clustercode.api.event.RxEventBus; - -import java.util.Collection; -import java.util.Collections; - -@XSlf4j -public class TaskHookImpl implements TaskHook { - - private final RxEventBus eventBus; - private Collection clusterTasks = Collections.emptyList(); - - @Inject - TaskHookImpl(RxEventBus eventBus) { - this.eventBus = eventBus; - eventBus.listenFor(ClusterTaskCollectionChanged.class) - .subscribe(this::onTaskCollectionChanged); - } - - @Synchronized - private void onTaskCollectionChanged(ClusterTaskCollectionChanged event) { - log.debug("Task collection changed: {}", event); - this.clusterTasks = event.getTasks(); - // for future web sockets usage. - } - - @Override - public Collection getClusterTasks() { - return clusterTasks; - } - - @Override - public boolean cancelTask() { - return eventBus.emit(new CancelTaskApiRequest("", false)).isCancelled(); - } - -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/AbstractRestApi.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/AbstractRestApi.java deleted file mode 100644 index 682c0d17..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/AbstractRestApi.java +++ /dev/null @@ -1,59 +0,0 @@ -package clustercode.api.rest.v1.rest; - -import clustercode.api.rest.v1.dto.ApiError; -import org.slf4j.ext.XLogger; -import org.slf4j.ext.XLoggerFactory; - -import javax.ws.rs.core.Response; -import java.util.function.Supplier; - -abstract class AbstractRestApi { - - protected final XLogger log = XLoggerFactory.getXLogger(getClass()); - - final Response createResponse(Supplier entitySupplier) { - try { - return Response.ok(entitySupplier.get()) - .build(); - } catch (Exception ex) { - log.catching(ex); - return Response - .status(Response.Status.INTERNAL_SERVER_ERROR) - .entity(ApiError - .builder() - .message(ex.getMessage()) - .build()) - .build(); - } - } - - final Response clientError(String message) { - return Response - .status(Response.Status.BAD_REQUEST) - .entity(ApiError - .builder() - .message(message) - .build()) - .build(); - } - - final Response serverError(Throwable ex) { - return Response - .serverError() - .entity(ApiError - .builder() - .message(ex.getMessage()) - .build()) - .build(); - } - - final Response serverError(String message) { - return Response - .serverError() - .entity(ApiError - .builder() - .message(message) - .build()) - .build(); - } -} diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/ProgressApi.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/ProgressApi.java deleted file mode 100644 index 62e55326..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/ProgressApi.java +++ /dev/null @@ -1,86 +0,0 @@ -package clustercode.api.rest.v1.rest; - -import clustercode.api.rest.v1.RestServiceConfig; -import clustercode.api.rest.v1.dto.ApiError; -import clustercode.api.rest.v1.hook.ProgressHook; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiResponse; -import io.swagger.annotations.ApiResponses; -import org.glassfish.jersey.server.JSONP; - -import javax.inject.Inject; -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; - -@Api -@Path(RestServiceConfig.REST_API_CONTEXT_PATH + "/progress") -public class ProgressApi extends AbstractRestApi { - - private final ProgressHook cache; - - @Inject - ProgressApi(ProgressHook cache) { - this.cache = cache; - } - - @GET - @Produces({MediaType.APPLICATION_JSON}) - @JSONP(queryParam = "callback") - @ApiOperation( - value = "Conversion progress", - notes = "Gets the percentage of the current encoding process. Returns -1 if no conversion active.", - response = Double.class, - tags = {"Progress"}) - @ApiResponses(value = { - @ApiResponse( - code = 200, - message = "OK", - response = Double.class), - @ApiResponse( - code = 500, - message = "Unexpected error", - response = ApiError.class)}) - public Response getProgress() { - return createResponse(cache::getPercentage); - } - - @Path("ffmpeg") - @GET - @Produces({MediaType.APPLICATION_JSON}) - @JSONP(queryParam = "callback") - @ApiOperation( - value = "This URL is not available anymore, use the 2.0 API.", - response = ApiError.class, - tags = {"Progress"}) - @ApiResponses(value = { - @ApiResponse( - code = 400, - message = "This URL is not available anymore, use the 2.0 API.", - response = ApiError.class)}) - public Response getFfmpegProgress() { - return clientError("This URL is not available anymore, use the 2.0 API."); - } - - @Path("handbrake") - @GET - @Produces({MediaType.APPLICATION_JSON}) - @JSONP(queryParam = "callback") - @ApiOperation( - value = "This URL is not available anymore, use the 2.0 API.", - response = ApiError.class, - tags = {"Progress"}) - @ApiResponses(value = { - @ApiResponse( - code = 400, - message = "This URL is not available anymore, use the 2.0 API.", - response = ApiError.class - )}) - public Response getHandbrakeProgress() { - return clientError("This URL is not available anymore, use the 2.0 API."); - } -} - diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/TasksApi.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/TasksApi.java deleted file mode 100644 index a9d8f949..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/TasksApi.java +++ /dev/null @@ -1,87 +0,0 @@ -package clustercode.api.rest.v1.rest; - -import clustercode.api.cluster.ClusterTask; -import clustercode.api.rest.v1.RestServiceConfig; -import clustercode.api.rest.v1.dto.ApiError; -import clustercode.api.rest.v1.dto.Task; -import clustercode.api.rest.v1.hook.TaskHook; -import io.swagger.annotations.*; - -import javax.inject.Inject; -import javax.ws.rs.*; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.sql.Date; -import java.text.DecimalFormat; -import java.util.stream.Collectors; - -@Api -@Path(RestServiceConfig.REST_API_CONTEXT_PATH + "/tasks") -public class TasksApi extends AbstractRestApi { - - private static final DecimalFormat decimalFormat = new DecimalFormat("#.##"); - private final TaskHook taskHook; - - @Inject - TasksApi(TaskHook taskHook) { - this.taskHook = taskHook; - } - - @GET - @Produces({MediaType.APPLICATION_JSON}) - @ApiOperation( - value = "Tasks information", - notes = "Provides operations to get task information. Completed tasks do not appear in the list.", - response = Task.class, responseContainer = "List", tags = {"Tasks"}) - @ApiResponses(value = { - @ApiResponse(code = 200, message = "An Array of Task object.", response = Task.class, responseContainer = - "List"), - @ApiResponse(code = 500, message = "Unexpected error", response = ApiError.class)}) - public Response getTasks() { - return createResponse(() -> taskHook - .getClusterTasks() - .stream() - .map(this::convertToDto) - .collect(Collectors.toList())); - } - - @DELETE - @Path("/stop") - @Produces({MediaType.APPLICATION_JSON}) - @ApiOperation( - value = "", - notes = "Stops the task that is currently being processed by the workers.", - response = Boolean.class) - @ApiResponses(value = { - @ApiResponse(code = 200, message = "Stopped the task successfully."), - @ApiResponse(code = 409, message = "The task has not been found."), - @ApiResponse(code = 412, message = "The parameters were not fully or correctly specified"), - @ApiResponse(code = 500, message = "Unexpected error", response = ApiError.class) - }) - public Response stopTask( - @QueryParam("hostname") - @ApiParam(value = "This param is not needed anymore, you can pass any value including null") - String hostname - ) { - try { - boolean cancelled = taskHook.cancelTask(); - if (cancelled) return Response.ok().build(); - return Response.status(Response.Status.CONFLICT).build(); - } catch (Exception ex) { - log.catching(ex); - return serverError(ex); - } - } - - private Task convertToDto(ClusterTask clusterTask) { - return Task.builder() - .priority(clusterTask.getPriority()) - .source(clusterTask.getSourceName()) - .added(Date.from(clusterTask.getDateAdded().toInstant())) - .updated(Date.from(clusterTask.getDateAdded().toInstant())) - .nodename("worker") - .progress(Double.parseDouble(decimalFormat.format(clusterTask.getPercentage()))) - .build(); - } -} - diff --git a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/VersionApi.java b/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/VersionApi.java deleted file mode 100644 index fb9220fa..00000000 --- a/clustercode.api.rest.v1/src/main/java/clustercode/api/rest/v1/rest/VersionApi.java +++ /dev/null @@ -1,47 +0,0 @@ -package clustercode.api.rest.v1.rest; - -import clustercode.api.event.messages.StartupCompletedEvent; -import clustercode.api.rest.v1.RestServiceConfig; -import clustercode.api.rest.v1.dto.VersionInfo; -import io.swagger.annotations.Api; -import io.swagger.annotations.ApiOperation; -import io.swagger.annotations.ApiResponse; -import io.swagger.annotations.ApiResponses; -import lombok.Synchronized; - -import javax.ws.rs.GET; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; - -@Api -@Path(RestServiceConfig.REST_API_CONTEXT_PATH + "/version") -public class VersionApi extends AbstractRestApi { - - private static StartupCompletedEvent versionInfo; - - @GET - @Produces({MediaType.APPLICATION_JSON}) - @ApiOperation( - value = "Version information", - notes = "Provides version information", - response = VersionInfo.class) - @ApiResponses(value = { - @ApiResponse(code = 200, message = "An object which contains the version information of the running " + - "instance", response = VersionInfo.class), - @ApiResponse(code = 500, message = "Startup not completed yet")}) - public Response getVersionInfo() { - if (versionInfo == null) return serverError("Startup not completed yet."); - return createResponse(() -> - VersionInfo.builder() - .mainVersion(versionInfo.getMainVersion()) - .build()); - } - - @Synchronized - public static void setVersion(StartupCompletedEvent event) { - versionInfo = event; - } - -} diff --git a/clustercode.api.scan/build.gradle b/clustercode.api.scan/build.gradle deleted file mode 100644 index e650cbc1..00000000 --- a/clustercode.api.scan/build.gradle +++ /dev/null @@ -1,6 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_domain}") - compile project(":${proj_impl_util}") -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/FileScanner.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/FileScanner.java deleted file mode 100644 index 2ebb5e97..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/FileScanner.java +++ /dev/null @@ -1,103 +0,0 @@ -package clustercode.api.scan; - -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - -/** - * Represents a convenient file scanner which searches for files using filters. This class throws RuntimeExceptions - * if it is being used incorrectly. - */ -public interface FileScanner { - - /** - * Specifies the root directory of the scanner. This setting is mandatory. - * - * @param dir the root dir. Has to be a directory. - * @return this. - */ - FileScanner searchIn(Path dir); - - /** - * Specifies whether subdirectories are being searched. This is equivalent to {@link #withDepth(Integer)} with a - * depth of Integer.MAX_VALUE. - * - * @param recursive true if enabled. - * @return this. - */ - FileScanner withRecursion(boolean recursive); - - /** - * Specifies the depth in which the directory should be searched. A value of 1 only scans the first level sub - * directories. {@link Integer#MAX_VALUE} means all directories. This value overrides - * {@link #withRecursion(boolean)}. - * - * @param value the depth value. Must be {@literal >= 1}. - * @return this. - */ - FileScanner withDepth(Integer value); - - /** - * Enables the search for directories instead of files. All filters apply to directories instead of files too. By - * default, this flag is set to false. - * - * @param dirs true if directory search should be enabled. - * @return this. - */ - FileScanner withDirectories(boolean dirs); - - /** - * Sets the list of file extensions which are allowed to be included in the scan. Specify an entry with e.g. " - * .txt" or "txt". If no matcher is provided, all sourcePath are being included. - * - * @param allowedExtensions the allowed extension list. - * @return this. - */ - FileScanner withFileExtensions(List allowedExtensions); - - /** - * Sets the extension which will cause e.g. the "foo/bar" file to be skipped if a sourcePath named - * "foo/bar.skipping" exists too. This matcher is executed after {@link #withFileExtensions(List)} - * - * @param skipping the skipping extension. - * @return this. - */ - FileScanner whileSkippingExtraFilesWith(String skipping); - - /** - * Sets the directory which is expected to contain the extra files for - * {@link net.chrigel.clustercode.cleanup.processor.MarkSourceDirProcessor}. Needs {@link - * #whileSkippingExtraFilesWith(String)} to be set. - * - * @param dir the directory. If it does not exist, it will be ignored. - * @return this. - */ - FileScanner whileSkippingExtraFilesIn(Path dir); - - /** - * Scans the file system. This method blocks until the file system scan is complete. Any IO exception is being - * logged as warning. - * - * @return A list of files which match the filters. Does not include the search dir. May be empty if no files - * found. Returns {@link Optional#empty()} if the search could not be conducted. - */ - Optional> scan(); - - /** - * Scans the file system using the settings in a stream-manner. - * - * @return the stream. - * @throws RuntimeException if the stream could not be opened due to IO error with the original exception as - * cause. The ex. is being logged as error. - */ - Stream stream(); - - /** - * Scans the file system using a stream. Returns an empty stream if an IOException occurred (which is being - * logged as warning). - * - * @return the (empty) stream. - */ - Stream streamAndIgnoreErrors(); -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/MediaScanService.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/MediaScanService.java deleted file mode 100644 index 91800511..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/MediaScanService.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.api.scan; - -import clustercode.api.domain.Media; - -import java.nio.file.Path; -import java.util.List; -import java.util.Map; - -public interface MediaScanService { - - /** - * Gets a map of files under the base input dir. Each key contains the sourcePath to a - * directory - * which contains a number as name. The number represents the priority of the queue. The sourcePath is relative to - * the - * base input directory. The value of the map is a list of files that were found in the sourcePath, with each - * sourcePath - * being relative to the base input directory. The non-null list is empty if no candidates for queueing were - * found. Completed jobs are excluded from the list, as well as files which do not match the whitelisted - * extensions. - *

- * Example:
- * {@code /input/1/file_That_Is_Being_Included.mp4} - * {@code /input/2/file_completed_will_be_ignored.mp4} because of - * {@code /input/2/file_completed_will_be_ignored.mp4.done} - * {@code /input/3/subdir/this_textfile_will_be_ignored.txt} - * {@code /input/this_directory_is_not_a_number_and_will_be_ignored}
- * The resulting map would contain 3 keys, but only key {@code /input/1} will have an entry in the list. - *

- *

- * This method is blocking until the file system has been recursively scanned. - *

- * - * @return the map as described. Empty map if no priority directories found. - * @throws RuntimeException if base input dir is not readable. - */ - Map> retrieveFiles(); - - /** - * Gets a list of media files under base input dir. See {@link #retrieveFiles()}. - * The resulting list would contain only {@code /input/1/file_That_Is_Being_Included.mp4} in the given example. - *

- * This method is blocking until the file system has been recursively scanned. - *

- * - * @return the list as described. Empty list if no media files were found. - * @throws RuntimeException if base input dir is not readable. - */ - List retrieveFilesAsList(); - -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileMatcher.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileMatcher.java deleted file mode 100644 index 065abe56..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileMatcher.java +++ /dev/null @@ -1,9 +0,0 @@ -package clustercode.api.scan; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.impl.util.OptionalFunction; - -public interface ProfileMatcher extends OptionalFunction { - -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileParser.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileParser.java deleted file mode 100644 index 7419dcb4..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileParser.java +++ /dev/null @@ -1,20 +0,0 @@ -package clustercode.api.scan; - -import clustercode.api.domain.Profile; - -import java.nio.file.Path; -import java.util.Optional; - -public interface ProfileParser { - - /** - * Reads and parses the given text file. Logs a warning if the file could not be read and parsed. Empty lines and - * lines beginning with "#" are treated as comments and ignored. Expects UTF-8 formatted files. Lines with - * whitespaces are splitted. - * - * @param path the path to the file. - * @return an optional containing the profile. Empty if the file could not be read. - */ - Optional parseFile(Path path); - -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileScanService.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileScanService.java deleted file mode 100644 index 1758269e..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/ProfileScanService.java +++ /dev/null @@ -1,20 +0,0 @@ -package clustercode.api.scan; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; - -import java.util.Optional; - -public interface ProfileScanService { - - /** - * This java doc is invalid currently, as {@link ProfileMatchers} allow custom - * strategies. - * - * @param candidate the selected media job, not null. The instance will not be modified. - * @return an empty profile if it could not be parsed for some reason. Otherwise contains the most appropriate - * profile for the internally configured transcoder. - */ - Optional selectProfile(Media candidate); - -} diff --git a/clustercode.api.scan/src/main/java/clustercode/api/scan/SelectionService.java b/clustercode.api.scan/src/main/java/clustercode/api/scan/SelectionService.java deleted file mode 100644 index d079c028..00000000 --- a/clustercode.api.scan/src/main/java/clustercode/api/scan/SelectionService.java +++ /dev/null @@ -1,12 +0,0 @@ -package clustercode.api.scan; - -import clustercode.api.domain.Media; - -import java.util.List; -import java.util.Optional; - -public interface SelectionService { - - Optional selectMedia(List list); - -} diff --git a/clustercode.api.transcode/build.gradle b/clustercode.api.transcode/build.gradle deleted file mode 100644 index b2075a78..00000000 --- a/clustercode.api.transcode/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_impl_util}") - compile project(":${proj_api_domain}") - compile project(":${proj_api_config}") - compile project(":${proj_api_event}") - compile "${dep_rxjava}" - compile "${dep_owner}" -} diff --git a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeProgress.java b/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeProgress.java deleted file mode 100644 index 06d58fdc..00000000 --- a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeProgress.java +++ /dev/null @@ -1,15 +0,0 @@ -package clustercode.api.transcode; - -import clustercode.api.domain.Media; -import clustercode.api.domain.OutputFrameTuple; -import clustercode.api.domain.Profile; - -import java.util.function.Consumer; - -public interface TranscodeProgress { - - Media getMedia(); - - Profile getProfile(); - -} diff --git a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeReport.java b/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeReport.java deleted file mode 100644 index 9d8eb81d..00000000 --- a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodeReport.java +++ /dev/null @@ -1,7 +0,0 @@ -package clustercode.api.transcode; - -public interface TranscodeReport { - - double getPercentage(); - -} diff --git a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodingService.java b/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodingService.java deleted file mode 100644 index f628b9ee..00000000 --- a/clustercode.api.transcode/src/main/java/clustercode/api/transcode/TranscodingService.java +++ /dev/null @@ -1,39 +0,0 @@ -package clustercode.api.transcode; - -import clustercode.api.domain.TranscodeTask; -import clustercode.api.event.messages.TranscodeBeginEvent; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import io.reactivex.Flowable; -import io.reactivex.Observable; - -import java.util.function.Consumer; - -public interface TranscodingService { - - /** - * Performs the transcoding. - * - * @param task the cleanup, not null. - */ - void transcode(TranscodeTask task); - - /** - * Cancels the current transcoding job. Does nothing if no transcoding active. - * - * @return true if the job has been cancelled or none active. False if failed or cancellation timed out. - */ - boolean cancelTranscode(); - - Flowable onTranscodeBegin(); - - Flowable onTranscodeFinished(); - - Observable onProgressUpdated(); - - TranscodingService onTranscodeBegin(Consumer listener); - - TranscodingService onTranscodeFinished(Consumer listener); - - TranscodingService onProgressUpdated(Consumer listener); - -} diff --git a/clustercode.impl.cleanup/build.gradle b/clustercode.impl.cleanup/build.gradle deleted file mode 100644 index e41738dd..00000000 --- a/clustercode.impl.cleanup/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_cleanup}") - compile project(":${proj_api_config}") - compile project(":${proj_api_process}") - testCompile project(":${proj_test_util}").sourceSets.test.output -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupActivator.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupActivator.java deleted file mode 100644 index 64e5c57e..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupActivator.java +++ /dev/null @@ -1,44 +0,0 @@ -package clustercode.impl.cleanup; - -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import io.reactivex.disposables.Disposable; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; -import java.util.LinkedList; -import java.util.List; - -@Slf4j -public class CleanupActivator implements Activator { - - private final RxEventBus eventBus; - private final CleanupMessageHandler messageHandler; - private final List handlers = new LinkedList<>(); - - @Inject - CleanupActivator(RxEventBus eventBus, CleanupMessageHandler messageHandler) { - this.eventBus = eventBus; - this.messageHandler = messageHandler; - } - - @Override - public void preActivate(ActivatorContext context) { - log.debug("Activating cleanup services."); - handlers.add(eventBus - .listenFor(TranscodeFinishedEvent.class, messageHandler::onTranscodeFinished)); - } - - @Override - public void activate(ActivatorContext context) { - } - - @Override - public void deactivate(ActivatorContext context) { - log.debug("Deactivating cleanup services."); - handlers.forEach(Disposable::dispose); - handlers.clear(); - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupConfig.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupConfig.java deleted file mode 100644 index cfa09ca2..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupConfig.java +++ /dev/null @@ -1,65 +0,0 @@ -package clustercode.impl.cleanup; - -import clustercode.api.config.converter.PathConverter; -import clustercode.impl.cleanup.processor.CleanupProcessors; -import org.aeonbits.owner.Config; - -import java.nio.file.Path; -import java.util.List; - -public interface CleanupConfig extends Config { - - /** - * Gets the root path of the output directory. - * - * @return the path, not null. - */ - @ConverterClass(PathConverter.class) - @Key("CC_MEDIA_OUTPUT_DIR") - @DefaultValue("/output") - Path base_output_dir(); - - @ConverterClass(PathConverter.class) - @Key("CC_MEDIA_INPUT_DIR") - @DefaultValue("/input") - Path base_input_dir(); - /** - * Returns true if existing files are allowed to be overwritten. - */ - @Key("CC_CLEANUP_OVERWRITE") - @DefaultValue("true") - boolean overwrite_files(); - - /** - * Gets the group id of the new owner of the output file(s). - */ - @Key("CC_CLEANUP_CHOWN_GROUPID") - @DefaultValue("0") - int group_id(); - - /** - * Gets the user id of the new owner of the output file(s). - */ - @Key("CC_CLEANUP_CHOWN_USERID") - @DefaultValue("0") - int user_id(); - - /** - * Gets the root path of the directory in which the sources should get marked as done. - * - * @return the path or empty. - */ - @ConverterClass(PathConverter.class) - @Key("CC_CLEANUP_MARK_SOURCE_DIR") - @DefaultValue("/input/done") - Path mark_source_dir(); - - @Key("CC_MEDIA_SKIP_NAME") - @DefaultValue(".done") - String skip_extension(); - - @Key("CC_CLEANUP_STRATEGY") - @DefaultValue("STRUCTURED_OUTPUT MARK_SOURCE") - @Separator(" ") - List cleanup_processors(); -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupMessageHandler.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupMessageHandler.java deleted file mode 100644 index 36542baa..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupMessageHandler.java +++ /dev/null @@ -1,28 +0,0 @@ -package clustercode.impl.cleanup; - -import clustercode.api.cleanup.CleanupService; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.CleanupFinishedMessage; -import clustercode.api.event.messages.TranscodeFinishedEvent; - -import javax.inject.Inject; - -class CleanupMessageHandler { - - private final RxEventBus eventBus; - private final CleanupService cleanupService; - - @Inject - CleanupMessageHandler( - RxEventBus eventBus, - CleanupService cleanupService) { - this.eventBus = eventBus; - this.cleanupService = cleanupService; - } - - void onTranscodeFinished(TranscodeFinishedEvent msg) { - cleanupService.performCleanup(msg); - eventBus.emitAsync(new CleanupFinishedMessage()); - } - -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupServiceImpl.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupServiceImpl.java deleted file mode 100644 index 980a1830..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/CleanupServiceImpl.java +++ /dev/null @@ -1,36 +0,0 @@ -package clustercode.impl.cleanup; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.api.cleanup.CleanupService; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.processor.CleanupProcessors; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; -import java.util.Map; - -@Slf4j -public class CleanupServiceImpl implements CleanupService { - - private final Map cleanupProcessorMap; - - @Inject - CleanupServiceImpl(Map cleanupProcessorMap) { - this.cleanupProcessorMap = cleanupProcessorMap; - } - - @Override - public void performCleanup(TranscodeFinishedEvent result) { - - CleanupContext context = CleanupContext.builder() - .transcodeFinishedEvent(result) - .build(); - - log.info("Performing cleanup..."); - for (CleanupProcessor cleanupProcessor : cleanupProcessorMap.values()) { - context = cleanupProcessor.processStep(context); - } - log.info("Cleanup completed."); - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractMarkSourceProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractMarkSourceProcessor.java deleted file mode 100644 index d564697a..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractMarkSourceProcessor.java +++ /dev/null @@ -1,48 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.impl.cleanup.CleanupConfig; -import org.slf4j.ext.XLogger; -import org.slf4j.ext.XLoggerFactory; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -public abstract class AbstractMarkSourceProcessor implements CleanupProcessor { - - protected final CleanupConfig cleanupConfig; - protected XLogger log = XLoggerFactory.getXLogger(getClass()); - - protected AbstractMarkSourceProcessor(CleanupConfig cleanupConfig) { - this.cleanupConfig = cleanupConfig; - } - - @Override - public final CleanupContext processStep(CleanupContext context) { - log.entry(context); - if (!isStepValid(context)) return log.exit(context); - return log.exit(doProcessStep(context)); - } - - protected abstract CleanupContext doProcessStep(CleanupContext context); - - protected abstract boolean isStepValid(CleanupContext cleanupContext); - - protected final void createMarkFile(Path marked, Path source) { - try { - log.debug("Creating file {}", marked); - Files.createFile(marked); - } catch (IOException e) { - log.error("Could not create file {}: {}", marked, e.getMessage()); - log.warn("It may be possible that {} will be scheduled for transcoding again, as it could not be " + - "marked as done.", source); - } - } - - protected Path getSourcePath(CleanupContext context) { - return cleanupConfig.base_input_dir().resolve( - context.getTranscodeFinishedEvent().getMedia().getSourcePath()); - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractOutputDirectoryProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractOutputDirectoryProcessor.java deleted file mode 100644 index 725aa892..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/AbstractOutputDirectoryProcessor.java +++ /dev/null @@ -1,63 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.util.FileUtil; -import org.slf4j.ext.XLogger; -import org.slf4j.ext.XLoggerFactory; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.time.Clock; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; - -public abstract class AbstractOutputDirectoryProcessor implements CleanupProcessor { - - public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd.HH-mm-ss"); - protected final XLogger log = XLoggerFactory.getXLogger(getClass()); - protected final Clock clock; - - protected AbstractOutputDirectoryProcessor(Clock clock) { - this.clock = clock; - } - - /** - * Moves the source file to the target file. If the file exists and {@code overwrite} is enabled, then the file is - * being overwritten. If the file exists and {@code overwrite} is disabled, then the target path is being modified - * using {@link FileUtil#getTimestampedPath(Path, TemporalAccessor, DateTimeFormatter)}. - * - * @param source the source file. - * @param target the target outputfile. - * @param overwrite the overwrite flag. - * @return the final target path. - * @throws RuntimeException if the file could not be moved. - */ - protected Path moveAndReplaceExisting(Path source, Path target, boolean overwrite) { - log.entry(source, target, "overwrite=".concat(Boolean.toString(overwrite))); - if (Files.exists(target) && !overwrite) { - log.debug("Target file {} exists already. Applying timestamp to target.", target); - target = FileUtil.getTimestampedPath(target, ZonedDateTime.now(clock), FORMATTER); - } - - try { - log.info("Moving file from {} to {}...", source, target); - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); - return target; - } catch (IOException e) { - log.error("Could not move {} to {}: {}", source, target, e.toString()); - throw new RuntimeException(e); - } - } - - protected boolean isFailed(TranscodeFinishedEvent result) { - if (!result.isSuccessful()) { - log.warn("Not moving file {}, since transcoding failed.", result.getTemporaryPath().toString()); - return true; - } - return false; - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/ChangeOwnerProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/ChangeOwnerProcessor.java deleted file mode 100644 index e94485cb..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/ChangeOwnerProcessor.java +++ /dev/null @@ -1,100 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.api.process.ExternalProcessService; -import clustercode.api.process.ProcessConfiguration; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.impl.util.Platform; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.List; - -@XSlf4j -public class ChangeOwnerProcessor implements CleanupProcessor { - - private final CleanupConfig cleanupConfig; - private final ExternalProcessService externalProcessService; - private boolean enabled; - - @Inject - ChangeOwnerProcessor( - CleanupConfig cleanupConfig, - ExternalProcessService externalProcessService) { - this.cleanupConfig = cleanupConfig; - this.externalProcessService = externalProcessService; - checkId(cleanupConfig.group_id()); - checkId(cleanupConfig.user_id()); - checkSettings(); - } - - private void checkSettings() { - enabled = true; - if (Platform.currentPlatform() == Platform.WINDOWS) { - log.warn("Changing owner on Windows is not supported. This strategy will be ignored."); - enabled = false; - } - } - - @Override - public CleanupContext processStep(CleanupContext context) { - log.entry(context); - - if (!isStepValid(context)) return log.exit(context); - - Path outputFile = context.getOutputPath(); - List args = buildArguments(outputFile); - - log.info("Changing owner of {} to {}.", outputFile, args.get(0)); - - ProcessConfiguration config = ProcessConfiguration - .builder() - .executable(Paths.get("/bin", "chown")) - .arguments(args) - .stdoutObserver(System.out::println) - .build(); - - externalProcessService - .start(config) - .subscribe(exitCode -> { - if (exitCode > 0) log.warn( - "Could not change owner of {}. Exit code of 'chown' with arguments {} was {}.", - outputFile, args, exitCode); - }, log::catching); - - return log.exit(context); - } - - private boolean isStepValid(CleanupContext context) { - if (!enabled) { - log.debug("This processor is disabled."); - return false; - } - - if (context.getOutputPath() == null) { - log.warn("Output file has not been created yet. Are you sure you have " + - "configured the cleanup strategies correctly?"); - return false; - } - return true; - } - - private List buildArguments(Path outputFile) { - return Arrays.asList( - cleanupConfig.user_id() + ":" + cleanupConfig.group_id(), - outputFile.toString()); - } - - private void checkId(int id) { - if (0 > id) { - throw new InvalidConfigurationException("Cannot use a negative owner id"); - } else if (id > 65534) { - throw new InvalidConfigurationException("Cannot use an owner id higher than 65534"); - } - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/CleanupProcessors.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/CleanupProcessors.java deleted file mode 100644 index 5fa825d7..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/CleanupProcessors.java +++ /dev/null @@ -1,13 +0,0 @@ -package clustercode.impl.cleanup.processor; - - -public enum CleanupProcessors { - - UNIFIED_OUTPUT, - STRUCTURED_OUTPUT, - DELETE_SOURCE, - MARK_SOURCE, - MARK_SOURCE_DIR, - CHOWN; - -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/DeleteSourceProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/DeleteSourceProcessor.java deleted file mode 100644 index 6bd4a670..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/DeleteSourceProcessor.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.impl.cleanup.CleanupConfig; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Provides a processor which deletes the source file. - */ -@XSlf4j -public class DeleteSourceProcessor implements CleanupProcessor { - - private final CleanupConfig cleanupConfig; - - @Inject - DeleteSourceProcessor(CleanupConfig cleanupConfig) { - this.cleanupConfig = cleanupConfig; - } - - @Override - public CleanupContext processStep(CleanupContext context) { - log.entry(context); - - Path source = cleanupConfig.base_input_dir().resolve( - context.getTranscodeFinishedEvent().getMedia().getSourcePath()); - - if (!context.getTranscodeFinishedEvent().isSuccessful()) { - log.warn("Not deleting {}, since transcoding failed.", source); - return log.exit(context); - } - - deleteFile(source); - - return log.exit(context); - } - - void deleteFile(Path path) { - try { - log.info("Deleting {}.", path); - Files.deleteIfExists(path); - } catch (IOException e) { - log.warn("Could not delete file {}: {}", path, e.getMessage()); - } - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessor.java deleted file mode 100644 index 9e5eeede..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessor.java +++ /dev/null @@ -1,70 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.impl.util.FileUtil; - -import javax.inject.Inject; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Provides a processor which marks the source file as done in a designated directory, so that it would not be - * rescheduled for transcoding during the next scan. - */ -public class MarkSourceDirProcessor - extends AbstractMarkSourceProcessor { - - @Inject - MarkSourceDirProcessor(CleanupConfig cleanupSettings) { - super(cleanupSettings); - } - - @Override - protected CleanupContext doProcessStep(CleanupContext context) { - Path source = getSourcePath(context); - - Path marked = createOutputDirectoryTree( - cleanupConfig.base_input_dir().relativize(source.resolveSibling( - source.getFileName().toString() + cleanupConfig.skip_extension()))); - - createMarkFile(marked, source); - return context; - } - - @Override - protected boolean isStepValid(CleanupContext context) { - Path source = getSourcePath(context); - - if (!context.getTranscodeFinishedEvent().isSuccessful()) { - log.warn("Not marking {} as done, since transcoding failed.", source); - return false; - } - - if (!Files.exists(source)) { - log.warn("Not marking {} as done, since the file does not exist (anymore).", source); - return false; - } - return true; - } - - /** - * Creates the directory tree in {@link CleanupConfig#mark_source_dir()} using the given path. The given - * path will be stripped from the root directory. The parent directories of the target file will be created if the - * tree does not exist. - *

- * Example: {@code mediaSource} is "0/subdir/file.ext". The base output dir is assumed to be "output". The return - * value results in being "output/subdir/file.ext", where "output/subdir" will be created. The file itself will NOT - * be created. - *

- * - * @param mediaSource the media source, which requires at least 1 parent element. - * @return the target as described. - */ - Path createOutputDirectoryTree(Path mediaSource) { - Path target = cleanupConfig.mark_source_dir().resolve(mediaSource); - FileUtil.createParentDirectoriesFor(target); - return target; - } - -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceProcessor.java deleted file mode 100644 index 15b028e3..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/MarkSourceProcessor.java +++ /dev/null @@ -1,46 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.impl.cleanup.CleanupConfig; - -import javax.inject.Inject; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Provides a processor which marks the source file as done, so that it would not be rescheduled for transcoding during - * the next scan. - */ -public class MarkSourceProcessor - extends AbstractMarkSourceProcessor { - - @Inject - MarkSourceProcessor(CleanupConfig cleanupConfig) { - super(cleanupConfig); - } - - @Override - protected CleanupContext doProcessStep(CleanupContext context) { - Path source = getSourcePath(context); - - Path marked = source.resolveSibling(source.getFileName().toString() + cleanupConfig.skip_extension()); - - createMarkFile(marked, source); - return context; - } - - @Override - protected boolean isStepValid(CleanupContext context) { - Path source = getSourcePath(context); - if (!context.getTranscodeFinishedEvent().isSuccessful()) { - log.warn("Not marking {} as done, since transcoding failed.", source); - return false; - } - if (!Files.exists(source)) { - log.warn("Not marking {} as done, since the file does not exist (anymore).", source); - return false; - } - return true; - } - -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessor.java deleted file mode 100644 index a83c5934..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessor.java +++ /dev/null @@ -1,69 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.impl.util.FileUtil; - -import javax.inject.Inject; -import java.nio.file.Path; -import java.time.Clock; - -/** - * Provides a processor which recreates the source directory tree in the configured root output directory. The priority - * directory will be omitted. If overwriting is disabled, the file will have a timestamp appended in the file name. - */ -public class StructuredOutputDirectoryProcessor - extends AbstractOutputDirectoryProcessor { - - private final CleanupConfig cleanupConfig; - - @Inject - StructuredOutputDirectoryProcessor(CleanupConfig cleanupConfig, - Clock clock) { - super(clock); - this.cleanupConfig = cleanupConfig; - FileUtil.createDirectoriesFor(cleanupConfig.base_output_dir()); - } - - @Override - public CleanupContext processStep(CleanupContext context) { - log.entry(context); - TranscodeFinishedEvent result = context.getTranscodeFinishedEvent(); - - if (isFailed(result)) return log.exit(context); - - Path source = result.getTemporaryPath(); - - Path media = result.getMedia().getSourcePath(); - - Path target = createOutputDirectoryTree(media); - - Path tempFile = source.getFileName(); - Path finalPath = target.getParent().resolve(tempFile); - FileUtil.createParentDirectoriesFor(finalPath); - context.setOutputPath(moveAndReplaceExisting(source, finalPath, cleanupConfig.overwrite_files())); - return log.exit(context); - } - - /** - * Creates the directory tree in {@link CleanupConfig#base_output_dir()} using the given path. The given - * path will be stripped from the root directory. The parent directories of the target file will be created if the - * tree does not exist. - *

- * Example: {@code mediaSource} is "0/subdir/file.ext". The base output dir is assumed to be "output". The return - * value results in being "output/subdir/file.ext", where "output/subdir" will be created. The file itself will NOT - * be created. - *

- * - * @param mediaSource the media source, which requires at least 1 parent element. - * @return the target as described. - */ - Path createOutputDirectoryTree(Path mediaSource) { - - Path relativeParent = mediaSource.subpath(1, mediaSource.getNameCount()); - Path target = cleanupConfig.base_output_dir().resolve(relativeParent); - FileUtil.createParentDirectoriesFor(target); - return target; - } -} diff --git a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessor.java b/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessor.java deleted file mode 100644 index b6982346..00000000 --- a/clustercode.impl.cleanup/src/main/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessor.java +++ /dev/null @@ -1,42 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.impl.util.FileUtil; - -import javax.inject.Inject; -import java.nio.file.Path; -import java.time.Clock; - -/** - * Provides a processor which moves the transcode result to a single directory. - */ -public class UnifiedOutputDirectoryProcessor - extends AbstractOutputDirectoryProcessor { - - private final CleanupConfig cleanupConfig; - - @Inject - UnifiedOutputDirectoryProcessor(CleanupConfig cleanupConfig, - Clock clock) { - super(clock); - this.cleanupConfig = cleanupConfig; - FileUtil.createDirectoriesFor(cleanupConfig.base_output_dir()); - } - - @Override - public CleanupContext processStep(CleanupContext context) { - log.entry(context); - TranscodeFinishedEvent result = context.getTranscodeFinishedEvent(); - - if (isFailed(result)) return log.exit(context); - - Path source = result.getTemporaryPath(); - Path target = cleanupConfig.base_output_dir().resolve(source.getFileName()); - - context.setOutputPath(moveAndReplaceExisting(source, target, cleanupConfig.overwrite_files())); - return log.exit(context); - } - -} diff --git a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/DeleteSourceProcessorTest.java b/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/DeleteSourceProcessorTest.java deleted file mode 100644 index 33318096..00000000 --- a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/DeleteSourceProcessorTest.java +++ /dev/null @@ -1,65 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.domain.Media; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -public class DeleteSourceProcessorTest implements FileBasedUnitTest { - - private DeleteSourceProcessor subject; - private Path inputDir; - - @Mock - private CleanupConfig cleanupConfig; - @Spy - private TranscodeFinishedEvent transcodeFinishedEvent; - @Spy - private CleanupContext context; - @Spy - private Media media; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - - inputDir = getPath("input"); - when(cleanupConfig.base_input_dir()).thenReturn(inputDir); - transcodeFinishedEvent.setMedia(media); - context.setTranscodeFinishedEvent(transcodeFinishedEvent); - subject = new DeleteSourceProcessor(cleanupConfig); - } - - @Test - public void processStep_ShouldDeleteSourceFile_IfFileExists() throws Exception { - - Path source = createFile(getPath("0", "video.ext")); - media.setSourcePath(source); - - subject.processStep(context); - - assertThat(inputDir.resolve(source)).doesNotExist(); - } - - @Test - public void processStep_ShouldDoNothing_IfFileNotExists() throws Exception { - Path source = getPath("0", "video.ext"); - media.setSourcePath(source); - - subject.processStep(context); - - assertThat(source).doesNotExist(); - } -} diff --git a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessorTest.java b/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessorTest.java deleted file mode 100644 index 269d16e5..00000000 --- a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceDirProcessorTest.java +++ /dev/null @@ -1,84 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.domain.Media; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -public class MarkSourceDirProcessorTest implements FileBasedUnitTest { - - private MarkSourceDirProcessor subject; - private Path inputDir; - private Path markDir; - - @Mock - private CleanupConfig cleanupConfig; - @Spy - private CleanupContext context; - @Spy - private Media media; - @Spy - private TranscodeFinishedEvent transcodeFinishedEvent; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - - inputDir = getPath("input"); - markDir = getPath("mark"); - context.setTranscodeFinishedEvent(transcodeFinishedEvent); - transcodeFinishedEvent.setMedia(media); - transcodeFinishedEvent.setSuccessful(true); - when(cleanupConfig.skip_extension()).thenReturn(".done"); - when(cleanupConfig.base_input_dir()).thenReturn(inputDir); - when(cleanupConfig.mark_source_dir()).thenReturn(markDir); - subject = new MarkSourceDirProcessor(cleanupConfig); - } - - @Test - public void processStep_ShouldRecreateDirectoryStructure() throws Exception { - Path source = createFile(getPath("0", "video.ext")); - Path expected = markDir.resolve("0").resolve("video.ext.done"); - - createFile(inputDir.resolve(source)); - media.setSourcePath(source); - subject.processStep(context); - - assertThat(expected).exists(); - } - - @Test - public void processStep_ShouldRecreateDirectoryStructure_WithSubdirectories() throws Exception { - Path source = createFile(getPath("0","movies", "video.ext")); - Path expected = markDir.resolve("0").resolve("movies").resolve("video.ext.done"); - - createFile(inputDir.resolve(source)); - media.setSourcePath(source); - subject.processStep(context); - - assertThat(expected).exists(); - } - - @Test - public void processStep_ShouldNotCreateDirectoryStructure_IfSourceDoesNotExist() throws Exception { - Path source = getPath("0","movies", "video.ext"); - Path expected = markDir.resolve("0").resolve("movies").resolve("video.ext.done"); - - media.setSourcePath(source); - subject.processStep(context); - - assertThat(expected).doesNotExist(); - } -} diff --git a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceProcessorTest.java b/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceProcessorTest.java deleted file mode 100644 index 0653edea..00000000 --- a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/MarkSourceProcessorTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.domain.Media; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -public class MarkSourceProcessorTest implements FileBasedUnitTest { - - private MarkSourceProcessor subject; - private Path inputDir; - - @Mock - private CleanupConfig cleanupConfig; - @Spy - private TranscodeFinishedEvent transcodeFinishedEvent; - @Spy - private CleanupContext context; - @Spy - private Media media; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - - inputDir = getPath("input"); - context.setTranscodeFinishedEvent(transcodeFinishedEvent); - transcodeFinishedEvent.setMedia(media); - transcodeFinishedEvent.setSuccessful(true); - when(cleanupConfig.skip_extension()).thenReturn(".done"); - when(cleanupConfig.base_input_dir()).thenReturn(inputDir); - - subject = new MarkSourceProcessor(cleanupConfig); - } - - @Test - public void processStep_ShouldCreateFile_IfSourceDoesExist() throws Exception { - Path source = createFile(getPath("0", "video.ext")); - Path expected = inputDir.resolve("0").resolve("video.ext.done"); - - createFile(inputDir.resolve(source)); - media.setSourcePath(source); - subject.processStep(context); - - assertThat(expected).exists(); - } - - @Test - public void processStep_ShouldNotCreateFile_IfSourceDoesNotExist() throws Exception { - Path source = getPath("0", "video.ext"); - Path expected = getPath("0", "video.ext.done"); - - media.setSourcePath(source); - subject.processStep(context); - - assertThat(expected).doesNotExist(); - } - -} diff --git a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessorTest.java b/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessorTest.java deleted file mode 100644 index 38ab40b4..00000000 --- a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/StructuredOutputDirectoryProcessorTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.domain.Media; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.test.util.ClockBasedUnitTest; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -public class StructuredOutputDirectoryProcessorTest - implements FileBasedUnitTest, ClockBasedUnitTest { - - private StructuredOutputDirectoryProcessor subject; - - @Mock - private CleanupConfig settings; - @Spy - private CleanupContext context; - @Spy - private TranscodeFinishedEvent transcodeFinishedEvent; - @Spy - private Media media; - - private Path outputDir; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - context.setTranscodeFinishedEvent(transcodeFinishedEvent); - transcodeFinishedEvent.setMedia(media); - - outputDir = getPath("output"); - when(settings.base_output_dir()).thenReturn(outputDir); - when(transcodeFinishedEvent.isSuccessful()).thenReturn(true); - - subject = new StructuredOutputDirectoryProcessor(settings, getFixedClock(8, 20)); - } - - @Test - public void processStep_ShouldMoveFileToNewDestination() throws Exception { - - Path source = createFile(getPath("0", "subdir", "file.ext")); - Path temp = createFile(getPath("tmp", "file.ext")); - - transcodeFinishedEvent.setTemporaryPath(temp); - media.setSourcePath(source); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "subdir", "file.ext"); - assertThat(result.getOutputPath()).isEqualTo(expected); - assertThat(expected).exists(); - } - - @Test - public void processStep_ShouldMoveFileToNewDestination_WithOtherFileExtension() throws Exception { - - Path source = createFile(getPath("0", "subdir", "file.mp4")); - Path temp = createFile(getPath("tmp", "file.mkv")); - - transcodeFinishedEvent.setTemporaryPath(temp); - media.setSourcePath(source); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "subdir", "file.mkv"); - assertThat(result.getOutputPath()).isEqualTo(expected); - assertThat(expected).exists(); - } - - @Test - public void processStep_ShouldMoveFileWithTimestamp_IfFileExists() throws Exception { - - Path source = createFile(getPath("0", "subdir", "file.ext")); - createFile(getPath("output", "subdir", "file.ext")); - Path temp = createFile(getPath("tmp", "file.ext")); - - transcodeFinishedEvent.setTemporaryPath(temp); - media.setSourcePath(source); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "subdir", "file.2017-01-31.08-20-00.ext"); - assertThat(result.getOutputPath()).isEqualTo(expected); - assertThat(expected).exists(); - } - - @Test - public void createOutputDirectoryTree_ShouldRecreateDirectoryTree_WithSubdirectories() throws Exception { - Path source = getPath("0", "subdir1", "subdir2", "file.ext"); - Path expected = outputDir.resolve("subdir1").resolve("subdir2").resolve("file.ext"); - - Path result = subject.createOutputDirectoryTree(source); - - assertThat(result) - .isEqualTo(expected) - .hasParentRaw(expected.getParent()); - } - - @Test - public void createOutputDirectoryTree_ShouldRecreateDirectoryTree_WithoutSubdirs() throws Exception { - Path source = getPath("0", "file.ext"); - Path expected = outputDir.resolve("file.ext"); - - Path result = subject.createOutputDirectoryTree(source); - - assertThat(result).isEqualTo(expected); - } - -} diff --git a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessorTest.java b/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessorTest.java deleted file mode 100644 index b4e315cf..00000000 --- a/clustercode.impl.cleanup/src/test/java/clustercode/impl/cleanup/processor/UnifiedOutputDirectoryProcessorTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package clustercode.impl.cleanup.processor; - -import clustercode.api.cleanup.CleanupContext; -import clustercode.api.domain.Media; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.test.util.ClockBasedUnitTest; -import clustercode.test.util.FileBasedUnitTest; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; - -import static org.mockito.Mockito.when; - -public class UnifiedOutputDirectoryProcessorTest - implements FileBasedUnitTest, ClockBasedUnitTest { - - private UnifiedOutputDirectoryProcessor subject; - - @Mock - private CleanupConfig cleanupConfig; - @Spy - private CleanupContext context; - @Spy - private TranscodeFinishedEvent transcodeFinishedEvent; - @Spy - private Media media; - - private Path outputDir; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - outputDir = getPath("output"); - context.setTranscodeFinishedEvent(transcodeFinishedEvent); - transcodeFinishedEvent.setMedia(media); - when(cleanupConfig.base_output_dir()).thenReturn(outputDir); - when(transcodeFinishedEvent.isSuccessful()).thenReturn(true); - - subject = new UnifiedOutputDirectoryProcessor(cleanupConfig, getFixedClock(8, 20)); - } - - @Test - public void processStep_ShouldMoveRootFileToOutputDir() throws Exception { - - Path temp = createFile(getPath("0", "video.ext")); - - transcodeFinishedEvent.setTemporaryPath(temp); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "video.ext"); - - Assertions.assertThat(result.getOutputPath()) - .isEqualTo(expected) - .exists(); - } - - @Test - public void processStep_ShouldMoveSubdirFileToOutputDir() throws Exception { - - Path temp = createFile(getPath("0", "subdir", "video.ext")); - - transcodeFinishedEvent.setTemporaryPath(temp); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "video.ext"); - - Assertions.assertThat(result.getOutputPath()) - .isEqualTo(expected) - .exists(); - } - - @Test - public void processStep_ShouldMoveSubdirFileToOutputDir_AndAddTimestampToFile_IfFileExists() throws Exception { - Path temp = createFile(getPath("0", "subdir", "video.ext")); - createFile(outputDir.resolve("video.ext")); - - transcodeFinishedEvent.setTemporaryPath(temp); - - CleanupContext result = subject.processStep(context); - - Path expected = getPath("output", "video.2017-01-31.08-20-00.ext"); - - Assertions.assertThat(result.getOutputPath()) - .isEqualTo(expected) - .exists(); - } - -} diff --git a/clustercode.impl.cluster.jgroups/build.gradle b/clustercode.impl.cluster.jgroups/build.gradle deleted file mode 100644 index 8ab24cee..00000000 --- a/clustercode.impl.cluster.jgroups/build.gradle +++ /dev/null @@ -1,11 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_cluster}") - compile project(":${proj_api_event}") - compile project(":${proj_api_transcode}") - compile project(":${proj_api_domain}") - compile "${dep_rxjava}" - compile "${dep_inject}" - testCompile project(":${proj_test_util}").sourceSets.test.output -} diff --git a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JGroupsClusterFacade.java b/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JGroupsClusterFacade.java deleted file mode 100644 index 4b2abcbb..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JGroupsClusterFacade.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.impl.cluster.jgroups; - -import clustercode.api.cluster.ClusterService; -import clustercode.api.domain.Media; -import lombok.Synchronized; - -import javax.inject.Inject; -import java.util.Optional; - -public class JGroupsClusterFacade implements ClusterService { - - private final JgroupsClusterImpl jgroupsCluster; - private ClusterService current; - - @Inject - JGroupsClusterFacade( - JgroupsClusterImpl jgroupsCluster - ) { - this.jgroupsCluster = jgroupsCluster; - } - - @Synchronized - @Override - public void joinCluster() { - if (current == null) { - jgroupsCluster.joinCluster(); - current = jgroupsCluster; - } - } - - @Override - public void removeTask() { - current.removeTask(); - } - - @Override - public void setTask(Media candidate) { - current.setTask(candidate); - } - - @Override - public boolean isQueuedInCluster(Media candidate) { - return current.isQueuedInCluster(candidate); - } - - @Override - public Optional getName() { - return current.getName(); - } - -} diff --git a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterActivator.java b/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterActivator.java deleted file mode 100644 index 4a8915a7..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterActivator.java +++ /dev/null @@ -1,103 +0,0 @@ -package clustercode.impl.cluster.jgroups; - -import clustercode.api.cluster.ClusterService; -import clustercode.api.cluster.messages.CancelTaskApiRequest; -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; -import clustercode.api.domain.TranscodeTask; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.*; -import io.reactivex.disposables.Disposable; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.CompletableFuture; - -@XSlf4j -public class JgroupsClusterActivator implements Activator { - - private final ClusterService clusterService; - private final RxEventBus eventBus; - private final List handlers = new LinkedList<>(); - - @Inject - JgroupsClusterActivator( - ClusterService jgroupsService, - RxEventBus eventBus - ) { - this.clusterService = jgroupsService; - this.eventBus = eventBus; - } - - @Override - public void preActivate(ActivatorContext context) { - handlers.add(eventBus - .listenFor(CancelTaskApiRequest.class) - .filter(this::isLocalHost) - .doOnNext(log::entry) - .subscribe(r -> r.setCancelled(cancelTaskLocally()))); - - handlers.add(eventBus - .listenFor(CancelTaskApiRequest.class) - .filter(this::isNotLocalHost) - .doOnNext(log::entry) - .subscribe(r -> r.setCancelled(true))); - - handlers.add(eventBus - .listenFor(TranscodeBeginEvent.class) - .map(TranscodeBeginEvent::getTask) - .map(TranscodeTask::getMedia) - .subscribe(clusterService::setTask)); - - handlers.add(eventBus - .listenFor(TranscodeFinishedEvent.class) - .subscribe(e -> clusterService.removeTask())); - - handlers.add(eventBus - .listenFor(MediaInClusterMessage.class) - .subscribe(this::onMediaInClusterQuery)); - - } - - @Override - public void activate(ActivatorContext context) { - log.debug("Activating JGroups cluster."); - CompletableFuture.supplyAsync(() -> { - clusterService.joinCluster(); - return 1; - }).thenAccept(memberCount -> { - eventBus.emit( - ClusterConnectMessage.builder() - .hostname(clusterService.getName().orElse("localhost")) - .clusterSize(memberCount) - .build()); - }); - } - - private void onMediaInClusterQuery(MediaInClusterMessage mediaInClusterMessage) { - mediaInClusterMessage.setInCluster(clusterService.isQueuedInCluster(mediaInClusterMessage.getMedia())); - } - - @Override - public void deactivate(ActivatorContext context) { - log.debug("Deactivating JGroups cluster."); - handlers.forEach(Disposable::dispose); - handlers.clear(); - } - - private boolean isNotLocalHost(CancelTaskApiRequest cancelTaskApiRequest) { - return !isLocalHost(cancelTaskApiRequest); - } - - private boolean isLocalHost(CancelTaskApiRequest cancelTaskApiRequest) { - String localName = clusterService.getName().orElse(""); - return localName.equals(cancelTaskApiRequest.getHostname()); - } - - private boolean cancelTaskLocally() { - return eventBus.emit(new CancelTranscodeMessage()).isCancelled(); - } - -} diff --git a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterImpl.java b/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterImpl.java deleted file mode 100644 index c833ad4a..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/JgroupsClusterImpl.java +++ /dev/null @@ -1,44 +0,0 @@ -package clustercode.impl.cluster.jgroups; - -import clustercode.api.cluster.ClusterService; -import clustercode.api.domain.Media; -import lombok.Synchronized; -import lombok.extern.slf4j.XSlf4j; - -import java.util.Optional; - -@XSlf4j -public class JgroupsClusterImpl - implements ClusterService { - - private Media current; - - @Synchronized - @Override - public void joinCluster() { - log.warn("This is not a jgroups cluster anymore, doing nothing"); - } - - @Synchronized - @Override - public void removeTask() { - current = null; - } - - @Override - public void setTask(Media candidate) { - // TODO: add to messaging - current = candidate; - } - - @Override - public boolean isQueuedInCluster(Media candidate) { - return candidate.equals(current); - } - - @Override - public Optional getName() { - return Optional.empty(); - } - -} diff --git a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/SingleNodeClusterImpl.java b/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/SingleNodeClusterImpl.java deleted file mode 100644 index 48bb01b6..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/java/clustercode/impl/cluster/jgroups/SingleNodeClusterImpl.java +++ /dev/null @@ -1,40 +0,0 @@ -package clustercode.impl.cluster.jgroups; - -import clustercode.api.cluster.ClusterService; -import clustercode.api.domain.Media; -import lombok.extern.slf4j.Slf4j; - -import java.util.Optional; - -@Slf4j -public class SingleNodeClusterImpl implements ClusterService { - - private Media task; - - @Override - public void joinCluster() { - log.info("Will work as a single-node cluster."); - } - - @Override - public void removeTask() { - this.task = null; - } - - @Override - public void setTask(Media candidate) { - this.task = candidate; - } - - @Override - public boolean isQueuedInCluster(Media candidate) { - if (candidate == null) return false; - return candidate.equals(task); - } - - @Override - public Optional getName() { - return Optional.of("localhost"); - } - -} diff --git a/clustercode.impl.cluster.jgroups/src/main/resources/fork.xml b/clustercode.impl.cluster.jgroups/src/main/resources/fork.xml deleted file mode 100644 index 7100b989..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/resources/fork.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - diff --git a/clustercode.impl.cluster.jgroups/src/main/resources/tcp.xml b/clustercode.impl.cluster.jgroups/src/main/resources/tcp.xml deleted file mode 100644 index 30b70cb7..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/resources/tcp.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/clustercode.impl.cluster.jgroups/src/main/resources/udp.xml b/clustercode.impl.cluster.jgroups/src/main/resources/udp.xml deleted file mode 100644 index fd79149e..00000000 --- a/clustercode.impl.cluster.jgroups/src/main/resources/udp.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/clustercode.impl.constraint/build.gradle b/clustercode.impl.constraint/build.gradle deleted file mode 100644 index 39f750c9..00000000 --- a/clustercode.impl.constraint/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_domain}") - compile project(":${proj_api_config}") - compile project(":${proj_impl_util}") - compile project(":${proj_api_event}") - compile "${dep_owner}" - compile "${dep_inject}" - - testCompile project(":${proj_test_util}").sourceSets.test.output -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/AbstractConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/AbstractConstraint.java deleted file mode 100644 index 00b236b9..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/AbstractConstraint.java +++ /dev/null @@ -1,43 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Constraint; -import clustercode.api.domain.Media; -import org.slf4j.ext.XLogger; -import org.slf4j.ext.XLoggerFactory; - -/** - * Provides a template class for constraints. A {@link XLogger} is being configured, accessible with {@code log} - * field. - */ -public abstract class AbstractConstraint implements Constraint { - - protected final XLogger log = XLoggerFactory.getXLogger(getClass()); - - protected AbstractConstraint() { - log.info("Enabled {}.", getClass().getSimpleName()); - } - - /** - * Logs a debug message and returns the result unmodified. This method can be used before returning from - * {@link #accept(Media)}. "Accepted: " or "Declined: " will be prepended before the {@code formatString}, based on - * {@code accepted}. - * - * @param accepted the result of {@link #accept(Media)}. - * @param formatString The format string to log. Use {} as placeholder for variables (SLF4J syntax). - * @param arguments the arguments for {@code formatString} - * @return {@code accepted} - */ - protected final boolean logAndReturnResult(boolean accepted, String formatString, Object... arguments) { - if (log.isDebugEnabled()) { - String decision; - if (accepted) { - decision = "Accepted: "; - } else { - decision = "Declined: "; - } - log.debug(decision.concat(formatString), arguments); - } - return accepted; - } - -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ClusterConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ClusterConstraint.java deleted file mode 100644 index 8bf68c0e..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ClusterConstraint.java +++ /dev/null @@ -1,27 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.MediaInClusterMessage; - -import javax.inject.Inject; - -public class ClusterConstraint extends AbstractConstraint { - - private final RxEventBus eventBus; - - @Inject - ClusterConstraint(RxEventBus eventBus) { - this.eventBus = eventBus; - } - - @Override - public boolean accept(Media candidate) { - boolean isInCluster = eventBus.emit( - MediaInClusterMessage.builder() - .media(candidate) - .build() - ).isInCluster(); - return logAndReturnResult(!isInCluster, "{} is in cluster: {}", candidate.getSourcePath(), isInCluster); - } -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ConstraintConfig.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ConstraintConfig.java deleted file mode 100644 index 253d952f..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/ConstraintConfig.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.config.converter.PathConverter; -import org.aeonbits.owner.Config; - -import java.nio.file.Path; -import java.util.List; - -public interface ConstraintConfig extends Config { - - @Key("CC_CONSTRAINT_FILE_REGEX") - @DefaultValue("") - String filename_regex(); - - /** - * @return size in MB. x >= 0 - */ - @Key("CC_CONSTRAINT_FILE_MIN_SIZE") - @DefaultValue("150") - long min_file_size(); - - /** - * @return size in MB. x >= 0 - */ - @Key("CC_CONSTRAINT_FILE_MAX_SIZE") - @DefaultValue("0") - long max_file_size(); - - /** - * Unordered List of constraints. - * @return one of: ALL, FILE_SIZE, TIME, FILE_NAME, NONE - */ - @Separator(" ") - @Key("CC_CONSTRAINTS_ACTIVE") - @DefaultValue("FILE_SIZE CLUSTER") - List active_constraints(); - - @Key("CC_CONSTRAINT_TIME_BEGIN") - @DefaultValue("08:00") - String time_begin(); - - @Key("CC_CONSTRAINT_TIME_STOP") - @DefaultValue("16:00") - String time_stop(); - - @Key("CC_MEDIA_INPUT_DIR") - @DefaultValue("/input") - @ConverterClass(PathConverter.class) - Path base_input_dir(); - -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/Constraints.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/Constraints.java deleted file mode 100644 index ecfb3478..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/Constraints.java +++ /dev/null @@ -1,12 +0,0 @@ -package clustercode.impl.constraint; - -public enum Constraints { - - ALL, - NONE, - FILE_NAME, - TIME, - FILE_SIZE, - CLUSTER - -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileNameConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileNameConstraint.java deleted file mode 100644 index 218fada5..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileNameConstraint.java +++ /dev/null @@ -1,30 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; - -import javax.inject.Inject; -import java.util.regex.Pattern; - -/** - * Provides a constraint which enables file name checking by regex. The input path of the candidate is being - * checked (relative, without base input directory). Specify a Java-valid regex pattern, otherwise a runtime - * exception is being thrown. - */ -public class FileNameConstraint - extends AbstractConstraint { - - private final Pattern pattern; - - @Inject - FileNameConstraint(ConstraintConfig config) { - this.pattern = Pattern.compile(config.filename_regex()); - } - - @Override - public boolean accept(Media candidate) { - String toTest = candidate.getSourcePath().toString(); - return logAndReturnResult(pattern.matcher(toTest).matches(), "file name of {} with regex {}", - candidate.getSourcePath(), pattern.pattern()); - } - -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileSizeConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileSizeConstraint.java deleted file mode 100644 index 774a1f9d..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/FileSizeConstraint.java +++ /dev/null @@ -1,91 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.impl.util.InvalidConfigurationException; - -import javax.inject.Inject; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.text.DecimalFormat; - -/** - * This constraint checks the file size of the given argument. If the file is too big or too small it will be - * rejected. The limits are configurable. If the minimum or maximum size are 0 (zero), the check is disabled - * (for its respective limit). - */ -public class FileSizeConstraint - extends AbstractConstraint { - - public static long BYTES = 1; - public static long KIBI_BYTES = BYTES * 1024; - public static long MEBI_BYTES = KIBI_BYTES * 1024; - private final double minSize; - private final double maxSize; - private final ConstraintConfig config; - private final DecimalFormat formatter = new DecimalFormat("#.####"); - - @Inject - FileSizeConstraint(ConstraintConfig config) { - this(config, MEBI_BYTES); - } - - protected FileSizeConstraint(ConstraintConfig config, - long factor) { - checkConfiguration(config.min_file_size(), config.max_file_size()); - this.config = config; - this.minSize = config.min_file_size() * factor; - this.maxSize = config.max_file_size() * factor; - } - - private void checkConfiguration(double minSize, double maxSize) { - if (Math.min(minSize, maxSize) > 0 && minSize > maxSize) { - throw new InvalidConfigurationException("minSize cannot be greater than maxSize. Min: {}, Max: {}", - minSize, maxSize); - } - if (Math.min(minSize, maxSize) < 0) { - throw new InvalidConfigurationException("File sizes cannot contain negative values. Min: {}, Max: {}", - minSize, maxSize); - } - if (minSize == maxSize) { - throw new InvalidConfigurationException("File sizes cannot be equal. Min: {}, Max: {}", - minSize, maxSize); - } - } - - @Override - public boolean accept(Media candidate) { - Path file = config.base_input_dir().resolve(candidate.getSourcePath()); - try { - long size = Files.size(file); - if (minSize > 0 && maxSize > 0) { - // file between max and min - return logAndReturn(size >= minSize && size <= maxSize, file, size); - } else if (minSize <= 0) { - // size smaller than max, min disabled - return logAndReturn(size <= maxSize, file, size); - } else { - // size greater than min, max disabled - return logAndReturn(size >= minSize, file, size); - } - } catch (IOException e) { - log.warn("Could not determine file size of {}: {}. Declined file.", file, e.toString()); - return false; - } - } - - protected String formatNumber(double number) { - return formatter.format(number); - } - - protected boolean logAndReturn(boolean result, Path file, long size) { - return logAndReturnResult( - result, - "file size of {} with {} MB (min: {}, max: {})", - file, - formatNumber(size / MEBI_BYTES), - formatNumber(minSize / MEBI_BYTES), - formatNumber(maxSize / MEBI_BYTES)); - } -} - diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/NoConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/NoConstraint.java deleted file mode 100644 index 6bc272c5..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/NoConstraint.java +++ /dev/null @@ -1,11 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; - -public class NoConstraint extends AbstractConstraint { - - @Override - public boolean accept(Media candidate) { - return logAndReturnResult(true, "{}", candidate); - } -} diff --git a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/TimeConstraint.java b/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/TimeConstraint.java deleted file mode 100644 index 623de110..00000000 --- a/clustercode.impl.constraint/src/main/java/clustercode/impl/constraint/TimeConstraint.java +++ /dev/null @@ -1,64 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.impl.util.InvalidConfigurationException; - -import javax.inject.Inject; -import java.time.Clock; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; - -/** - * Provides an implementation of a time constraint. The media will not be accepted for scheduling when the current - * time is outside of the configurable 24h time window. The 'begin' and 'stop' strings are expected in the "HH:mm" - * format (0-23). - */ -public class TimeConstraint - extends AbstractConstraint { - - private final Clock clock; - private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); - private LocalTime stop; - private LocalTime begin; - - @Inject - protected TimeConstraint(ConstraintConfig config, - Clock clock) { - this.clock = clock; - String begin = config.time_begin(); - String stop = config.time_stop(); - try { - this.begin = LocalTime.parse(begin, formatter); - this.stop = LocalTime.parse(stop, formatter); - } catch (DateTimeParseException ex) { - throw new InvalidConfigurationException("The time format is HH:mm. You specified: begin({}), stop({})", ex, - begin, stop); - } - checkConfiguration(); - } - - private void checkConfiguration() { - if (begin.compareTo(stop) == 0) { - throw new InvalidConfigurationException("Begin and stop time are identical (specify different times in " + - "HH:mm format)."); - } - } - - @Override - public boolean accept(Media candidate) { - LocalTime now = LocalTime.now(clock); - if (begin.isBefore(stop)) { - return logAndReturn(begin.isBefore(now) && now.isBefore(stop), now); - } else { - return logAndReturn(( - now.isAfter(begin) && now.isAfter(stop)) || (now.isBefore(stop) && now.isBefore(begin)), now); - } - } - - protected boolean logAndReturn(boolean result, LocalTime now) { - return logAndReturnResult(result, "Time window {} (begin: {}, stop {})", - formatter.format(now), formatter.format(begin), formatter.format(stop)); - } - -} diff --git a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileNameConstraintTest.java b/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileNameConstraintTest.java deleted file mode 100644 index 07252896..00000000 --- a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileNameConstraintTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -public class FileNameConstraintTest implements FileBasedUnitTest { - - private FileNameConstraint subject; - @Mock - private Media candidate; - @Mock - private ConstraintConfig config; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - } - - @Test - public void accept_ShouldReturnFalse_IfRegexOnlyMatchesPartially() throws Exception { - when(candidate.getSourcePath()).thenReturn(getPath("input", "movie.mp4")); - when(config.filename_regex()).thenReturn(".mp4"); - - subject = new FileNameConstraint(config); - assertThat(subject.accept(candidate)).isFalse(); - } - - @Test - public void accept_ShouldReturnTrue_IfRegexMatches() throws Exception { - when(candidate.getSourcePath()).thenReturn(getPath("input", "movie.mp4")); - when(config.filename_regex()).thenReturn("^.*\\.mp4"); - - subject = new FileNameConstraint(config); - assertThat(subject.accept(candidate)).isTrue(); - } - -} diff --git a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileSizeConstraintTest.java b/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileSizeConstraintTest.java deleted file mode 100644 index bf16bdd4..00000000 --- a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/FileSizeConstraintTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.Mockito.when; - -public class FileSizeConstraintTest implements FileBasedUnitTest { - - private FileSizeConstraint subject; - private Path inputDir; - - @Mock - private ConstraintConfig config; - - @Spy - private Media media; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - when(config.base_input_dir()).thenReturn(getPath("input")); - inputDir = config.base_input_dir(); - Files.createDirectory(inputDir); - } - - private void initSubject() { - subject = new FileSizeConstraint(config, FileSizeConstraint.BYTES); - } - - private void writeBytes(int count, Path path) throws IOException { - Files.write(path, new byte[count]); - } - - @Test - public void accept_ShouldReturnTrue_IfFileIsGreaterThanMinSize() throws Exception { - when(config.max_file_size()).thenReturn(1024L); - when(config.min_file_size()).thenReturn(10L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - writeBytes(12, file); - - media.setSourcePath(inputDir.relativize(file)); - - assertThat(subject.accept(media)).isTrue(); - } - - @Test - public void accept_ShouldReturnTrue_IfFileIsGreaterThanMinSize_AndMaxSizeDisabled() throws Exception { - when(config.max_file_size()).thenReturn(104L); - when(config.min_file_size()).thenReturn(0L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - writeBytes(12, file); - - media.setSourcePath(inputDir.relativize(file)); - - assertThat(subject.accept(media)).isTrue(); - } - - @Test - public void accept_ShouldReturnTrue_IfFileIsSmallerThanMinSize_AndMinSizeDisabled() throws Exception { - when(config.max_file_size()).thenReturn(16L); - when(config.min_file_size()).thenReturn(0L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - writeBytes(12, file); - - media.setSourcePath(inputDir.relativize(file)); - - assertThat(subject.accept(media)).isTrue(); - } - - @Test - public void accept_ShouldReturnFalse_IfFileIsSmallerThanMinSize() throws Exception { - when(config.max_file_size()).thenReturn(1024L); - when(config.min_file_size()).thenReturn(10L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - writeBytes(8, file); - - media.setSourcePath(inputDir.relativize(file)); - - assertThat(subject.accept(media)).isFalse(); - } - - @Test - public void accept_ShouldReturnFalse_IfFileIsGreaterThanMaxSize() throws Exception { - when(config.max_file_size()).thenReturn(10000000L); - when(config.min_file_size()).thenReturn(1000000L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - writeBytes(101, file); - - media.setSourcePath(inputDir.relativize(file)); - assertThat(subject.accept(media)).isFalse(); - } - - @Test - public void accept_ShouldReturnFalse_IfFileSizeCannotBeDetermined() throws Exception { - when(config.max_file_size()).thenReturn(100L); - when(config.min_file_size()).thenReturn(10L); - initSubject(); - - Path file = inputDir.resolve("movie.mp4"); - - media.setSourcePath(inputDir.relativize(file)); - assertThat(subject.accept(media)).isFalse(); - } - - @Test - public void ctor_ShouldThrowException_IfFileSizesEqual() throws Exception { - when(config.max_file_size()).thenReturn(0L); - when(config.min_file_size()).thenReturn(0L); - assertThatExceptionOfType(InvalidConfigurationException.class).isThrownBy(this::initSubject); - } - - @Test - public void ctor_ShouldThrowException_IfConfiguredIncorrectly_WhenSizesSwapped() throws Exception { - when(config.max_file_size()).thenReturn(1L); - when(config.min_file_size()).thenReturn(12L); - assertThatExceptionOfType(InvalidConfigurationException.class).isThrownBy(this::initSubject); - } - - @Test - public void ctor_ShouldThrowException_IfConfiguredIncorrectly_WhenSizesNegative() throws Exception { - when(config.max_file_size()).thenReturn(-1L); - when(config.min_file_size()).thenReturn(-1L); - assertThatExceptionOfType(InvalidConfigurationException.class).isThrownBy(this::initSubject); - } - -} diff --git a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/TimeConstraintTest.java b/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/TimeConstraintTest.java deleted file mode 100644 index 3929ba67..00000000 --- a/clustercode.impl.constraint/src/test/java/clustercode/impl/constraint/TimeConstraintTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package clustercode.impl.constraint; - -import clustercode.api.domain.Media; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.test.util.ClockBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import java.time.Clock; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.Mockito.when; - -public class TimeConstraintTest implements ClockBasedUnitTest { - - private TimeConstraint subject; - - @Mock - private Media candidate; - @Mock - private ConstraintConfig config; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - } - - @Test - public void accept_ShouldReturnTrue_IfCurrentTimeIsBetweenBeginAndStop() throws Exception { - when(config.time_begin()).thenReturn("13:00"); - when(config.time_stop()).thenReturn("14:00"); - subject = new TimeConstraint(config, getFixedClock(13, 30)); - - assertThat(subject.accept(candidate)).isTrue(); - } - - @Test - public void accept_ShouldReturnFalse_IfCurrentTimeIsBeforeBegin() throws Exception { - when(config.time_begin()).thenReturn("13:00"); - when(config.time_stop()).thenReturn("14:00"); - subject = new TimeConstraint(config, getFixedClock(12, 30)); - - assertThat(subject.accept(candidate)).isFalse(); - } - - @Test - public void accept_ShouldReturnFalse_IfCurrentTimeIsAfterStop() throws Exception { - when(config.time_begin()).thenReturn("13:00"); - when(config.time_stop()).thenReturn("14:00"); - subject = new TimeConstraint(config, getFixedClock(14, 30)); - - assertThat(subject.accept(candidate)).isFalse(); - } - - @Test - public void accept_ShouldReturnTrue_IfCurrentTimeIsAfterBegin_AndStopIsBeforeBegin() throws Exception { - when(config.time_begin()).thenReturn("13:00"); - when(config.time_stop()).thenReturn("12:00"); - subject = new TimeConstraint(config, getFixedClock(14, 30)); - - assertThat(subject.accept(candidate)).isTrue(); - } - - @Test - public void accept_ShouldReturnFalse_IfCurrentTimeIsBeforeBegin_AndStopIsBeforeBegin() throws Exception { - when(config.time_begin()).thenReturn("13:00"); - when(config.time_stop()).thenReturn("11:00"); - subject = new TimeConstraint(config, getFixedClock(12, 30)); - - assertThat(subject.accept(candidate)).isFalse(); - } - - @Test - public void ctor_ShouldThrowException_IfBeginAndStopAreSame() throws Exception { - when(config.time_begin()).thenReturn("12:00"); - when(config.time_stop()).thenReturn("12:00"); - assertThatExceptionOfType(InvalidConfigurationException.class).isThrownBy(() -> - subject = new TimeConstraint(config, getFixedClock(12, 30))); - } - - @Test - public void ctor_ShouldThrowException_IfConstraintConfiguredIncorrectly() throws Exception { - when(config.time_begin()).thenReturn("-1"); - when(config.time_stop()).thenReturn("-1"); - assertThatExceptionOfType(InvalidConfigurationException.class).isThrownBy(() -> - subject = new TimeConstraint(config, Clock.systemDefaultZone())); - } - -} diff --git a/clustercode.impl.process/build.gradle b/clustercode.impl.process/build.gradle deleted file mode 100644 index eece8131..00000000 --- a/clustercode.impl.process/build.gradle +++ /dev/null @@ -1,6 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_process}") - compile project(":${proj_impl_util}") -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/AutoResolvableInterpreter.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/AutoResolvableInterpreter.java deleted file mode 100644 index 58b405f9..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/AutoResolvableInterpreter.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.impl.process; - -import clustercode.api.process.ScriptInterpreter; -import clustercode.impl.util.FilesystemProvider; - -import java.nio.file.Path; - -public class AutoResolvableInterpreter implements ScriptInterpreter { - - @Override - public Path getPath() { - return FilesystemProvider.getInstance().getPath(""); - } -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/BourneAgainShell.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/BourneAgainShell.java deleted file mode 100644 index fcc1c84f..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/BourneAgainShell.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.impl.process; - -import clustercode.api.process.ScriptInterpreter; -import clustercode.impl.util.FilesystemProvider; - -import java.nio.file.Path; - -public class BourneAgainShell implements ScriptInterpreter { - - @Override - public Path getPath() { - return FilesystemProvider.getInstance().getPath("/bin", "bash"); - } -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcess.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcess.java deleted file mode 100644 index 9e88aa21..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcess.java +++ /dev/null @@ -1,95 +0,0 @@ -package clustercode.impl.process; - -import clustercode.api.process.ProcessConfiguration; -import clustercode.api.process.RunningExternalProcess; -import clustercode.impl.util.Platform; -import io.reactivex.Single; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; -import io.reactivex.subjects.Subject; -import lombok.extern.slf4j.Slf4j; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.function.Consumer; - -@Slf4j -class ExternalProcess { - - private final ProcessConfiguration c; - private final Consumer processHandler; - - private final Subject stdErrReplaySubject = PublishSubject.create().toSerialized(); - private final Subject stdOutReplaySubject = PublishSubject.create().toSerialized(); - - private Process process; - - ExternalProcess(ProcessConfiguration c, Consumer processHandler) { - this.c = c; - this.processHandler = processHandler; - } - - public int start() throws InterruptedException, IOException { - ProcessBuilder builder = new ProcessBuilder(buildArguments()); - if (Platform.currentPlatform() == Platform.WINDOWS) { - // This is necessary. Otherwise waitFor() will be deadlocked even if the process finished hours ago. - builder.redirectErrorStream(true); - } - if (c.getWorkingDir() != null) builder.directory(c.getWorkingDir().toFile()); - - log.info("Invoking: {}", builder.command()); - this.process = builder.start(); - - if (Platform.currentPlatform() != Platform.WINDOWS) { - c.getErrorObservers().forEach(consumer -> - stdErrReplaySubject.ofType(String.class) - .observeOn(Schedulers.computation()) - .subscribe(consumer::accept)); - } - c.getStdoutObservers().forEach(consumer -> - stdOutReplaySubject.ofType(String.class) - .observeOn(Schedulers.computation()) - .subscribe(consumer::accept)); - - readStreamAsync(process.getInputStream(), stdOutReplaySubject); - readStreamAsync(process.getErrorStream(), stdErrReplaySubject); - - createHandle(); - - return process.waitFor(); - } - - private List buildArguments() { - List args = new LinkedList<>(); - args.add(c.getExecutable().toString()); - args.addAll(c.getArguments()); - return args; - } - - private void readStreamAsync(InputStream stream, Subject subject) { - CompletableFuture.runAsync(() -> { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream))) { - String line; - while ((line = reader.readLine()) != null) { - subject.onNext(line); - } - subject.onComplete(); - } catch (IOException ex) { - subject.onError(ex); - } - }); - } - - private void createHandle() { - if (processHandler == null) return; - Single.just(new RunningProcessImpl(process)) - .observeOn(Schedulers.io()) - .subscribe(processHandler::accept); - } - -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcessServiceImpl.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcessServiceImpl.java deleted file mode 100644 index ca78c113..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/ExternalProcessServiceImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package clustercode.impl.process; - -import clustercode.api.process.ExternalProcessService; -import clustercode.api.process.ProcessConfiguration; -import clustercode.api.process.RunningExternalProcess; -import io.reactivex.Single; -import io.reactivex.schedulers.Schedulers; -import lombok.extern.slf4j.XSlf4j; - -import java.util.function.Consumer; - -@XSlf4j -public class ExternalProcessServiceImpl implements ExternalProcessService { - - @Override - public Single start(ProcessConfiguration configuration) { - return start(configuration, null); - } - - @Override - public Single start(ProcessConfiguration c, - Consumer processHandler) { - return Single - .fromCallable(() -> new ExternalProcess(c, processHandler).start()) - .observeOn(Schedulers.io()) - .subscribeOn(Schedulers.io()) - .doOnError(ex -> log.warn(ex.toString())) - .doAfterSuccess(exitCode -> - log.info("Process finished with exit code {}: {}", - exitCode, - c.getExecutable())); - } - -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/RunningProcessImpl.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/RunningProcessImpl.java deleted file mode 100644 index 116d08c2..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/RunningProcessImpl.java +++ /dev/null @@ -1,55 +0,0 @@ -package clustercode.impl.process; - -import lombok.extern.slf4j.XSlf4j; -import clustercode.api.process.RunningExternalProcess; -import org.slf4j.ext.XLogger; - -import java.util.concurrent.TimeUnit; - -@XSlf4j -class RunningProcessImpl implements RunningExternalProcess { - - private Process process; - - RunningProcessImpl(Process process) { - this.process = process; - } - - @Override - public RunningExternalProcess sleep(long millis) { - return sleep(millis, TimeUnit.MILLISECONDS); - } - - @Override - public RunningExternalProcess sleep(long timeout, TimeUnit unit) { - try { - Thread.sleep(unit.toMillis(timeout)); - } catch (InterruptedException e) { - log.catching(XLogger.Level.WARN, e); - } - return this; - } - - @Override - public void awaitDestruction() { - try { - log.debug("Waiting for process to destroy..."); - process.destroyForcibly().waitFor(); - } catch (InterruptedException e) { - log.throwing(e); - throw new RuntimeException(e); - } - } - - @Override - public boolean destroyNowWithTimeout(long timeout, TimeUnit unit) { - try { - log.warn("Waiting for process to destroy within {} {}...", timeout, unit.toString().toLowerCase()); - return process.destroyForcibly().waitFor(timeout, unit); - } catch (InterruptedException e) { - log.throwing(e); - throw new RuntimeException(e); - } - } - -} diff --git a/clustercode.impl.process/src/main/java/clustercode/impl/process/Shell.java b/clustercode.impl.process/src/main/java/clustercode/impl/process/Shell.java deleted file mode 100644 index 6dddc62e..00000000 --- a/clustercode.impl.process/src/main/java/clustercode/impl/process/Shell.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.impl.process; - -import clustercode.api.process.ScriptInterpreter; -import clustercode.impl.util.FilesystemProvider; - -import java.nio.file.Path; - -public class Shell implements ScriptInterpreter { - - @Override - public Path getPath() { - return FilesystemProvider.getInstance().getPath("/bin", "sh"); - } -} diff --git a/clustercode.impl.scan/build.gradle b/clustercode.impl.scan/build.gradle deleted file mode 100644 index 691e555d..00000000 --- a/clustercode.impl.scan/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_api_scan}") - compile project(":${proj_api_cluster}") - compile project(":${proj_api_config}") - compile project(":${proj_api_event}") - compile "${dep_inject}" - compile "${dep_owner}" - - testCompile project(":${proj_test_util}").sourceSets.test.output -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/FileScannerImpl.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/FileScannerImpl.java deleted file mode 100644 index 48be084e..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/FileScannerImpl.java +++ /dev/null @@ -1,167 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.scan.FileScanner; -import lombok.extern.slf4j.XSlf4j; -import org.slf4j.ext.XLogger; - -import java.io.IOException; -import java.nio.file.FileVisitOption; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -@XSlf4j -public class FileScannerImpl - implements FileScanner { - - private boolean isDirEnabled; - private Optional searchDir = Optional.empty(); - private Optional> allowedExtensions = Optional.empty(); - private Optional skipExtension = Optional.empty(); - private int depth; - private Optional skipDirectory = Optional.empty(); - - - @Override - public FileScanner searchIn(Path dir) { - this.searchDir = Optional.of(dir); - return this; - } - - @Override - public FileScanner withRecursion(boolean recursive) { - if (recursive) { - this.depth = Integer.MAX_VALUE; - } else { - this.depth = 1; - } - return this; - } - - @Override - public FileScanner withDepth(Integer value) { - this.depth = value; - return this; - } - - @Override - public FileScanner withDirectories(boolean dirs) { - this.isDirEnabled = dirs; - return this; - } - - @Override - public FileScanner withFileExtensions(List allowedExtensions) { - this.allowedExtensions = Optional.of(allowedExtensions); - return this; - } - - @Override - public FileScanner whileSkippingExtraFilesWith(String skipping) { - this.skipExtension = Optional.of(skipping); - return this; - } - - @Override - public FileScanner whileSkippingExtraFilesIn(Path dir) { - this.skipDirectory = Optional.ofNullable(dir); - return this; - } - - @Override - public Optional> scan() { - try { - return Optional.of(createStreamWithLogLevel(XLogger.Level.WARN).collect(Collectors.toList())); - } catch (RuntimeException ex) { - return Optional.empty(); - } - } - - @Override - public Stream stream() { - return createStreamWithLogLevel(XLogger.Level.ERROR); - } - - @Override - public Stream streamAndIgnoreErrors() { - try { - return createStreamWithLogLevel(XLogger.Level.WARN); - } catch (RuntimeException e) { - return Stream.empty(); - } - } - - private Stream createStreamWithLogLevel(XLogger.Level logLevel) { - try { - return Files - .walk(searchDir.get(), this.depth, FileVisitOption.FOLLOW_LINKS) - .filter(path -> !path.equals(searchDir.get())) - .filter(this::includeFileOrDirectory) - .filter(this::hasAllowedExtension) - .filter(this::hasNotCompanionFile); - } catch (IOException e) { - // log.catching(logLevel, e); - throw new RuntimeException(e); - } - } - - /** - * Tests whether the given sourcePath name ends with an extension specified with {@link #withFileExtensions(List)}. - * Returns true if no matcher is present. - * - * @param path - * @return true if no matcher present or at least one of the extensions is applicable, otherwise false. - */ - boolean hasAllowedExtension(Path path) { - return allowedExtensions.map(strings -> strings - .stream() - .anyMatch(extension -> path.getFileName().toString().endsWith(extension)) - ).orElse(true); - } - - /** - * Tests whether the given file has not another file named with the {@link #whileSkippingExtraFilesWith(String)} - * extension. E.g. if both files "foo/bar" and "foo/bar.skipping" exist, then this method returns false, - * otherwise true. - * - * @param path the base file name. - * @return false if there is a companion file, true if the companion file does not exist or the extension is not - * specified. - */ - boolean hasNotCompanionFile(Path path) { - if (skipExtension.isPresent()) { - Path sibling = path.resolveSibling(path.getFileName() + skipExtension.get()); - boolean companionFileExists = Files.exists(sibling); - boolean markDirFileExists = skipDirectory.map(dir -> { - Path siblingInDir = searchDir.get().getParent().relativize(sibling); - Path toChck = dir.resolve(siblingInDir); - return Files.exists(toChck); - }).orElse(false); - if (companionFileExists || markDirFileExists) log.debug("Ignoring: {}", path); - return !(companionFileExists || markDirFileExists); - } else { - return true; - } - } - - /** - * Tests whether the sourcePath is being included by determining {@link #withDirectories(boolean)}. If the - * directories flag is enabled, this method returns whether {@code sourcePath} is a directory, otherwise it tests if - * {@code sourcePath} is a regular file. - * - * @param path the sourcePath. - * @return true if the dir flag is enabled and sourcePath is a dir, true if dir flag is disabled and sourcePath - * is a file, false otherwise. - */ - boolean includeFileOrDirectory(Path path) { - if (isDirEnabled) { - return Files.isDirectory(path); - } else { - return Files.isRegularFile(path); - } - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanConfig.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanConfig.java deleted file mode 100644 index b136fc04..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanConfig.java +++ /dev/null @@ -1,58 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.config.converter.PathConverter; -import org.aeonbits.owner.Config; - -import java.nio.file.Path; -import java.util.List; - -public interface MediaScanConfig extends Config { - - /** - * Gets the root directory for scanning. - * - * @return the root dir, not null. - */ - @Key("CC_MEDIA_INPUT_DIR") - @ConverterClass(PathConverter.class) - @DefaultValue("/input") - Path base_input_dir(); - - /** - * Gets the list of file name extensions. An entry can be ".txt" or "txt". - * - * @return the list of included file extensions. May be empty, not null. - */ - @Key("CC_MEDIA_EXTENSIONS") - @DefaultValue("mkv,mp4,avi") - List allowed_extensions(); - - /** - * Gets the extension which cause a file to be skipped in the scan. If e.g. the string equals ".doe", then a - * file named "foo/bar" will be ignored if there is a file named "foo/bar.doe" in the same directory. - * - * @return the extension, not null. - */ - @Key("CC_MEDIA_SKIP_NAME") - @DefaultValue(".done") - String skip_extension_name(); - - /** - * Gets the interval after which the file system is rescanned when no media has been found. - * - * @return the interval in minutes, >= 1. - */ - @Key("CC_MEDIA_SCAN_INTERVAL") - @DefaultValue("30") - long media_scan_interval(); - - /** - * Gets the root path of the directory in which the sources should get marked as done. - * - * @return the path or empty. - */ - @Key("CC_CLEANUP_MARK_SOURCE_DIR") - @DefaultValue("/input/done") - @ConverterClass(PathConverter.class) - Path mark_source_dir(); -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanServiceImpl.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanServiceImpl.java deleted file mode 100644 index 1a53eca4..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/MediaScanServiceImpl.java +++ /dev/null @@ -1,111 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Media; -import clustercode.api.scan.FileScanner; -import clustercode.api.scan.MediaScanService; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import javax.inject.Provider; -import java.nio.file.Path; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; - -@XSlf4j -public class MediaScanServiceImpl implements MediaScanService { - - private final MediaScanConfig scanConfig; - private final Provider scannerProvider; - - @Inject - MediaScanServiceImpl(MediaScanConfig scanConfig, - Provider scannerProvider) { - this.scanConfig = scanConfig; - this.scannerProvider = scannerProvider; - } - - @Override - public Map> retrieveFiles() { - log.info("Scanning for directories in {}", scanConfig.base_input_dir()); - return scannerProvider.get() - .searchIn(scanConfig.base_input_dir()) - .withRecursion(false) - .withDirectories(true) - .stream() - .filter(this::isPriorityDirectory) - .peek(path -> log.info("Found input directory: {}", path)) - .collect(Collectors.toMap( - Function.identity(), this::getListOfMediaFiles)); - } - - @Override - public List retrieveFilesAsList() { - return retrieveFiles() - .values().stream() - .flatMap(List::stream) - .collect(Collectors.toList()); - } - - /** - * Collects a list of possible media candidates that are recursively found under the given path. - * - * @param path the path to the root directory. - * @return a list of candidates which may empty on error or none found. - */ - List getListOfMediaFiles(Path path) { - log.info("Scanning for media files in {}", path); - return scannerProvider.get() - .searchIn(path) - .withRecursion(true) - .withFileExtensions(scanConfig.allowed_extensions()) - .whileSkippingExtraFilesWith(scanConfig.skip_extension_name()) - .whileSkippingExtraFilesIn(scanConfig.mark_source_dir()) - .streamAndIgnoreErrors() - .map(file -> buildMedia(path, file)) - .peek(candidate -> log.info("Found file: {}", candidate)) - .collect(Collectors.toList()); - } - - /** - * Creates a media object with the given priority dir and file location. - * - * @param priorityDir the root path, which must start with a number. - * @param file the file name, which will be relativized against the base input dir. - * @return new media object. - */ - Media buildMedia(Path priorityDir, Path file) { - return Media.builder() - .sourcePath(scanConfig.base_input_dir().relativize(file)) - .priority(getNumberFromDir(priorityDir)) - .build(); - } - - /** - * Indicates whether the given path starts with a number {@literal >= 0}. - * - * @param path the path. - * @return true if the filename is {@literal >= 0}. - */ - boolean isPriorityDirectory(Path path) { - try { - return getNumberFromDir(path) >= 0; - } catch (NumberFormatException ex) { - log.debug(ex.getMessage()); - return false; - } - } - - /** - * Get the number of the file path. - * - * @param path a relative path which starts with a number. - * @return the number of the file name. - * @throws NumberFormatException if the path could not be parsed and is not a number. - */ - int getNumberFromDir(Path path) { - return Integer.parseInt(path.getFileName().toString()); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileParserImpl.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileParserImpl.java deleted file mode 100644 index 86b6c860..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileParserImpl.java +++ /dev/null @@ -1,110 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileParser; -import lombok.extern.slf4j.XSlf4j; -import org.slf4j.ext.XLogger; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -@XSlf4j -public class ProfileParserImpl implements ProfileParser { - - public static final Pattern FORMAT_PATTERN = Pattern.compile("%\\{([a-zA-Z]+)=(.*)\\}"); - - @Override - public Optional parseFile(Path path) { - log.entry(path); - try { - List lines = Files - .lines(path) - .map(String::trim) - .filter(this::isNotCommentLine) - .collect(Collectors.toList()); - return log.exit(Optional.of( - Profile.builder() - .arguments(lines.stream() - .filter(this::isNotFieldLine) - .collect(Collectors.toList())) - .fields(lines.stream() - .filter(this::isFieldLine) - .collect(Collectors.toMap(this::extractKey, this::extractValue))) - .location(path) - .build())); - } catch (IOException e) { - log.catching(XLogger.Level.WARN, e); - return log.exit(Optional.empty()); - } - } - - /** - * Returns the key (first group) extracted from {@link #FORMAT_PATTERN}. - * - * @param t - * @return the string with the key in upper case. - */ - String extractKey(String t) { - Matcher m = FORMAT_PATTERN.matcher(t); - m.find(); - return m.group(1).toUpperCase(Locale.ENGLISH); - } - - /** - * Returns the value (second group) extracted from {@link #FORMAT_PATTERN}. - * - * @param t - * @return the string with the value. - */ - String extractValue(String t) { - Matcher m = FORMAT_PATTERN.matcher(t); - m.find(); - return m.group(2); - } - - /** - * Negates {@link #isFieldLine(String)}. - */ - boolean isNotFieldLine(String s) { - return !isFieldLine(s); - } - - /** - * Returns true if the given string begins with "%{", ends with "}" and has a "=" in between. - * - * @param s - * @return - */ - boolean isFieldLine(String s) { - return FORMAT_PATTERN.matcher(s).find(); - } - - /** - * Negates {@link #isCommentLine(String)}. - */ - boolean isNotCommentLine(String s) { - return !isCommentLine(s); - } - - /** - * Tests whether the given string is a comment. Comments are empty lines or lines starting with "#" at the - * beginning. - * - * @param subject the line to test, not null. - * @return true if the line is comment. - * @throws NullPointerException if subject is null. - */ - boolean isCommentLine(String subject) { - return subject.startsWith("#") || "".equals(subject); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanConfig.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanConfig.java deleted file mode 100644 index 0a5f9110..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanConfig.java +++ /dev/null @@ -1,56 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.config.converter.PathConverter; -import clustercode.impl.scan.matcher.ProfileMatchers; -import org.aeonbits.owner.Config; - -import java.nio.file.Path; -import java.util.List; - -public interface ProfileScanConfig extends Config { - - /** - * Gets the extension of the profile file name. - * - * @return the extension, with leading dot if applicable (e.g. ".ffmpeg"), not null. - */ - @Key("CC_PROFILE_FILE_EXTENSION") - @DefaultValue(".ffmpeg") - String profile_file_name_extension(); - - /** - * Gets the base name of the profile file. This method could be combined with {@link #profile_file_name_extension()} - * to create a file (e.g. "profile.ffmpeg"). - * - * @return the file name (e.g. "profile"), not null. - */ - @Key("CC_PROFILE_FILE_NAME") - @DefaultValue("profile") - String profile_file_name(); - - /** - * Gets the root directory for profiles. - * - * @return the path to the directory, not null. - */ - @Key("CC_PROFILE_DIR") - @DefaultValue("/profiles") - @ConverterClass(PathConverter.class) - Path profile_base_dir(); - - /** - * Gets the base name of the default profile file without extension. This method could be combined with {@link - * #profile_file_name_extension()}. - * - * @return the file name (e.g. "default"), not null. - */ - @Key("CC_PROFILE_FILE_DEFAULT") - @DefaultValue("default") - String default_profile_file_name(); - - @Key("CC_PROFILE_STRATEGY") - @DefaultValue("COMPANION DIRECTORY_STRUCTURE DEFAULT") - @Separator(" ") - List profile_matchers(); - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanServiceImpl.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanServiceImpl.java deleted file mode 100644 index 5526bc7a..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ProfileScanServiceImpl.java +++ /dev/null @@ -1,39 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileMatcher; -import clustercode.api.scan.ProfileScanService; -import clustercode.impl.scan.matcher.ProfileMatchers; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.util.Map; -import java.util.Optional; - -@XSlf4j -public class ProfileScanServiceImpl implements ProfileScanService { - - private final Map profileMatcherMap; - - @Inject - ProfileScanServiceImpl(Map profileMatcherMap) { - this.profileMatcherMap = profileMatcherMap; - } - - @Override - public Optional selectProfile(Media candidate) { - log.entry(candidate); - log.debug("Selecting a profile..."); - for (ProfileMatcher profileMatcher : profileMatcherMap.values()) { - Optional result = profileMatcher.apply(candidate); - if (result.isPresent()) { - log.info("Selected profile {} for {}.", result.get().getLocation(), candidate); - return log.exit(result); - } - } - log.info("Could not find a suitable profile for {}.", candidate); - return log.exit(Optional.empty()); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesActivator.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesActivator.java deleted file mode 100644 index bae06486..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesActivator.java +++ /dev/null @@ -1,103 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.*; -import io.reactivex.disposables.Disposable; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; -import java.util.LinkedList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -@Slf4j -public class ScanServicesActivator implements Activator { - - private final RxEventBus eventBus; - private final List handlers = new LinkedList<>(); - private final ScanServicesMessageHandler messageHandler; - private final MediaScanConfig config; - - @Inject - public ScanServicesActivator(RxEventBus eventBus, - ScanServicesMessageHandler messageHandler, - MediaScanConfig config - ) { - this.eventBus = eventBus; - this.messageHandler = messageHandler; - this.config = config; - } - - @Override - public void preActivate(ActivatorContext context) { - log.debug("Activating scanning services."); - handlers.add(eventBus - .listenFor(ClusterConnectMessage.class) - .map(msg -> new ScanMediaCommand()) - .subscribe(messageHandler::onMediaScanRequest)); - handlers.add(eventBus - .listenFor(CleanupFinishedMessage.class) - .map(msg -> new ScanMediaCommand()) - .subscribe(messageHandler::onMediaScanRequest)); - handlers.add(eventBus - .listenFor(ScanMediaCommand.class) - .subscribe(messageHandler::onMediaScanRequest)); - handlers.add(eventBus - .listenFor(MediaScannedMessage.class) - .filter(MediaScannedMessage::listHasEntries) - .subscribe(messageHandler::onSuccessfulMediaScan)); - handlers.add(eventBus - .listenFor(MediaScannedMessage.class) - .filter(MediaScannedMessage::listIsEmpty) - .subscribe(messageHandler::onFailedMediaScan)); - handlers.add(eventBus - .listenFor(MediaSelectedMessage.class) - .filter(MediaSelectedMessage::isSelected) - .map(MediaSelectedMessage::getMedia) - .subscribe(messageHandler::onSuccessfulMediaSelection)); - handlers.add(eventBus - .listenFor(ProfileSelectedMessage.class) - .filter(ProfileSelectedMessage::isSelected) - .subscribe(messageHandler::onSuccessfulProfileSelection)); - handlers.add(eventBus - .listenFor(MediaSelectedMessage.class) - .filter(MediaSelectedMessage::isNotSelected) - .subscribe(messageHandler::onFailedMediaSelection)); - handlers.add(eventBus - .listenFor(ProfileSelectedMessage.class) - .filter(ProfileSelectedMessage::isNotSelected) - .map(this::onWaiting) - .delay(config.media_scan_interval(), TimeUnit.MINUTES) - .subscribe(messageHandler::onTimeout)); - handlers.add(eventBus - .listenFor(MediaSelectedMessage.class) - .filter(MediaSelectedMessage::isNotSelected) - .map(this::onWaiting) - .delay(config.media_scan_interval(), TimeUnit.MINUTES) - .subscribe(messageHandler::onTimeout)); - handlers.add(eventBus - .listenFor(MediaScannedMessage.class) - .filter(MediaScannedMessage::listIsEmpty) - .map(this::onWaiting) - .delay(config.media_scan_interval(), TimeUnit.MINUTES) - .subscribe(messageHandler::onTimeout)); - } - - @Override - public void activate(ActivatorContext context) { - } - - private T onWaiting(T msg) { - log.info("Waiting {} minutes.", config.media_scan_interval()); - return msg; - } - - @Override - public void deactivate(ActivatorContext context) { - log.debug("Deactivating scanning services."); - handlers.forEach(Disposable::dispose); - handlers.clear(); - } -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesMessageHandler.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesMessageHandler.java deleted file mode 100644 index c68b7a5e..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/ScanServicesMessageHandler.java +++ /dev/null @@ -1,84 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.*; -import clustercode.api.scan.MediaScanService; -import clustercode.api.scan.ProfileScanService; -import clustercode.api.scan.SelectionService; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; -import java.util.Optional; - -@Slf4j -class ScanServicesMessageHandler { - - private final MediaScanService scanService; - private final SelectionService selectionService; - private final ProfileScanService profileScanService; - private final RxEventBus eventBus; - - @Inject - ScanServicesMessageHandler( - MediaScanService scanService, - SelectionService selectionService, - ProfileScanService profileScanService, - RxEventBus eventBus - ) { - this.scanService = scanService; - this.selectionService = selectionService; - this.profileScanService = profileScanService; - this.eventBus = eventBus; - } - - void onMediaScanRequest(ScanMediaCommand msg) { - eventBus.emitAsync(MediaScannedMessage - .builder() - .mediaList(scanService.retrieveFilesAsList()) - .build()); - } - - void onSuccessfulMediaScan(MediaScannedMessage msg) { - log.info("Found {} possible media entries.", msg.getMediaList().size()); - log.debug("Selecting a suitable media for scheduling..."); - Optional result = selectionService.selectMedia(msg.getMediaList()); - eventBus.emitAsync(MediaSelectedMessage - .builder() - .media(result.orElse(null)) - .build()); - } - - void onFailedMediaScan(MediaScannedMessage msg) { - log.info("No media found."); - } - - void onSuccessfulMediaSelection(Media media) { - log.info("Selected media: {}", media); - Optional result = profileScanService.selectProfile(media); - eventBus.emitAsync(ProfileSelectedMessage - .builder() - .media(media) - .profile(result.orElse(null)) - .build()); - } - - void onFailedMediaSelection(MediaSelectedMessage msg) { - log.info("No suitable media found. Either all media are already converted or the last one is " + - "being transcoded by a cluster member."); - } - - void onTimeout(Object msg) { - startScanning(); - } - - private void startScanning() { - eventBus.emitAsync(new ScanMediaCommand()); - } - - void onSuccessfulProfileSelection(ProfileSelectedMessage msg) { - log.info("Selected {}", msg.getProfile()); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/SelectionServiceImpl.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/SelectionServiceImpl.java deleted file mode 100644 index 7fa95dc4..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/SelectionServiceImpl.java +++ /dev/null @@ -1,43 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Constraint; -import clustercode.api.domain.Media; -import clustercode.api.scan.SelectionService; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -@XSlf4j -public class SelectionServiceImpl implements SelectionService { - - private final Set constraints; - - @Inject - SelectionServiceImpl(Set constraints) { - this.constraints = constraints; - } - - @Override - public Optional selectMedia(List list) { - return log.exit(list.stream() - .sorted(Comparator.comparingInt(Media::getPriority).reversed()) - .filter(this::checkConstraints) - .findFirst()); - } - - /** - * Checks whether the given media candidate fulfills all constraints. May not evaluate all constraints if one - * declines the given media. - * - * @param media the media. Not null. - * @return true if all constraints are accepted, false if one declines. - */ - boolean checkConstraints(Media media) { - return constraints.stream().allMatch(filter -> filter.accept(media)); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/CompanionProfileMatcher.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/CompanionProfileMatcher.java deleted file mode 100644 index 4c54edd6..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/CompanionProfileMatcher.java +++ /dev/null @@ -1,45 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileMatcher; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; - -/** - * Provides a matcher which will search for a file named exactly as the media file, but with an additional extension for - * the configured transcoder settings defined in {@link ProfileScanConfig}. - */ -@XSlf4j -public class CompanionProfileMatcher implements ProfileMatcher { - - private final ProfileScanConfig scanConfig; - private final ProfileParser profileParser; - - @Inject - CompanionProfileMatcher(ProfileScanConfig scanConfig, - ProfileParser profileParser) { - this.scanConfig = scanConfig; - this.profileParser = profileParser; - } - - @Override - public Optional apply(Media candidate) { - log.entry(candidate); - Path profile = candidate.getSourcePath().resolveSibling( - candidate.getSourcePath().getFileName() + scanConfig.profile_file_name_extension()); - - if (Files.exists(profile)) { - return log.exit(profileParser.parseFile(profile)); - } else { - log.debug("Companion file {} does not exist.", profile); - return log.exit(Optional.empty()); - } - } -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DefaultProfileMatcher.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DefaultProfileMatcher.java deleted file mode 100644 index ec39e1be..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DefaultProfileMatcher.java +++ /dev/null @@ -1,44 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileMatcher; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; - -/** - * Provides a matcher which looks for the global default profile. - */ -@XSlf4j -public class DefaultProfileMatcher implements ProfileMatcher { - - private final ProfileParser parser; - private final ProfileScanConfig profileScanConfig; - - @Inject - DefaultProfileMatcher(ProfileScanConfig profileScanConfig, - ProfileParser parser) { - this.parser = parser; - this.profileScanConfig = profileScanConfig; - } - - @Override - public Optional apply(Media candidate) { - log.entry(candidate); - Path profileFile = profileScanConfig.profile_base_dir().resolve( - profileScanConfig.default_profile_file_name() + profileScanConfig.profile_file_name_extension()); - if (Files.exists(profileFile)) { - return log.exit(parser.parseFile(profileFile)); - } else { - log.warn("Default profile file {} does not exist.", profileFile); - return log.exit(Optional.empty()); - } - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DirectoryStructureMatcher.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DirectoryStructureMatcher.java deleted file mode 100644 index bd111c8a..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/DirectoryStructureMatcher.java +++ /dev/null @@ -1,72 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileMatcher; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Optional; - -/** - * Provides a matcher which looks in the recreated directory structure in the profiles folder based on the source file - * of the media. For a media file such as {@code 0/movies/subdir/movie.mp4} this matcher will look for a profile in - * {@code /profiles/0/movies/subdir/}. If it did not find it or on error, the parent will be searched ({@code - * /profiles/0/movies/}). This matcher stops at the root directory of the input dir, in the example case it is {@code - * 0/}. - */ -@XSlf4j -public class DirectoryStructureMatcher implements ProfileMatcher { - - private final ProfileParser profileParser; - private final ProfileScanConfig profileScanConfig; - - @Inject - DirectoryStructureMatcher(ProfileScanConfig profileScanConfig, - ProfileParser profileParser) { - this.profileParser = profileParser; - this.profileScanConfig = profileScanConfig; - } - - @Override - public Optional apply(Media candidate) { - log.entry(candidate); - Path mediaFileParent = candidate.getSourcePath().getParent(); - Path sisterDir = profileScanConfig.profile_base_dir().resolve(mediaFileParent); - Path profileFile = sisterDir.resolve(profileScanConfig.profile_file_name() + profileScanConfig - .profile_file_name_extension()); - - Path rootDir = profileScanConfig.profile_base_dir().resolve(mediaFileParent.getName(0)); - return log.exit(parseRecursive(profileFile, rootDir)); - } - - private Optional parseRecursive(Path file, Path root) { - if (Files.exists(file)) { - Optional result = profileParser.parseFile(file); - if (result.isPresent()) { - log.info("Found profile: {}", result.get().getLocation()); - return result; - } else { - return parseRecursive(getProfileFileFromParentDirectory(file, - profileScanConfig.profile_file_name() + profileScanConfig.profile_file_name_extension()), - root); - } - } else if (file.getParent().equals(root)) { - log.debug("Did not find a suitable profile in any subdir of {}", root); - return Optional.empty(); - } else { - return parseRecursive(getProfileFileFromParentDirectory(file, - profileScanConfig.profile_file_name() + profileScanConfig.profile_file_name_extension()), - root); - } - } - - private Path getProfileFileFromParentDirectory(Path profileFile, String fileNameOfParent) { - return profileFile.getParent().getParent().resolve(fileNameOfParent); - } - -} diff --git a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/ProfileMatchers.java b/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/ProfileMatchers.java deleted file mode 100644 index b6b39aad..00000000 --- a/clustercode.impl.scan/src/main/java/clustercode/impl/scan/matcher/ProfileMatchers.java +++ /dev/null @@ -1,9 +0,0 @@ -package clustercode.impl.scan.matcher; - -public enum ProfileMatchers { - - COMPANION, - DEFAULT, - DIRECTORY_STRUCTURE - -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/FileScannerImplTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/FileScannerImplTest.java deleted file mode 100644 index c287a7aa..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/FileScannerImplTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -public class FileScannerImplTest implements FileBasedUnitTest { - - private FileScannerImpl subject; - - @BeforeEach - public void setUp() throws Exception { - setupFileSystem(); - subject = new FileScannerImpl(); - } - - - @Test - public void scan_ShouldReturnEmptyList_IfSearchDirDoesNotExist() throws Exception { - Path searchDir = getPath("input"); - - Optional> results = subject.searchIn(searchDir).withRecursion(true) - .scan(); - - assertThat(results.isPresent()).isFalse(); - } - - @Test - public void scan_ShouldFindOneFile_IfRecursionIsDisabled() throws Exception { - Path searchDir = getPath("input"); - Path testMedia = createFile(searchDir.resolve("media.mp4")); - createFile(searchDir.resolve("subdir/ignored.mp4")); - - Optional> results = subject.searchIn(searchDir).withDepth(1).withRecursion(false) - .scan(); - - assertThat(results.get()).containsExactly(testMedia); - assertThat(results.get()).hasSize(1); - } - - @Test - public void scan_ShouldFindDirectory_IfDirSearchIsEnabled() throws Exception { - Path searchDir = getPath("input"); - - Path subdir = createDirectory(searchDir.resolve("subdir")); - - Optional> results = subject.searchIn(searchDir).withRecursion(true).withDirectories(true) - .scan(); - - assertThat(results.get()).containsExactly(subdir); - assertThat(results.get()).hasSize(1); - } - - @Test - public void scan_ShouldFindRecursiveFile_IfFileExists() throws Exception { - Path searchDir = getPath("input"); - - Path testMedia = createFile(searchDir.resolve("subdir/media.mp4")); - - Optional> results = subject.searchIn(searchDir).withRecursion(true) - .scan(); - - assertThat(results.get()).containsExactly(testMedia); - assertThat(results.get()).hasSize(1); - } - - @Test - public void hasAllowedExtension_ShouldReturnTrue_IfHasExtension() throws Exception { - subject.withFileExtensions(Arrays.asList(".mp4")); - Path testFile = getPath("something.mp4"); - - assertThat(subject.hasAllowedExtension(testFile)).isTrue(); - } - - @Test - public void hasAllowedExtension_ShouldReturnFalse_IfNotHasExtension() throws Exception { - subject.withFileExtensions(Arrays.asList("mp4")); - Path testFile = getPath("mp4.mkv"); - - assertThat(subject.hasAllowedExtension(testFile)).isFalse(); - } - - @Test - public void hasAllowedExtension_ShouldReturnTrue_IfNoFilterInstalled() throws Exception { - Path testFile = getPath("mp4.mkv"); - - assertThat(subject.hasAllowedExtension(testFile)).isTrue(); - } - - @Test - public void hasNotCompanionFile_ShouldReturnFalseIfFileExists() throws Exception { - String ext = ".done"; - subject.whileSkippingExtraFilesWith(ext); - - Path testFile = createFile(getPath("foo", "bar.ext")); - createFile(getPath("foo", "bar.ext.done")); - - assertThat(subject.hasNotCompanionFile(testFile)).isEqualTo(false); - } - - @Test - public void hasNotCompanionFile_ShouldReturnTrueIfFileNotExists() throws Exception { - String ext = ".done"; - subject.whileSkippingExtraFilesWith(ext); - - Path testFile = createFile(getPath("foo", "bar.ext")); - - assertThat(subject.hasNotCompanionFile(testFile)).isTrue(); - } - - @Test - public void hasNotCompanionFile_ShouldReturnTrue_IfExtensionNotProvided() throws Exception { - Path testFile = createFile(getPath("foo", "bar.ext")); - - assertThat(subject.hasNotCompanionFile(testFile)).isTrue(); - } - - @Test - public void hasNotCompanionFile_ShouldReturnTrue_IfMarkDirProvided_ButFileNotFound() throws Exception { - String ext = ".done"; - Path testFile = createFile(getPath("foo", "bar.ext")); - Path dir = createDirectory(getPath("mark")); - - subject.whileSkippingExtraFilesWith(ext) - .whileSkippingExtraFilesIn(dir) - .searchIn(getPath("input", "foo")); - - assertThat(subject.hasNotCompanionFile(testFile)).isTrue(); - } - - @Test - public void hasNotCompanionFile_ShouldReturnFalse_IfFileExistsInDirectory() throws Exception { - String ext = ".done"; - Path inputFolder = getPath("input", "0"); - Path testFile = createFile(inputFolder.resolve(("bar.ext"))); - createFile(getPath("mark", "0", "bar.ext" + ext)); - - subject.whileSkippingExtraFilesWith(ext) - .whileSkippingExtraFilesIn(getPath("mark")) - .searchIn(inputFolder); - - assertThat(subject.hasNotCompanionFile(testFile)).isFalse(); - } - - @Test - public void stream_ShouldReturnEmptyStream_IfIOExceptionOccurred() throws Exception { - Path testDir = getPath("foo", "bar"); - assertThat(subject.searchIn(testDir).streamAndIgnoreErrors()).isEmpty(); - } - - @Test - public void emptyStreamOnError_ShouldThrowException_IfIOExceptionOccurred() throws Exception { - Path testDir = getPath("foo", "bar"); - assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> - subject.searchIn(testDir).stream()); - } -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/MediaScanServiceImplTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/MediaScanServiceImplTest.java deleted file mode 100644 index ca9b1ce7..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/MediaScanServiceImplTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Media; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.Mockito.when; - -public class MediaScanServiceImplTest implements FileBasedUnitTest { - - private MediaScanServiceImpl subject; - private Path inputDir; - - @Mock - private MediaScanConfig scanSettings; - - private Map> candidates; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - when(scanSettings.allowed_extensions()).thenReturn(Arrays.asList(".mp4")); - when(scanSettings.base_input_dir()).thenReturn(getPath("input")); - when(scanSettings.skip_extension_name()).thenReturn(".done"); - when(scanSettings.mark_source_dir()).thenReturn(getPath("mark")); - - inputDir = scanSettings.base_input_dir(); - subject = new MediaScanServiceImpl(scanSettings, FileScannerImpl::new); - } - - @Test - public void getListOfMediaFiles_ShouldReturnListWithTwoEntries() throws Exception { - Path dir1 = inputDir.resolve("1"); - - Path file11 = createFile(dir1.resolve("file11.mp4")); - Path file12 = createFile(dir1.resolve("file12.mp4")); - - List result = subject.getListOfMediaFiles(dir1); - - assertThat(result).extracting(Media::getSourcePath) - .containsExactly(inputDir.relativize(file11), inputDir.relativize(file12)); - } - - @Test - public void getListOfMediaFiles_ShouldReturnListWithOneEntry_AndIgnoreCompanionFile() throws Exception { - Path dir1 = inputDir.resolve("1"); - - Path file11 = createFile(dir1.resolve("file11.mp4")); - createFile(dir1.resolve("file12.mp4")); - createFile(dir1.resolve("file12.mp4.done")); - createFile(dir1.resolve("file13.txt")); - - List result = subject.getListOfMediaFiles(dir1); - - assertThat(result).extracting(Media::getSourcePath).containsExactly(inputDir.relativize(file11)); - } - - @Test - public void getListOfMediaFiles_ShouldReturnListWithOneEntry_AndIgnoreMarkedFileInMarkSourceDir() throws Exception { - Path dir1 = getPath("input","1"); - - Path file11 = createFile(dir1.resolve("file11.mp4")); - createFile(dir1.resolve("file12.mp4")); - createFile(scanSettings.base_input_dir().resolve("1").resolve("file12.mp4.done")); - - List result = subject.getListOfMediaFiles(dir1); - - assertThat(result).extracting(Media::getSourcePath).containsExactly(inputDir.relativize(file11)); - } - - @Test - public void getListOfMediaFiles_ShouldReturnEmptyList_IfNoFilesFound() throws Exception { - Path dir1 = createDirectory(inputDir.resolve("1")); - - List result = subject.getListOfMediaFiles(dir1); - - assertThat(result).isEmpty(); - } - - @Test - public void retrieveFiles_ShouldReturnOneEntry_AndIgnoreInvalidDirectories() throws Exception { - Path dir1 = createDirectory(inputDir.resolve("1")); - createDirectory(inputDir.resolve("inexistent")); - - candidates = subject.retrieveFiles(); - - assertThat(candidates).containsKey(dir1); - assertThat(candidates.get(dir1)).isEmpty(); - assertThat(candidates).hasSize(1); - } - - @Test - public void doExecute_ShouldThrowException_IfInputDirIsInexistent() throws Exception { - assertThatExceptionOfType(RuntimeException.class).isThrownBy(() -> subject.retrieveFiles()); - } - - @Test - public void isPriorityDirectory_ShouldReturnTrue_IfDirectoryIsPositive() throws Exception { - Path dir = inputDir.resolve("2"); - assertThat(subject.isPriorityDirectory(dir)).isTrue(); - } - - @Test - public void isPriorityDirectory_ShouldReturnTrue_IfDirectoryIsZero() throws Exception { - Path dir = inputDir.resolve("0"); - assertThat(subject.isPriorityDirectory(dir)).isTrue(); - } - - @Test - public void isPriorityDirectory_ShouldReturnFalse_IfDirectoryIsInvalid() throws Exception { - Path dir = inputDir.resolve("-1"); - assertThat(subject.isPriorityDirectory(dir)).isFalse(); - } - - @Test - public void getNumberFromDir_ShouldReturn2_IfPathBeginsWith2() throws Exception { - Path dir = inputDir.resolve("2"); - assertThat(subject.getNumberFromDir(dir)).isEqualTo(2); - } - - @Test - public void getNumberFromDir_ShouldThrowException_IfPathDoesNotContainNumber() throws Exception { - Path dir = inputDir.resolve("error"); - assertThatExceptionOfType(NumberFormatException.class).isThrownBy(() -> subject.getNumberFromDir(dir)); - } -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileParserImplTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileParserImplTest.java deleted file mode 100644 index 6041c53f..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileParserImplTest.java +++ /dev/null @@ -1,140 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Profile; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; - -public class ProfileParserImplTest implements FileBasedUnitTest { - - private ProfileParserImpl subject; - - @BeforeEach - public void setUp() throws Exception { - subject = new ProfileParserImpl(); - setupFileSystem(); - } - - @Test - public void parseFile_ShouldIgnoreEmptyLine() throws Exception { - Path testFile = getPath("profile.ffmpeg"); - String option1 = " line_without_space"; - String option2 = "line with space"; - Files.write(testFile, Arrays.asList(option1, "", option2)); - - List results = subject.parseFile(testFile).get().getArguments(); - - assertThat(results).containsExactly("line_without_space", "line with space"); - } - - @Test - public void parseFile_ShouldIgnoreFieldLines() throws Exception { - Path testFile = getPath("profile.ffmpeg"); - String option1 = " %{FIELD=value}"; - String option2 = "another line"; - Files.write(testFile, Arrays.asList(option1, option2)); - - List results = subject.parseFile(testFile).get().getArguments(); - - assertThat(results).containsExactly("another line"); - } - - @Test - public void parseFile_ShouldParseFieldLines() throws Exception { - Path testFile = getPath("profile.ffmpeg"); - String option1 = "%{FIELD=value}"; - String option2 = "%{key=other}"; - Files.write(testFile, Arrays.asList(option1, option2)); - - Map results = subject.parseFile(testFile).get().getFields(); - - assertThat(results) - .containsKeys("FIELD", "KEY") - .containsValues("value", "other") - .hasSize(2); - } - - @Test - public void parseFile_ShouldReturnEmptyProfile_OnError() throws Exception { - Path testFile = getPath("profile.ffmpeg"); - - Optional result = subject.parseFile(testFile); - - assertThat(result).isEmpty(); - } - - @Test - public void isCommentLine_ShouldReturnTrue_IfLineBeginsWithHashtag() throws Exception { - String testLine = "# this is a comment"; - assertThat(subject.isCommentLine(testLine)).isTrue(); - } - - @Test - public void isCommentLine_ShouldReturnTrue_IfLineIsEmpty() throws Exception { - String testLine = ""; - assertThat(subject.isCommentLine(testLine)).isTrue(); - } - - @Test - public void isCommentLine_ShouldReturnFalse_IfLineIsValid() throws Exception { - String testLine = "this is not a comment"; - assertThat(subject.isCommentLine(testLine)).isFalse(); - } - - @Test - public void extractKey_ShouldReturnKey_InUppercase() throws Exception { - String testLine = "%{key=value}"; - assertThat(subject.extractKey(testLine)).isEqualTo("KEY"); - } - - @Test - public void isFieldLine_ShouldReturnTrue_IfLineIsFieldLine() throws Exception { - String testLine = "%{KEY=value}"; - assertThat(subject.isFieldLine(testLine)).isTrue(); - } - - @Test - public void isFieldLine_ShouldReturnFalse_IfLineDoesNotBeginWithPercent() throws Exception { - String testLine = "{KEY=value}"; - assertThat(subject.isFieldLine(testLine)).isFalse(); - } - - @Test - public void isFieldLine_ShouldReturnFalse_IfKeyIsInvalid() throws Exception { - String testLine = "%{=value}"; - assertThat(subject.isFieldLine(testLine)).isFalse(); - } - - @Test - public void isFieldLine_ShouldReturnTrue_IfKeyIsValid_AndNoValuePresent() throws Exception { - String testLine = "%{key=}"; - assertThat(subject.isFieldLine(testLine)).isTrue(); - } - - @Test - public void isFieldLine_ShouldReturnFalse_IfKeyIsValid_AndNoNoClosingBracket() throws Exception { - String testLine = "%{key="; - assertThat(subject.isFieldLine(testLine)).isFalse(); - } - - @Test - public void extractValue_ShouldReturnValue() throws Exception { - String testLine = "%{KEY=.value}"; - assertThat(subject.extractValue(testLine)).isEqualTo(".value"); - } - - @Test - public void extractValue_ShouldReturnEmptyString_IfNoValuePresent() throws Exception { - String testLine = "%{KEY=}"; - assertThat(subject.extractValue(testLine)).isEqualTo(""); - } -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileScanServiceImplTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileScanServiceImplTest.java deleted file mode 100644 index a5d4e2f2..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/ProfileScanServiceImplTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package clustercode.impl.scan; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileMatcher; -import clustercode.impl.scan.matcher.ProfileMatchers; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.*; - -public class ProfileScanServiceImplTest { - - private ProfileScanServiceImpl subject; - - @Mock - private Media candidate; - @Mock - private ProfileMatcher matcher1; - @Mock - private ProfileMatcher matcher2; - - @Spy - private Profile profile; - - private Map matchers = new HashMap<>(); - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - matchers.put(ProfileMatchers.DIRECTORY_STRUCTURE, matcher1); - matchers.put(ProfileMatchers.DEFAULT, matcher2); - subject = new ProfileScanServiceImpl(matchers); - } - - @Test - public void selectProfile_ShouldReturnProfile_OfFirstMatcher() throws Exception { - when(matcher1.apply(candidate)).thenReturn(Optional.of(profile)); - - Profile result = subject.selectProfile(candidate).get(); - - assertThat(result).isEqualTo(profile); - verify(matcher1).apply(candidate); - verify(matcher2, never()).apply(any()); - } - - @Test - public void selectProfile_ShouldReturnProfile_OfSecondMatcher() throws Exception { - when(matcher1.apply(candidate)).thenReturn(Optional.empty()); - when(matcher2.apply(candidate)).thenReturn(Optional.of(profile)); - - Profile result = subject.selectProfile(candidate).get(); - - assertThat(result).isEqualTo(profile); - verify(matcher1).apply(candidate); - verify(matcher2).apply(candidate); - } - - @Test - public void selectProfile_ShouldReturnEmpty_IfNoMatcherValid() throws Exception { - when(matcher1.apply(candidate)).thenReturn(Optional.empty()); - when(matcher2.apply(candidate)).thenReturn(Optional.empty()); - - Optional result = subject.selectProfile(candidate); - - assertThat(result).isEmpty(); - verify(matcher1).apply(candidate); - verify(matcher2).apply(candidate); - } - -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/CompanionProfileMatcherTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/CompanionProfileMatcherTest.java deleted file mode 100644 index 5dee8934..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/CompanionProfileMatcherTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.when; - -public class CompanionProfileMatcherTest implements FileBasedUnitTest { - - @Mock - private ProfileScanConfig config; - @Mock - private ProfileParser profileParser; - @Mock - private Media candidate; - @Spy - private Profile profile; - - private CompanionProfileMatcher subject; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - subject = new CompanionProfileMatcher(config, profileParser); - when(config.profile_file_name_extension()).thenReturn(".ffmpeg"); - when(profileParser.parseFile(any())).thenReturn(Optional.of(profile)); - } - - @Test - public void apply_ShouldReturnProfile_IfFileFoundAndReadable() throws Exception { - Path media = createParentDirOf(getPath("input", "movie.mp4")); - createFile(getPath("input", "movie.mp4.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - - Profile result = subject.apply(candidate).get(); - - assertThat(result).isEqualTo(profile); - } - - @Test - public void apply_ShouldReturnEmpty_IfFileNotFound() throws Exception { - Path media = createParentDirOf(getPath("input", "movie.mp4")); - - when(candidate.getSourcePath()).thenReturn(media); - - assertThat(subject.apply(candidate)).isEmpty(); - } - - @Test - public void apply_ShouldReturnEmpty_IfFileNotReadable() throws Exception { - Path media = createParentDirOf(getPath("input", "movie.mkv")); - Path file = createFile(getPath("input", "movie.mkv.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - - when(profileParser.parseFile(file)).thenReturn(Optional.empty()); - - assertThat(subject.apply(candidate)).isEmpty(); - } - -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DefaultProfileMatcherTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DefaultProfileMatcherTest.java deleted file mode 100644 index 1f0218b0..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DefaultProfileMatcherTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.when; - -public class DefaultProfileMatcherTest implements FileBasedUnitTest { - - - private DefaultProfileMatcher subject; - @Mock - private ProfileParser parser; - @Mock - private ProfileScanConfig config; - @Mock - private Media candidate; - @Spy - private Profile profile; - private Path profileFolder; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - profileFolder = getPath("profiles"); - when(config.profile_file_name_extension()).thenReturn(".ffmpeg"); - when(config.profile_base_dir()).thenReturn(profileFolder); - when(config.default_profile_file_name()).thenReturn("default"); - subject = new DefaultProfileMatcher(config, parser); - } - - @Test - public void apply_ShouldReturnDefaultProfile_IfFileFoundAndParsable() throws Exception { - Path profileFile = createFile(profileFolder.resolve("default.ffmpeg")); - - when(parser.parseFile(profileFile)).thenReturn(Optional.of(profile)); - assertThat(subject.apply(candidate)).hasValue(profile); - - } - - @Test - public void apply_ShouldReturnEmptyProfile_IfFileNotFound() throws Exception { - when(parser.parseFile(any())).thenReturn(Optional.empty()); - assertThat(subject.apply(candidate)).isEmpty(); - - } - -} diff --git a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DirectoryStructureMatcherTest.java b/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DirectoryStructureMatcherTest.java deleted file mode 100644 index 67323ae8..00000000 --- a/clustercode.impl.scan/src/test/java/clustercode/impl/scan/matcher/DirectoryStructureMatcherTest.java +++ /dev/null @@ -1,113 +0,0 @@ -package clustercode.impl.scan.matcher; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.scan.ProfileParser; -import clustercode.impl.scan.ProfileScanConfig; -import clustercode.test.util.FileBasedUnitTest; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.nio.file.Path; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.when; - -public class DirectoryStructureMatcherTest implements FileBasedUnitTest { - - private DirectoryStructureMatcher subject; - @Mock - private ProfileParser parser; - @Mock - private ProfileScanConfig config; - @Mock - private Media candidate; - @Spy - private Profile profile; - private Path profileFolder; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - profileFolder = getPath("profiles"); - when(config.profile_file_name()).thenReturn("profile"); - when(config.profile_file_name_extension()).thenReturn(".ffmpeg"); - when(config.profile_base_dir()).thenReturn(profileFolder); - subject = new DirectoryStructureMatcher(config, parser); - } - - @Test - public void apply_ShouldReturnProfile_ClosestToOriginalStructure() throws Exception { - Path media = createFile(getPath("0", "movies", "subdir", "movie.mp4")); - Path profileFile = createFile(profileFolder.resolve("0/movies/subdir/profile.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - when(parser.parseFile(profileFile)).thenReturn(Optional.of(profile)); - - Optional result = subject.apply(candidate); - - assertThat(result).hasValue(profile); - } - - @Test - public void apply_ShouldReturnProfile_FromParentDirectory() throws Exception { - Path media = createFile(getPath("0", "movies", "subdir", "movie.mp4")); - Path profileFile = createFile(profileFolder.resolve("0/movies/profile.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - when(parser.parseFile(profileFile)).thenReturn(Optional.of(profile)); - - Optional result = subject.apply(candidate); - - assertThat(result).hasValue(profile); - } - - @Test - public void apply_ShouldReturnProfile_FromGrandParentDirectory() throws Exception { - Path media = createFile(getPath("0", "movies", "subdir", "movie.mp4")); - Path profileFile = createFile(profileFolder.resolve("0/profile.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - when(parser.parseFile(profileFile)).thenReturn(Optional.of(profile)); - profile.setLocation(profileFile); - - Optional result = subject.apply(candidate); - - assertThat(result).hasValue(profile); - } - - - @Test - public void apply_ShouldReturnEmptyProfile_IfNoFileMatches() throws Exception { - Path media = createFile(getPath("0", "movies", "subdir", "movie.mp4")); - - when(candidate.getSourcePath()).thenReturn(media); - when(parser.parseFile(any())).thenReturn(Optional.empty()); - - Optional result = subject.apply(candidate); - - assertThat(result).isEmpty(); - } - - @Test - @SuppressWarnings("unchecked") - public void apply_ShouldReturnParentProfile_IfSiblingFileCouldNotBeRead() throws Exception { - Path media = createFile(getPath("0", "movies", "subdir", "movie.mp4")); - createFile(profileFolder.resolve("0/movies/subdir/profile.ffmpeg")); - createFile(profileFolder.resolve("0/movies/profile.ffmpeg")); - - when(candidate.getSourcePath()).thenReturn(media); - when(parser.parseFile(any())).thenReturn(Optional.empty(), Optional.of(profile)); - - Optional result = subject.apply(candidate); - - assertThat(result).hasValue(profile); - } - -} diff --git a/clustercode.impl.transcode/build.gradle b/clustercode.impl.transcode/build.gradle deleted file mode 100644 index ff6afbf0..00000000 --- a/clustercode.impl.transcode/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -version '1.0.1' - -dependencies { - compile project(":${proj_api_transcode}") - compile project(":${proj_api_process}") - compile project(":${proj_api_event}") - compile "${dep_rabbitmq}" - compile "${dep_inject}" - testCompile project(":${proj_test_util}").sourceSets.test.output -} diff --git a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodeActivator.java b/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodeActivator.java deleted file mode 100644 index c6fd3e24..00000000 --- a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodeActivator.java +++ /dev/null @@ -1,69 +0,0 @@ -package clustercode.impl.transcode; - -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; -import clustercode.api.domain.TranscodeTask; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.CancelTranscodeMessage; -import clustercode.api.event.messages.ProfileSelectedMessage; -import clustercode.api.transcode.TranscodingService; -import io.reactivex.disposables.Disposable; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; -import java.util.LinkedList; -import java.util.List; - -@Slf4j -public class TranscodeActivator implements Activator { - - private final TranscodingService transcodingService; - private final RxEventBus eventBus; - private final TranscodingMessageHandler messageHandler; - private final List handlers = new LinkedList<>(); - - @Inject - TranscodeActivator( - TranscodingService transcodingService, - RxEventBus eventBus, - TranscodingMessageHandler messageHandler - ) { - this.transcodingService = transcodingService; - this.eventBus = eventBus; - this.messageHandler = messageHandler; - } - - @Override - public void preActivate(ActivatorContext context) { - log.debug("Activating transcoding services."); - handlers.add(eventBus - .listenFor(CancelTranscodeMessage.class, this::onCancelTranscodeTask)); - handlers.add(eventBus - .listenFor(TranscodeTask.class, transcodingService::transcode)); - transcodingService - .onProgressUpdated(eventBus::emit) - .onTranscodeBegin(eventBus::emit) - .onTranscodeFinished(eventBus::emit); - - handlers.add(eventBus - .listenFor(ProfileSelectedMessage.class) - .filter(ProfileSelectedMessage::isSelected) - .subscribe(messageHandler::onProfileSelected)); - } - - @Override - public void activate(ActivatorContext context) { - - } - - private void onCancelTranscodeTask(CancelTranscodeMessage event) { - event.setCancelled(transcodingService.cancelTranscode()); - } - - @Override - public void deactivate(ActivatorContext context) { - log.debug("Deactivating transcoding services."); - handlers.forEach(Disposable::dispose); - handlers.clear(); - } -} diff --git a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscoderConfig.java b/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscoderConfig.java deleted file mode 100644 index f96660a8..00000000 --- a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscoderConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package clustercode.impl.transcode; - -import clustercode.api.config.converter.PathConverter; -import org.aeonbits.owner.Config; - -import java.nio.file.Path; - -public interface TranscoderConfig extends Config { - - /** - * Gets the path to the temporary directory, which is needed during transcoding. - * - * @return the path to the dir. - */ - @Key("CC_TRANSCODE_TEMP_DIR") - @DefaultValue("/var/tmp/clustercode") - @ConverterClass(PathConverter.class) - Path temporary_dir(); - - /** - * Gets the default video extension with leading "." (e.g. ".mkv"). - * - * @return the default extension, not null. - */ - @Key("CC_TRANSCODE_DEFAULT_FORMAT") - @DefaultValue(".mkv") - String default_video_extension(); - - @Key("CC_MEDIA_INPUT_DIR") - @DefaultValue("/input") - @ConverterClass(PathConverter.class) - Path base_input_dir(); -} diff --git a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingMessageHandler.java b/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingMessageHandler.java deleted file mode 100644 index ce788c56..00000000 --- a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingMessageHandler.java +++ /dev/null @@ -1,34 +0,0 @@ -package clustercode.impl.transcode; - -import clustercode.api.domain.TranscodeTask; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.ProfileSelectedMessage; -import clustercode.api.transcode.TranscodingService; -import lombok.extern.slf4j.Slf4j; - -import javax.inject.Inject; - -@Slf4j -public class TranscodingMessageHandler { - - private final TranscodingService transcodingService; - private final RxEventBus eventBus; - - @Inject - TranscodingMessageHandler(TranscodingService transcodingService, - RxEventBus eventBus) { - - this.transcodingService = transcodingService; - this.eventBus = eventBus; - } - - - public void onProfileSelected(ProfileSelectedMessage msg) { - TranscodeTask task = TranscodeTask - .builder() - .profile(msg.getProfile()) - .media(msg.getMedia()) - .build(); - transcodingService.transcode(task); - } -} diff --git a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingServiceImpl.java b/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingServiceImpl.java deleted file mode 100644 index 4dadb851..00000000 --- a/clustercode.impl.transcode/src/main/java/clustercode/impl/transcode/TranscodingServiceImpl.java +++ /dev/null @@ -1,186 +0,0 @@ -package clustercode.impl.transcode; - -import clustercode.api.domain.Profile; -import clustercode.api.domain.TranscodeTask; -import clustercode.api.event.messages.TranscodeBeginEvent; -import clustercode.api.event.messages.TranscodeFinishedEvent; -import clustercode.api.transcode.TranscodeReport; -import clustercode.api.transcode.TranscodingService; -import clustercode.impl.util.FileUtil; -import io.reactivex.BackpressureStrategy; -import io.reactivex.Flowable; -import io.reactivex.Observable; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; -import io.reactivex.subjects.Subject; -import lombok.Synchronized; -import lombok.extern.slf4j.XSlf4j; - -import javax.inject.Inject; -import java.nio.file.Path; -import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -@XSlf4j -public class TranscodingServiceImpl implements TranscodingService { - - public static final String OUTPUT_PLACEHOLDER = "${OUTPUT}"; - public static final String INPUT_PLACEHOLDER = "${INPUT}"; - - private final TranscoderConfig transcoderConfig; - private final Subject publisher; - - private boolean cancelRequested; - - @Inject - TranscodingServiceImpl(TranscoderConfig transcoderConfig) { - this.transcoderConfig = transcoderConfig; - - this.publisher = PublishSubject.create().toSerialized(); - - publisher.ofType(TranscodeTask.class) - .observeOn(Schedulers.computation()) - .subscribeOn(Schedulers.io()) - .subscribe(this::prepareTranscode); - } - - @Synchronized - private void doTranscode(Path tempFile, TranscodeTask task) { - - var source = task.getMedia().getSourcePath(); - log.info("Starting transcoding process: from {} to {}. This might take a while...", source, tempFile); - - publisher.onNext(TranscodeBeginEvent - .builder() - .task(task) - .build()); - } - - private List buildArguments(Path source, Path target, TranscodeTask task) { - return task.getProfile() - .getArguments() - .stream() - .map(s -> replaceInput(s, source)) - .map(s -> replaceOutput(s, target)) - .collect(Collectors.toList()); - } - - private void onSuccess(Path tempFile, TranscodeTask task) { - log.entry(tempFile, task); - var event = TranscodeFinishedEvent - .builder() - .temporaryPath(tempFile) - .media(task.getMedia()) - .profile(task.getProfile()) - .successful(true) - .cancelled(cancelRequested) - .build(); - - cancelRequested = false; - - if (event.isSuccessful()) log.info("Transcode finished."); - else { - log.info("Transcode {}.", event.isCancelled() ? "cancelled" : "failed"); - } - publisher.onNext(event); - } - - private void onError(Throwable ex) { - log.error(ex.toString()); - var event = TranscodeFinishedEvent - .builder() - .successful(false) - .build(); - cancelRequested = false; - publisher.onNext(event); - } - - private void prepareTranscode(TranscodeTask task) { - log.entry(task); - var tempFile = transcoderConfig - .temporary_dir() - .resolve(FileUtil.getFileNameWithoutExtension( - task.getMedia().getSourcePath()) + getPropertyOrDefault( - task.getProfile(), "FORMAT", transcoderConfig.default_video_extension()) - ); - - doTranscode(tempFile, task); - } - - @Override - public void transcode(TranscodeTask task) { - publisher.onNext(task); - } - - @Override - @Synchronized - public boolean cancelTranscode() { - log.debug("Cancelling task..."); - this.cancelRequested = true; - return true; - } - - @Override - public Flowable onTranscodeBegin() { - return publisher - .subscribeOn(Schedulers.computation()) - .observeOn(Schedulers.computation()) - .ofType(TranscodeBeginEvent.class) - .toFlowable(BackpressureStrategy.BUFFER); - } - - @Override - public Flowable onTranscodeFinished() { - return publisher - .subscribeOn(Schedulers.computation()) - .observeOn(Schedulers.computation()) - .ofType(TranscodeFinishedEvent.class) - .toFlowable(BackpressureStrategy.BUFFER); - } - - @Override - public Observable onProgressUpdated() { - throw new UnsupportedOperationException("not implemented"); - } - - @Override - public TranscodingService onProgressUpdated(Consumer listener) { - publisher - .ofType(TranscodeReport.class) - .observeOn(Schedulers.computation()) - .subscribe(listener::accept); - return this; - } - - @Override - public TranscodingService onTranscodeFinished(Consumer listener) { - publisher - .ofType(TranscodeFinishedEvent.class) - .observeOn(Schedulers.computation()) - .subscribe(listener::accept); - return this; - } - - @Override - public TranscodingService onTranscodeBegin(Consumer listener) { - publisher - .ofType(TranscodeBeginEvent.class) - .observeOn(Schedulers.computation()) - .subscribe(listener::accept); - return this; - } - - private String getPropertyOrDefault(Profile profile, String key, String defaultValue) { - return profile.getFields().getOrDefault(key, defaultValue); - } - - String replaceOutput(String s, Path path) { - return s.replace(OUTPUT_PLACEHOLDER, path.toString()); - } - - String replaceInput(String s, Path path) { - return s.replace(INPUT_PLACEHOLDER, transcoderConfig.base_input_dir().resolve(path).toString()); - } - -} diff --git a/clustercode.impl.transcode/src/test/java/clustercode/impl/transcode/TranscodingServiceImplTest.java b/clustercode.impl.transcode/src/test/java/clustercode/impl/transcode/TranscodingServiceImplTest.java deleted file mode 100644 index 8788b6b6..00000000 --- a/clustercode.impl.transcode/src/test/java/clustercode/impl/transcode/TranscodingServiceImplTest.java +++ /dev/null @@ -1,145 +0,0 @@ -package clustercode.impl.transcode; - -import clustercode.api.domain.Media; -import clustercode.api.domain.Profile; -import clustercode.api.domain.TranscodeTask; -import clustercode.api.process.ExternalProcessService; -import clustercode.api.process.ProcessConfiguration; -import clustercode.api.process.RunningExternalProcess; -import clustercode.test.util.CompletableUnitTest; -import clustercode.test.util.FileBasedUnitTest; -import io.reactivex.Single; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.Spy; - -import java.io.IOException; -import java.nio.file.Path; -import java.time.Duration; -import java.util.Arrays; -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Matchers.any; -import static org.mockito.Mockito.when; - -public class TranscodingServiceImplTest implements FileBasedUnitTest, CompletableUnitTest { - - @Mock - private ExternalProcessService process; - @Mock - private RunningExternalProcess runningProcessMock; - @Mock - private TranscoderConfig transcoderConfig; - - @Spy - private Media media; - @Spy - private Profile profile; - @Spy - private TranscodeTask task; - - private TranscodingServiceImpl subject; - - private ProcessConfiguration processConfiguration; - - @BeforeEach - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - setupFileSystem(); - - processConfiguration = ProcessConfiguration.builder() - .executable(getPath("mock")) - .build(); - - when(media.getSourcePath()).thenReturn(getPath("0", "video.mkv")); - when(profile.getFields()).thenReturn(Collections.singletonMap("FORMAT", ".mp4")); - when(transcoderConfig.temporary_dir()).thenReturn(getPath("tmp")); - when(transcoderConfig.base_input_dir()).thenReturn(getPath("root")); - when(profile.getArguments()).thenReturn(Collections.emptyList()); - - task.setMedia(media); - task.setProfile(profile); - - subject = new TranscodingServiceImpl( - transcoderConfig - ); - } - - @Test - public void replaceOutput_ShouldReplaceOutput_WithNewValue() throws Exception { - - Path output = getPath("tmp", "video.mp4"); - when(profile.getArguments()).thenReturn(Collections.singletonList(TranscodingServiceImpl.OUTPUT_PLACEHOLDER)); - - String result = subject.replaceOutput( - TranscodingServiceImpl.OUTPUT_PLACEHOLDER, - output - ); - - assertThat(result).isEqualTo(output.toString()); - } - - @Test - public void replaceInput_ShouldReplaceInput_WithBasePath() throws Exception { - Path input = getPath("0", "video.mkv"); - when(profile.getArguments()).thenReturn(Arrays.asList()); - - String result = subject.replaceInput( - TranscodingServiceImpl.INPUT_PLACEHOLDER, - input); - - assertThat(result).isEqualTo(transcoderConfig.base_input_dir().resolve(input).toString()); - } - - @Test - public void transcode_ShouldFireEvent_IfTranscodingFailed() { - Assertions.assertTimeoutPreemptively(Duration.ofMillis(1000), () -> { - when(process.start(any(), any())).thenReturn(Single.just(1)); - - subject.onTranscodeFinished() - .subscribe(result -> { - assertThat(result.isSuccessful()).isFalse(); - completeOne(); - }); - subject.transcode(task); - - waitForCompletion(); - }); - } - - @Test - public void transcode_ShouldFireEvent_IfTranscodingFailed_OnException() { - Assertions.assertTimeoutPreemptively(Duration.ofMillis(1000), () -> { - when(process.start(any(), any())).thenReturn(Single.error(IOException::new)); - - subject.onTranscodeFinished() - .subscribe(result -> { - assertThat(result.isSuccessful()).isFalse(); - completeOne(); - }); - subject.transcode(task); - - waitForCompletion(); - }); - } - - @Test - public void transcode_ShouldFireEvent_IfTranscodingBegins() { - Assertions.assertTimeoutPreemptively(Duration.ofMillis(1000), () -> { - when(process.start(any(), any())).thenReturn(Single.just(0)); - - subject.onTranscodeBegin() - .subscribe(result -> { - assertThat(result.getTask()).isEqualTo(task); - completeOne(); - }); - subject.transcode(task); - - waitForCompletion(); - }); - } -} diff --git a/clustercode.impl.transcode/src/test/resources/log4j2.xml b/clustercode.impl.transcode/src/test/resources/log4j2.xml deleted file mode 100644 index 6c141605..00000000 --- a/clustercode.impl.transcode/src/test/resources/log4j2.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/clustercode.impl.util/build.gradle b/clustercode.impl.util/build.gradle deleted file mode 100644 index 78ced7ff..00000000 --- a/clustercode.impl.util/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -version '1.0.0' - -dependencies { - compile "${dep_guice}" -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/FileUtil.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/FileUtil.java deleted file mode 100644 index e4471f76..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/FileUtil.java +++ /dev/null @@ -1,123 +0,0 @@ -package clustercode.impl.util; - -import lombok.extern.slf4j.XSlf4j; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.attribute.FileAttribute; -import java.time.format.DateTimeFormatter; -import java.time.temporal.TemporalAccessor; - -@XSlf4j -public class FileUtil { - - /** - * Gets the file name of the given path without the extension. The extension begins at the last occurrence of ".". - * Returns the base name if there is no extension or the file begins with a dot ("."). - * - * @param path the path. - * @return the base file name (without directory path and leading "."). - * @throws NullPointerException if the path has zero elements or is null. - * @see Path#getFileName() - */ - public static String getFileNameWithoutExtension(Path path) { - String name = path.getFileName().toString(); - int index = name.lastIndexOf('.'); - if (index <= 0) { - return name; - } else { - return name.substring(0, index); - } - } - - /** - * Gets the file extension of the given path. The extension begins at the last occurrence of ".". Returns an empty - * string if the file does not contain a dot (".") or begins with one (hidden file in unix). - * - * @param path the path. - * @return the extension, with leading dot ("."). - * @throws NullPointerException if path is null or has zero elements. - * @see Path#getFileName() - */ - public static String getFileExtension(Path path) { - String ext = path.getFileName().toString(); - int index = ext.lastIndexOf('.'); - if (index <= 0) { - return ""; - } else { - return ext.substring(index, ext.length()); - } - } - - /** - * Creates a timestamped path from the given base file. Example: "/path/basefile.ext" becomes - * "/path/basefile.2017-01-23.14-34-20.ext" if the formatter is of pattern "yyyy-MM-dd.HH-mm-ss". - * - * @param baseFile the base name. - * @param time the timestamp to format. - * @param formatter the formatter for the timestamp. - * @return a new path which has a timestamp in its filename. - * @throws java.time.DateTimeException if the time cannot be formatted. - */ - public static Path getTimestampedPath(Path baseFile, TemporalAccessor time, DateTimeFormatter formatter) { - return getTimestampedPath(baseFile, formatter.format(time)); - } - - /** - * Creates a timestamped path from the given base file. Example: "/path/basefile.ext" becomes - * "/path/basefile.2017-01-23.14-34-20.ext" if the given timestamp equals to "2017-01-23.14-34-20". - * - * @param baseFile the base name. - * @param formattedTimestamp the pre-formatted timestamp. A leading dot will be added automatically. - * @return a new path which has a timestamp in its filename. - * @throws java.nio.file.InvalidPathException if the path cannot be constructed from the given string. - */ - public static Path getTimestampedPath(Path baseFile, String formattedTimestamp) { - String ext = getFileExtension(baseFile); - String timestamp = ".".concat(formattedTimestamp); - return baseFile.resolveSibling(getFileNameWithoutExtension(baseFile) - .concat(timestamp).concat(ext)); - } - - /** - * Creates the parent directories for the given path. - * - * @param target the target path. Must not be a root path. - * @throws RuntimeException if the dirs could not be created. - * @see Path#getParent() - */ - public static void createParentDirectoriesFor(Path target) { - createDirectoriesFor(target.getParent()); - } - - /** - * Creates the parent directories for the given path. - * - * @param target the target path. - * @throws RuntimeException if the dirs could not be created. - * @see Files#createDirectories(Path, FileAttribute[]) - */ - public static void createDirectoriesFor(Path target) { - try { - log.debug("Creating directories for {}", target); - Files.createDirectories(target); - } catch (IOException e) { - log.error("Could not create directories for {}: {}", target, e); - throw new RuntimeException(e); - } - } - - /** - * Deletes the given file. If an IOException occurs, a warning will be logged. - * - * @param file - */ - public static void deleteFile(Path file) { - try { - Files.delete(file); - } catch (IOException e) { - log.warn("Could not delete file:", e); - } - } -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/FilesystemProvider.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/FilesystemProvider.java deleted file mode 100644 index 442bdb68..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/FilesystemProvider.java +++ /dev/null @@ -1,35 +0,0 @@ -package clustercode.impl.util; - -import lombok.Synchronized; - -import java.nio.file.FileSystem; -import java.nio.file.FileSystems; - -public class FilesystemProvider { - - private static FileSystem fs = FileSystems.getDefault(); - - private FilesystemProvider() { - - } - - /** - * Gets the file system. - * - * @return - */ - @Synchronized - public static FileSystem getInstance() { - return fs; - } - - /** - * Sets the file system. This method is meant for testing and faking another file system. - * - * @param system - */ - @Synchronized - public static void setFileSystem(FileSystem system) { - fs = system; - } -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/InvalidConfigurationException.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/InvalidConfigurationException.java deleted file mode 100644 index 4429455d..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/InvalidConfigurationException.java +++ /dev/null @@ -1,31 +0,0 @@ -package clustercode.impl.util; - -import org.slf4j.helpers.MessageFormatter; - -public class InvalidConfigurationException extends RuntimeException { - - public InvalidConfigurationException() { - super(); - } - - public InvalidConfigurationException(String message) { - super(message); - } - - public InvalidConfigurationException(String message, Throwable cause) { - super(message, cause); - } - - public InvalidConfigurationException(String formatString, Object... args) { - super(MessageFormatter.arrayFormat(formatString, args).getMessage()); - } - - public InvalidConfigurationException(String formatString, Throwable cause, Object... args) { - super(MessageFormatter.arrayFormat(formatString, args).getMessage(), cause); - } - - public InvalidConfigurationException(Throwable cause) { - super(cause); - } - -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/OptionalFunction.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/OptionalFunction.java deleted file mode 100644 index 6993a64e..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/OptionalFunction.java +++ /dev/null @@ -1,48 +0,0 @@ -package clustercode.impl.util; - -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Function; - -@FunctionalInterface -public interface OptionalFunction extends Function> { - - /** - * Creates function that always returns an empty optional. - * - * @param - * @param - * @return a new function. - */ - static OptionalFunction empty() { - return arg -> Optional.empty(); - } - - /** - * Creates function that returns an empty optional. This is used to consume the parameter without returning a value. - * - * @param consumer - * @param - * @param - * @return a new function. - */ - static OptionalFunction empty(Consumer consumer) { - return arg -> { - consumer.accept(arg); - return Optional.empty(); - }; - } - - /** - * Creates a function that wraps the return value of the given function in an Optional. The given function may - * return null. - * - * @param function - * @param - * @param - * @return a new function. - */ - static OptionalFunction ofNullable(Function function) { - return arg -> Optional.ofNullable(function.apply(arg)); - } -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/Platform.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/Platform.java deleted file mode 100644 index 13efde17..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/Platform.java +++ /dev/null @@ -1,33 +0,0 @@ -package clustercode.impl.util; - -import lombok.Synchronized; - -import java.util.Locale; - -public enum Platform { - - WINDOWS, - MAC, - LINUX, - OTHER; - - private static Platform detectedOS; - - @Synchronized - public static Platform currentPlatform() { - if (detectedOS == null) { - String OS = System.getProperty("os.name", "generic").toLowerCase(Locale.ENGLISH); - if ((OS.contains("mac")) || (OS.contains("darwin"))) { - detectedOS = Platform.MAC; - } else if (OS.contains("win")) { - detectedOS = Platform.WINDOWS; - } else if (OS.contains("nux")) { - detectedOS = Platform.LINUX; - } else { - detectedOS = Platform.OTHER; - } - } - return detectedOS; - } - -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/PredicateUtil.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/PredicateUtil.java deleted file mode 100644 index 0818a042..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/PredicateUtil.java +++ /dev/null @@ -1,15 +0,0 @@ -package clustercode.impl.util; - -import java.util.function.Predicate; - -public final class PredicateUtil { - - private PredicateUtil() { - // Prevent Instantiation - } - - public static Predicate not(Predicate predicate) { - return predicate.negate(); - } - -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/UnsafeCastUtil.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/UnsafeCastUtil.java deleted file mode 100644 index 189a9ec3..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/UnsafeCastUtil.java +++ /dev/null @@ -1,23 +0,0 @@ -package clustercode.impl.util; - -/** - * - */ -public class UnsafeCastUtil { - - private UnsafeCastUtil() { - /* not instantiable */ - } - - /** - * Warning! Using this method is a sin against the gods of programming! - * - * @param - * @param o - * @return the casted object - */ - @SuppressWarnings("unchecked") - public static T cast(Object o) { - return (T) o; - } -} diff --git a/clustercode.impl.util/src/main/java/clustercode/impl/util/di/ModuleHelper.java b/clustercode.impl.util/src/main/java/clustercode/impl/util/di/ModuleHelper.java deleted file mode 100644 index 753a5162..00000000 --- a/clustercode.impl.util/src/main/java/clustercode/impl/util/di/ModuleHelper.java +++ /dev/null @@ -1,73 +0,0 @@ -package clustercode.impl.util.di; - -import clustercode.impl.util.InvalidConfigurationException; - -import java.util.List; - -public class ModuleHelper { - - private ModuleHelper() { - } - - public static StrategiesCheckerIntermediate verifyIn(List strategies) { - return new StrategiesCheckerIntermediate<>(strategies); - } - - public static class StrategiesCheckerIntermediate { - - private final List strategies; - - private StrategiesCheckerIntermediate(List strategies) { - this.strategies = strategies; - } - - public StrategiesChecker that(E value1) { - return new StrategiesChecker<>(value1, strategies); - } - } - - public static class StrategiesChecker { - - private final E value1; - private final List strategies; - - private StrategiesChecker(E value1, List strategies) { - this.value1 = value1; - this.strategies = strategies; - } - - /** - * Checks if value1 is before value2 in the previously given {@code strategies} list. If it is not, an {@link - * InvalidConfigurationException} is being thrown. Only checks if both values are present. It is assumed that - * the - * provided values are distinguishable (and not e.g. substring of each other). - * - * @param value2 the second value1. - */ - public void isBefore(E value2) { - if (strategies.contains(value1) && strategies.contains(value2)) { - int index1 = strategies.indexOf(value1); - int index2 = strategies.indexOf(value2); - if (index1 >= index2) { - throw new InvalidConfigurationException( - "{} cannot be specified before {}. You configured: {}", - value2, value1, strategies); - } - } - } - - /** - * Checks if value1 AND value2 are in the previously given {@code strategies} list. If they are, an {@link - * InvalidConfigurationException} is being thrown. - * - * @param value2 the second value. - */ - public void isNotGivenTogetherWith(E value2) { - if (strategies.contains(value1) && strategies.contains(value2)) { - throw new InvalidConfigurationException( - "Config cannot contain {} and {} at the same time as they are incompatible. You configured: {}", - value1, value2, strategies); - } - } - } -} diff --git a/clustercode.main/build.gradle b/clustercode.main/build.gradle deleted file mode 100644 index f9eee2e6..00000000 --- a/clustercode.main/build.gradle +++ /dev/null @@ -1,12 +0,0 @@ -version '1.4.0' - -dependencies { - compile project(":${proj_impl_cluster_jgroups}") - compile project(":${proj_impl_transcode}") - compile project(":${proj_impl_scan}") - compile project(":${proj_impl_process}") - compile project(":${proj_impl_cleanup}") - compile project(":${proj_impl_constraint}") - compile project(":${proj_api_rest}") - compile "${dep_guice}" -} diff --git a/clustercode.main/src/main/java/clustercode/main/ComponentActivator.java b/clustercode.main/src/main/java/clustercode/main/ComponentActivator.java deleted file mode 100644 index d61ed484..00000000 --- a/clustercode.main/src/main/java/clustercode/main/ComponentActivator.java +++ /dev/null @@ -1,36 +0,0 @@ -package clustercode.main; - -import clustercode.api.domain.Activator; -import clustercode.api.domain.ActivatorContext; - -import javax.inject.Inject; -import java.util.HashSet; -import java.util.Set; - -class ComponentActivator { - - private final Set activators; - private final ActivatorContext context; - - @Inject - ComponentActivator( - Set activators, - ActivatorContext context - ) { - this.context = context; - this.activators = new HashSet<>(activators); - } - - void preActivateServices() { - this.activators.forEach(activator -> activator.preActivate(context)); - } - - void activateServices() { - this.activators.forEach(activator -> activator.activate(context)); - } - - void deactivateServices() { - this.activators.forEach(activator -> activator.deactivate(context)); - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/ConfigurableLegModule.java b/clustercode.main/src/main/java/clustercode/main/ConfigurableLegModule.java deleted file mode 100644 index 032a42d9..00000000 --- a/clustercode.main/src/main/java/clustercode/main/ConfigurableLegModule.java +++ /dev/null @@ -1,10 +0,0 @@ -package clustercode.main; - -import clustercode.api.config.ConfigLoader; -import com.google.inject.Module; - -public interface ConfigurableLegModule extends Module { - - ConfigurableLegModule setConfig(ConfigLoader loader); - -} diff --git a/clustercode.main/src/main/java/clustercode/main/GuiceManager.java b/clustercode.main/src/main/java/clustercode/main/GuiceManager.java deleted file mode 100644 index 1cd9126d..00000000 --- a/clustercode.main/src/main/java/clustercode/main/GuiceManager.java +++ /dev/null @@ -1,74 +0,0 @@ -package clustercode.main; - -import clustercode.api.config.ConfigLoader; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.messages.StartupCompletedEvent; -import clustercode.main.modules.*; -import com.google.inject.Guice; -import com.google.inject.Injector; -import com.google.inject.Module; -import lombok.extern.slf4j.XSlf4j; - -import java.io.IOException; -import java.io.InputStream; -import java.util.LinkedList; -import java.util.List; -import java.util.Optional; -import java.util.jar.Manifest; - -@XSlf4j -public class GuiceManager { - - private Injector injector; - private final List modules; - - public GuiceManager(ConfigLoader loader) { - log.debug("Creating guice modules..."); - modules = new LinkedList<>(); - - modules.add(new GlobalModule()); - modules.add(new CleanupModule(loader)); - modules.add(new ClusterModule(loader)); - modules.add(new ConstraintModule(loader)); - modules.add(new ProcessModule()); - modules.add(new ScanModule(loader)); - modules.add(new TranscodeModule(loader)); - modules.add(new RestApiModule(loader)); - - } - - void start() { - log.info("Booting clustercode {}...", getApplicationVersion().orElse("unknown")); - injector = Guice.createInjector(modules); - ComponentActivator componentActivator = injector.getInstance(ComponentActivator.class); - log.info("Preparing components..."); - componentActivator.preActivateServices(); - log.info("Activating components..."); - componentActivator.activateServices(); - log.info("Bootup complete."); - injector.getInstance(RxEventBus.class) - .emit(StartupCompletedEvent - .builder() - .mainVersion(getApplicationVersion().orElse("unknown")) - .build()); - } - - public T getInstance(Class clazz) { - return injector.getInstance(clazz); - } - - public static Optional getApplicationVersion() { - InputStream stream = ClassLoader.getSystemResourceAsStream("META-INF/MANIFEST.MF"); - try { - return Optional.ofNullable( - new Manifest(stream) - .getMainAttributes() - .getValue("Implementation-VersionInfo")); - } catch (IOException | NullPointerException e) { - log.catching(e); - } - return Optional.empty(); - } - - -} diff --git a/clustercode.main/src/main/java/clustercode/main/Startup.java b/clustercode.main/src/main/java/clustercode/main/Startup.java deleted file mode 100644 index 4c8dbba1..00000000 --- a/clustercode.main/src/main/java/clustercode/main/Startup.java +++ /dev/null @@ -1,47 +0,0 @@ -package clustercode.main; - -import clustercode.api.config.ConfigLoader; -import org.slf4j.ext.XLogger; -import org.slf4j.ext.XLoggerFactory; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.List; - -public class Startup { - - private static XLogger log; - - public static void main(String[] args) throws Exception { - - List logFiles = Arrays.asList("log4j2-debug.xml", System.getenv("CC_LOG_CONFIG_FILE"), "log4j2.xml"); - - logFiles.forEach(name -> { - if (name == null) return; - if (log != null) return; - Path logFile = Paths.get(name); - if (Files.exists(logFile)) { - System.setProperty("log4j.configurationFile", logFile.toAbsolutePath().toString()); - log = XLoggerFactory.getXLogger(Startup.class); - log.info("Used log config: {}", logFile); - } - }); - log = XLoggerFactory.getXLogger(Startup.class); - - Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { - // Normally the expectable exceptions should be caught, but to debug any unexpected ones we log them. - log.error("Application-wide uncaught exception:", throwable); - System.exit(1); - }); - - log.info("Working dir: {}", new File("").getAbsolutePath()); - String configFile = args.length >= 1 ? args[0] : "config/clustercode.properties"; - ConfigLoader loader = new ConfigLoader().loadDefaultsFromPropertiesFile(configFile); - - new GuiceManager(loader).start(); - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/config/ClusterConfig.java b/clustercode.main/src/main/java/clustercode/main/config/ClusterConfig.java deleted file mode 100644 index 4e7fc0ee..00000000 --- a/clustercode.main/src/main/java/clustercode/main/config/ClusterConfig.java +++ /dev/null @@ -1,12 +0,0 @@ -package clustercode.main.config; - -import clustercode.main.modules.ClusterType; -import org.aeonbits.owner.Config; - -public interface ClusterConfig extends Config { - - @Key("CC_CLUSTER_TYPE") - @DefaultValue("JGROUPS") - ClusterType cluster_type(); - -} diff --git a/clustercode.main/src/main/java/clustercode/main/config/ScanConfig.java b/clustercode.main/src/main/java/clustercode/main/config/ScanConfig.java deleted file mode 100644 index 565f180b..00000000 --- a/clustercode.main/src/main/java/clustercode/main/config/ScanConfig.java +++ /dev/null @@ -1,7 +0,0 @@ -package clustercode.main.config; - -public class ScanConfig { - - - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/CleanupModule.java b/clustercode.main/src/main/java/clustercode/main/modules/CleanupModule.java deleted file mode 100644 index 9376838b..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/CleanupModule.java +++ /dev/null @@ -1,64 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.cleanup.CleanupProcessor; -import clustercode.api.cleanup.CleanupService; -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Activator; -import clustercode.impl.cleanup.CleanupActivator; -import clustercode.impl.cleanup.CleanupConfig; -import clustercode.impl.cleanup.CleanupServiceImpl; -import clustercode.impl.cleanup.processor.*; -import clustercode.impl.util.di.ModuleHelper; -import com.google.inject.Singleton; -import com.google.inject.multibindings.MapBinder; -import com.google.inject.multibindings.Multibinder; - -import java.util.HashMap; -import java.util.Map; - -public class CleanupModule extends ConfigurableModule { - - public CleanupModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - CleanupConfig config = loader.getConfig(CleanupConfig.class); - bind(CleanupConfig.class).toInstance(config); - - bind(CleanupService.class).to(CleanupServiceImpl.class); - - try { - ModuleHelper.verifyIn(config.cleanup_processors()) - .that(CleanupProcessors.STRUCTURED_OUTPUT) - .isNotGivenTogetherWith(CleanupProcessors.UNIFIED_OUTPUT); - - ModuleHelper.verifyIn(config.cleanup_processors()) - .that(CleanupProcessors.DELETE_SOURCE) - .isNotGivenTogetherWith(CleanupProcessors.MARK_SOURCE); - } catch (IllegalArgumentException ex) { - addError(ex); - } - - MapBinder mapBinder = MapBinder.newMapBinder(binder(), CleanupProcessors - .class, CleanupProcessor.class); - - Map> assignments = createClassAssignments(); - config.cleanup_processors().forEach(entry -> mapBinder.addBinding(entry).to(assignments.get(entry))); - - Multibinder multibinder = Multibinder.newSetBinder(binder(), Activator.class); - multibinder.addBinding().to(CleanupActivator.class).in(Singleton.class); - } - - private Map> createClassAssignments() { - Map> map = new HashMap<>(); - map.put(CleanupProcessors.CHOWN, ChangeOwnerProcessor.class); - map.put(CleanupProcessors.DELETE_SOURCE, DeleteSourceProcessor.class); - map.put(CleanupProcessors.MARK_SOURCE, MarkSourceProcessor.class); - map.put(CleanupProcessors.MARK_SOURCE_DIR, MarkSourceDirProcessor.class); - map.put(CleanupProcessors.STRUCTURED_OUTPUT, StructuredOutputDirectoryProcessor.class); - map.put(CleanupProcessors.UNIFIED_OUTPUT, UnifiedOutputDirectoryProcessor.class); - return map; - } -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ClusterModule.java b/clustercode.main/src/main/java/clustercode/main/modules/ClusterModule.java deleted file mode 100644 index e757b7eb..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ClusterModule.java +++ /dev/null @@ -1,30 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.main.config.ClusterConfig; - -public class ClusterModule extends ConfigurableModule { - - public ClusterModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - ClusterConfig base_config = loader.getConfig(ClusterConfig.class); - - try { - // I don't like switches, but here we don't need over engineering. - switch (base_config.cluster_type()) { - case JGROUPS: - install(new JGroupsModule(loader)); - break; - default: - throw new EnumConstantNotPresentException(ClusterType.class, base_config.cluster_type().name()); - } - } catch (UnsupportedOperationException ex) { - throw new InvalidConfigurationException("You configured the CC_CLUSTER_TYPE incorrectly. Consult the docs!"); - } - } -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ClusterType.java b/clustercode.main/src/main/java/clustercode/main/modules/ClusterType.java deleted file mode 100644 index 7abf1af3..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ClusterType.java +++ /dev/null @@ -1,5 +0,0 @@ -package clustercode.main.modules; - -public enum ClusterType { - JGROUPS -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ConfigurableModule.java b/clustercode.main/src/main/java/clustercode/main/modules/ConfigurableModule.java deleted file mode 100644 index cd67bd4b..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ConfigurableModule.java +++ /dev/null @@ -1,14 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import com.google.inject.AbstractModule; - -public abstract class ConfigurableModule extends AbstractModule { - - protected final ConfigLoader loader; - - public ConfigurableModule(ConfigLoader loader) { - this.loader = loader; - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ConstraintModule.java b/clustercode.main/src/main/java/clustercode/main/modules/ConstraintModule.java deleted file mode 100644 index 71209184..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ConstraintModule.java +++ /dev/null @@ -1,53 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Constraint; -import clustercode.impl.constraint.*; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.impl.util.di.ModuleHelper; -import com.google.inject.multibindings.Multibinder; - -import java.util.HashMap; -import java.util.Map; - -public class ConstraintModule extends ConfigurableModule { - - public ConstraintModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - ConstraintConfig config = loader.getConfig(ConstraintConfig.class); - bind(ConstraintConfig.class).toInstance(config); - - var setBinder = Multibinder.newSetBinder(binder(), Constraint.class); - var map = getConstraintMap(); - - try { - ModuleHelper.verifyIn(config.active_constraints()) - .that(Constraints.ALL) - .isNotGivenTogetherWith(Constraints.NONE); - } catch (InvalidConfigurationException ex) { - addError(ex); - } - - if (config.active_constraints().contains(Constraints.ALL)) { - map.forEach((key, value) -> setBinder.addBinding().to(value)); - } else if (config.active_constraints().contains(Constraints.NONE)) { - setBinder.addBinding().to(NoConstraint.class); - } else { - config.active_constraints().forEach(key -> setBinder.addBinding().to(map.get(key))); - } - - } - - private Map> getConstraintMap() { - Map> map = new HashMap<>(); - map.put(Constraints.FILE_NAME, FileNameConstraint.class); - map.put(Constraints.TIME, TimeConstraint.class); - map.put(Constraints.FILE_SIZE, FileSizeConstraint.class); - map.put(Constraints.CLUSTER, ClusterConstraint.class); - return map; - } -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/GlobalModule.java b/clustercode.main/src/main/java/clustercode/main/modules/GlobalModule.java deleted file mode 100644 index e92c2e08..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/GlobalModule.java +++ /dev/null @@ -1,25 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.domain.ActivatorContext; -import clustercode.api.event.RxEventBus; -import clustercode.api.event.RxEventBusImpl; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import com.google.inject.Singleton; - -import java.time.Clock; - -public class GlobalModule extends AbstractModule { - - @Override - protected void configure() { - bind(RxEventBus.class).to(RxEventBusImpl.class).in(Singleton.class); - bind(ActivatorContext.class).toInstance(new ActivatorContext() { - }); - } - - @Provides - private Clock getSystemClock() { - return Clock.systemDefaultZone(); - } -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/JGroupsModule.java b/clustercode.main/src/main/java/clustercode/main/modules/JGroupsModule.java deleted file mode 100644 index ac67ac2b..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/JGroupsModule.java +++ /dev/null @@ -1,28 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.cluster.ClusterService; -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Activator; -import clustercode.impl.cluster.jgroups.JGroupsClusterFacade; -import clustercode.impl.cluster.jgroups.JgroupsClusterActivator; -import clustercode.impl.cluster.jgroups.SingleNodeClusterImpl; -import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; - -class JGroupsModule extends ConfigurableModule { - - JGroupsModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - - bind(ClusterService.class).to(JGroupsClusterFacade.class).in(Singleton.class); - bind(SingleNodeClusterImpl.class).in(Singleton.class); - - Multibinder multibinder = Multibinder.newSetBinder(binder(), Activator.class); - multibinder.addBinding().to(JgroupsClusterActivator.class).in(Singleton.class); - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ProcessModule.java b/clustercode.main/src/main/java/clustercode/main/modules/ProcessModule.java deleted file mode 100644 index e2cdf880..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ProcessModule.java +++ /dev/null @@ -1,35 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.process.ExternalProcessService; -import clustercode.api.process.ScriptInterpreter; -import clustercode.impl.process.AutoResolvableInterpreter; -import clustercode.impl.process.BourneAgainShell; -import clustercode.impl.process.ExternalProcessServiceImpl; -import clustercode.impl.process.Shell; -import clustercode.impl.util.FilesystemProvider; -import clustercode.impl.util.Platform; -import com.google.inject.AbstractModule; - -import java.nio.file.Files; - -public class ProcessModule extends AbstractModule { - - @Override - protected void configure() { - bind(ExternalProcessService.class).to(ExternalProcessServiceImpl.class); - - switch (Platform.currentPlatform()) { - case WINDOWS: - bind(ScriptInterpreter.class).to(AutoResolvableInterpreter.class); - break; - default: - if (Files.exists(FilesystemProvider.getInstance().getPath("/bin", "bash"))) { - bind(ScriptInterpreter.class).to(BourneAgainShell.class); - } else { - bind(ScriptInterpreter.class).to(Shell.class); - } - break; - } - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/RestApiModule.java b/clustercode.main/src/main/java/clustercode/main/modules/RestApiModule.java deleted file mode 100644 index 3ac69078..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/RestApiModule.java +++ /dev/null @@ -1,51 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Activator; -import clustercode.api.rest.v1.RestServiceConfig; -import clustercode.api.rest.v1.RestServicesActivator; -import clustercode.api.rest.v1.hook.ProgressHook; -import clustercode.api.rest.v1.hook.ProgressHookImpl; -import clustercode.api.rest.v1.hook.TaskHook; -import clustercode.api.rest.v1.hook.TaskHookImpl; -import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; -import com.owlike.genson.ext.jaxrs.GensonJsonConverter; -import io.logz.guice.jersey.JerseyModule; -import io.logz.guice.jersey.configuration.JerseyConfiguration; - -public class RestApiModule extends ConfigurableModule { - - public RestApiModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - RestServiceConfig config = loader.getConfig(RestServiceConfig.class); - bind(RestServiceConfig.class).toInstance(config); - - if (config.rest_enabled()) { - installJersey(config.rest_api_port()); - } - - bind(ProgressHook.class).to(ProgressHookImpl.class).in(Singleton.class); - bind(TaskHook.class).to(TaskHookImpl.class).in(Singleton.class); - } - - private void installJersey(int port) { - - JerseyConfiguration configuration = JerseyConfiguration - .builder() - .addPackage("clustercode.api.rest") - .addPort(port) - .withContextPath("/api") - .registerClasses(GensonJsonConverter.class) - .build(); - - install(new JerseyModule(configuration)); - Multibinder multibinder = Multibinder.newSetBinder(binder(), Activator.class); - multibinder.addBinding().to(RestServicesActivator.class).in(Singleton.class); - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/ScanModule.java b/clustercode.main/src/main/java/clustercode/main/modules/ScanModule.java deleted file mode 100644 index 87b558df..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/ScanModule.java +++ /dev/null @@ -1,74 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Activator; -import clustercode.api.scan.*; -import clustercode.impl.scan.*; -import clustercode.impl.scan.matcher.*; -import clustercode.impl.util.InvalidConfigurationException; -import clustercode.impl.util.di.ModuleHelper; -import com.google.inject.Singleton; -import com.google.inject.multibindings.MapBinder; -import com.google.inject.multibindings.Multibinder; - -import java.util.HashMap; -import java.util.Map; - -/** - * Provides a guice module for configuring the scanning features. - */ -public class ScanModule extends ConfigurableModule { - - public ScanModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - var mediaScanConfig = loader.getConfig(MediaScanConfig.class); - checkInterval(mediaScanConfig.media_scan_interval()); - - ProfileScanConfig profileScanConfig = loader.getConfig(ProfileScanConfig.class); - bind(ProfileScanConfig.class).toInstance(profileScanConfig); - - bind(MediaScanConfig.class).toInstance(mediaScanConfig); - - bind(FileScanner.class).to(FileScannerImpl.class); - bind(MediaScanService.class).to(MediaScanServiceImpl.class); - - bind(SelectionService.class).to(SelectionServiceImpl.class); - - bind(ProfileScanService.class).to(ProfileScanServiceImpl.class); - bind(ProfileParser.class).to(ProfileParserImpl.class); - - ModuleHelper.verifyIn(profileScanConfig.profile_matchers()) - .that(ProfileMatchers.COMPANION) - .isBefore(ProfileMatchers.DEFAULT); - - ModuleHelper.verifyIn(profileScanConfig.profile_matchers()) - .that(ProfileMatchers.DIRECTORY_STRUCTURE) - .isBefore(ProfileMatchers.DEFAULT); - - var mapBinder = MapBinder.newMapBinder(binder(), ProfileMatchers.class, ProfileMatcher.class); - var map = getMatcherMap(); - profileScanConfig.profile_matchers().forEach(matcher -> mapBinder.addBinding(matcher).to(map.get(matcher))); - - var multibinder = Multibinder.newSetBinder(binder(), Activator.class); - multibinder.addBinding().to(ScanServicesActivator.class).in(Singleton.class); - } - - private void checkInterval(long scanInterval) { - if (scanInterval < 1) { - throw new InvalidConfigurationException("The scan interval must be >= 1."); - } - } - - private Map> getMatcherMap() { - Map> map = new HashMap<>(); - map.put(ProfileMatchers.COMPANION, CompanionProfileMatcher.class); - map.put(ProfileMatchers.DEFAULT, DefaultProfileMatcher.class); - map.put(ProfileMatchers.DIRECTORY_STRUCTURE, DirectoryStructureMatcher.class); - return map; - } - -} diff --git a/clustercode.main/src/main/java/clustercode/main/modules/TranscodeModule.java b/clustercode.main/src/main/java/clustercode/main/modules/TranscodeModule.java deleted file mode 100644 index 11fcb9fc..00000000 --- a/clustercode.main/src/main/java/clustercode/main/modules/TranscodeModule.java +++ /dev/null @@ -1,29 +0,0 @@ -package clustercode.main.modules; - -import clustercode.api.config.ConfigLoader; -import clustercode.api.domain.Activator; -import clustercode.api.transcode.TranscodingService; -import clustercode.impl.transcode.TranscodeActivator; -import clustercode.impl.transcode.TranscoderConfig; -import clustercode.impl.transcode.TranscodingServiceImpl; -import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; - -public class TranscodeModule extends ConfigurableModule { - - public TranscodeModule(ConfigLoader loader) { - super(loader); - } - - @Override - protected void configure() { - var config = loader.getConfig(TranscoderConfig.class); - bind(TranscoderConfig.class).toInstance(config); - - bind(TranscodingService.class).to(TranscodingServiceImpl.class).in(Singleton.class); - Multibinder multibinder = Multibinder.newSetBinder(binder(), Activator.class); - multibinder.addBinding().to(TranscodeActivator.class).in(Singleton.class); - - } - -} diff --git a/clustercode.main/src/main/resources/log4j2.xml b/clustercode.main/src/main/resources/log4j2.xml deleted file mode 100644 index 994fdbac..00000000 --- a/clustercode.main/src/main/resources/log4j2.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/clustercode.main/src/test/resources/log4j2-debug.xml b/clustercode.main/src/test/resources/log4j2-debug.xml deleted file mode 100644 index d25f2221..00000000 --- a/clustercode.main/src/test/resources/log4j2-debug.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/clustercode.test.integration/build.gradle b/clustercode.test.integration/build.gradle deleted file mode 100644 index ba33ab60..00000000 --- a/clustercode.test.integration/build.gradle +++ /dev/null @@ -1,32 +0,0 @@ -version '2.0.0' - -dependencies { - testCompile project(":${proj_test_util}") - - testCompile project(":${proj_test_util}").sourceSets.test.output - testCompile "${dep_testcontainers}" - testCompile "${dep_testcontainers_junit}" - - testCompile "org.glassfish.jersey.core:jersey-client:2.27" - testCompile project(":${proj_main}") -} - -test { - //systemProperty 'log4j.configurationFile', './clustercode.test.integration/src/test/resources/log4j2.xml' -} - -def createBlankVideo(int seconds) { - exec { - executable "ffmpeg" - args "-hide_banner", "-y", "-t", seconds, "-f", "rawvideo", - "-pix_fmt", "rgb24", "-r", "25", "-s", "640x480", "-i", "/dev/zero", - "${projectDir}/input/0/" + seconds + "s_blank.mp4" - } -} - -task doIt { - doLast { - createBlankVideo(60) - createBlankVideo(120) - } -} diff --git a/clustercode.test.integration/input/.gitignore b/clustercode.test.integration/input/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/clustercode.test.integration/input/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/clustercode.test.integration/output/.gitignore b/clustercode.test.integration/output/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/clustercode.test.integration/output/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/clustercode.test.integration/profiles/default.ffmpeg b/clustercode.test.integration/profiles/default.ffmpeg deleted file mode 100644 index 1318b4f7..00000000 --- a/clustercode.test.integration/profiles/default.ffmpeg +++ /dev/null @@ -1,23 +0,0 @@ -# This is a ffmpeg template file which encodes in x264 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. - --hide_banner -# Force yes when overwriting files: --y - -# This option is necessary! FFMPEG has input and output options so the order of the option matters. -# Therefore you have to specify the input in this file. --i ${INPUT} - -# copy metadata -#-map_metadata 0 - -# use x264 encoder --c:v libx264 --preset medium -# set parameter for libx264 --crf 24 - -${OUTPUT} -# Specify format (clustercode specific) -%{FORMAT=.mkv} diff --git a/clustercode.test.integration/src/test/java/clustercode/test/integration/AbstractDockerTestBase.java b/clustercode.test.integration/src/test/java/clustercode/test/integration/AbstractDockerTestBase.java deleted file mode 100644 index 27fb9961..00000000 --- a/clustercode.test.integration/src/test/java/clustercode/test/integration/AbstractDockerTestBase.java +++ /dev/null @@ -1,33 +0,0 @@ -package clustercode.test.integration; - -import lombok.extern.slf4j.Slf4j; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.output.Slf4jLogConsumer; - -import java.nio.file.Path; -import java.nio.file.Paths; - -@Slf4j -@SuppressWarnings("unchecked") -public abstract class AbstractDockerTestBase { - - protected Path createBlankVideo(int durationInSeconds) { - log.debug("Starting ffmpeg instance1 and create blank video"); - Path target = Paths.get("input", "0", "blank_video_" + durationInSeconds + ".mp4"); - var ffmpeg = (GenericContainer) new GenericContainer("jrottenberg/ffmpeg:3.4-alpine") - .withCommand( - "-hide_banner", "-y", - "-t", String.valueOf(durationInSeconds), - "-f", "rawvideo", - "-pix_fmt", "rgb24", - "-r", String.valueOf(25), - "-s", "640x480", - "-i", "/dev/zero", - "/" + target.toString()) - .withFileSystemBind("./input", "/input"); - ffmpeg.start(); - ffmpeg.followOutput(new Slf4jLogConsumer(log)); - log.debug("Startup of Ffmpeg instance1 complete"); - return target; - } -} diff --git a/clustercode.test.integration/src/test/java/clustercode/test/integration/ClustercodeContainer.java b/clustercode.test.integration/src/test/java/clustercode/test/integration/ClustercodeContainer.java deleted file mode 100644 index 347d8aae..00000000 --- a/clustercode.test.integration/src/test/java/clustercode/test/integration/ClustercodeContainer.java +++ /dev/null @@ -1,56 +0,0 @@ -package clustercode.test.integration; - -import org.slf4j.Logger; -import org.testcontainers.containers.BindMode; -import org.testcontainers.containers.output.Slf4jLogConsumer; -import org.testcontainers.containers.output.WaitingConsumer; -import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; - -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.function.Predicate; - -@SuppressWarnings("unchecked") -public class ClustercodeContainer extends HttpContainer { - - public ClustercodeContainer(int port) { - super("braindoctor/clustercode:amd64", port); - this - .withExposedPorts(port) - .waitingFor(new HttpWaitStrategy() - .forPath("/api/v1/version") - .forPort(port)) - .withEnv("CC_CONSTRAINTS_ACTIVE", "NONE") - .withEnv("LOG_LEVEL_CORE", "debug") - .withFileSystemBind("/etc/localtime", "/etc/localtime", BindMode.READ_ONLY) - .withFileSystemBind("./input", "/input") - .withFileSystemBind("./output", "/output") - .withFileSystemBind("./profiles", "/profiles"); - } - - protected final ClustercodeContainer withOutputLogger(Logger logger) { - this.withLogConsumer(new Slf4jLogConsumer(logger)); - return this; - } - - protected final void waitUntil(Predicate predicate) throws TimeoutException { - WaitingConsumer waitingConsumer = new WaitingConsumer(); - this.followOutput(waitingConsumer); - waitingConsumer.waitUntil(frame -> predicate.test(frame.getUtf8String()), 30, TimeUnit.SECONDS); - } - - protected final void waitUntilLineStartsWith(String beginning) throws TimeoutException { - waitUntil(line -> { - // take out the timestamp first. - if (line.length() > 38) { - return line.substring(38, line.length() - 1).startsWith(beginning); - } else { - return false; - } - }); - } - - protected final void waitUntilLineContains(String snippet) throws TimeoutException { - waitUntil(line -> line.contains(snippet)); - } -} diff --git a/clustercode.test.integration/src/test/java/clustercode/test/integration/HttpContainer.java b/clustercode.test.integration/src/test/java/clustercode/test/integration/HttpContainer.java deleted file mode 100644 index dd4ced81..00000000 --- a/clustercode.test.integration/src/test/java/clustercode/test/integration/HttpContainer.java +++ /dev/null @@ -1,67 +0,0 @@ -package clustercode.test.integration; - -import org.glassfish.jersey.client.JerseyClient; -import org.glassfish.jersey.client.JerseyClientBuilder; -import org.testcontainers.containers.GenericContainer; - -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; - -@SuppressWarnings("unchecked") -public class HttpContainer extends GenericContainer { - - private JerseyClient client; - private int port; - private MediaType defaultMediaType = MediaType.APPLICATION_JSON_TYPE; - - public HttpContainer(String dockerImageName, int port) { - super(dockerImageName); - this.port = port; - } - - public T httpGet(String uri, Class entity) { - String url = buildUrl(uri); - logger().debug("Perform http request for {}", url); - return getClient().target(url) - .request(defaultMediaType) - .get(entity); - } - - public Response httpGet(String uri) { - String url = buildUrl(uri); - logger().debug("Perform http request for {}", url); - return getClient().target(url) - .request(defaultMediaType) - .get(); - } - - public T httpPost(String uri, Class responseType, Entity entity) { - String url = buildUrl(uri); - logger().debug("Perform http request for {}", url); - return getClient().target(url) - .request(defaultMediaType) - .post(entity, responseType); - } - - public Response httpPost(String uri, Entity entity) { - String url = buildUrl(uri); - logger().debug("Perform http request for {}", url); - return getClient().target(url) - .request(defaultMediaType) - .post(entity); - } - - public JerseyClient getClient() { - if (client == null) client = JerseyClientBuilder.createClient(); - return client; - } - - public void setMediaType(MediaType mediaType) { - this.defaultMediaType = mediaType; - } - - private String buildUrl(String uri) { - return String.format("http://%1$s:%2$s%3$s", getContainerIpAddress(), getMappedPort(this.port), uri); - } -} diff --git a/clustercode.test.integration/src/test/java/clustercode/test/integration/RunOnceWaitStrategy.java b/clustercode.test.integration/src/test/java/clustercode/test/integration/RunOnceWaitStrategy.java deleted file mode 100644 index 257ebc5b..00000000 --- a/clustercode.test.integration/src/test/java/clustercode/test/integration/RunOnceWaitStrategy.java +++ /dev/null @@ -1,36 +0,0 @@ -package clustercode.test.integration; - -import org.rnorth.ducttape.TimeoutException; -import org.rnorth.ducttape.unreliables.Unreliables; -import org.testcontainers.containers.ContainerLaunchException; -import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; - -import java.time.Duration; -import java.util.concurrent.TimeUnit; - -public class RunOnceWaitStrategy extends AbstractWaitStrategy { - - public RunOnceWaitStrategy() { - this.withStartupTimeout(Duration.ofHours(1)); - } - - @Override - protected void waitUntilReady() { - - try { - Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, - () -> getRateLimiter().getWhenReady(() -> waitStrategyTarget.isRunning())); - - } catch (TimeoutException ex) { - throw new ContainerLaunchException("Timed out waiting for container to be created."); - } - - try { - Unreliables.retryUntilTrue((int) startupTimeout.getSeconds(), TimeUnit.SECONDS, - () -> getRateLimiter().getWhenReady(() -> !waitStrategyTarget.isRunning() && waitStrategyTarget.getCurrentContainerInfo().getState().getExitCode() == 0)); - } catch (TimeoutException ex) { - throw new ContainerLaunchException("Timed out waiting for container to be created."); - } - - } -} diff --git a/clustercode.test.integration/src/test/java/clustercode/test/integration/TaskListIT.java b/clustercode.test.integration/src/test/java/clustercode/test/integration/TaskListIT.java deleted file mode 100644 index 2163d184..00000000 --- a/clustercode.test.integration/src/test/java/clustercode/test/integration/TaskListIT.java +++ /dev/null @@ -1,49 +0,0 @@ -package clustercode.test.integration; - -import clustercode.api.rest.v1.dto.Task; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import javax.ws.rs.core.GenericType; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.List; -import java.util.concurrent.TimeoutException; - -import static org.assertj.core.api.Assertions.assertThat; - -@Slf4j -@Testcontainers -public class TaskListIT { - - @Container - private static ClustercodeContainer instance1 = new ClustercodeContainer(7700) - .withOutputLogger(log); - - @BeforeAll - public static void setup() throws IOException { - var target = Paths.get("input", "0", "120s_blank.mp4"); - var doneFile = target.resolveSibling("120s_blank.mp4.done"); - if (Files.exists(doneFile)) Files.delete(doneFile); - } - - @Test - //@Disabled("not working atm") - public void taskList_ShouldBeEmpty_WhenNoneFound() throws TimeoutException, InterruptedException { - log.info("Waiting..."); - instance1.waitUntilLineStartsWith("ExternalProcess - Invoking: [/usr/bin/ffmpeg,"); - - log.info("Assert"); - //Thread.sleep(5000); - var result = instance1.httpGet("/api/v1/tasks").readEntity(new GenericType>() { - }); - //Thread.sleep(5000); - assertThat(result) - .isNotEmpty(); - } - -} diff --git a/clustercode.test.integration/src/test/resources/blank_video.mp4 b/clustercode.test.integration/src/test/resources/blank_video.mp4 deleted file mode 100644 index dd68b1856b4982be44e5d9b2c4e1e8528dffe80d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3729 zcmeH~U1%It6oBvM&o=#`KhajH<%&@XW;ZjNG>IugvkAe7m8!))6k)hKb9ZOV%+6%y zCfV(SRIQ;e3O*RHfn5-#wu%p8Z6B0W?TbDs^udbwS1gu-tssh~WIbo@ZaTXb{CV)j zUNUpeJ@?%2o_p@unF%3;=0wT2L(e6og`k8h&1MYM_4`#q$f{hxIU%>4b4@0|r_#}0 ztuA~ye(C*-=O<3h_k2#z-~Hp2VtSyTCaK{CoT@w1piZQ~)cf!w;}0bJX=eWzjG5ek zficf7IXow5T2cCvX+<3bqa}nt+}k@lJDZxZP3}3Ykn)0jFLG0sa2@dRd|`WqaF`k_ z%M4AW0ncfD)Z|&mGbS~4SQ%C*D=?=Na!n~hzo@Dzb-8BoA`Pdrpbb(#EWuQ`>w&4M zDFr6MjkrG4h90m&o@?p=HLSpMnGRD`Dgy2}b_m)~amX};rch(bg}cd_dC)6x zEv@#zia8bv-47>iA6#pIDPQ+;xsVI3p9(90386MX9nYI&7IgHwO{Jk@8xTs}pwL1P zEn(O$6BveF5Ik@gIDtLuOb4u_8=mX4C?Lp?2$)>}1ULo^eR2VFc?h}6>V64y+tkva zGn4rOfLT3jvk(`t0}k6#=`6SNmdJwU`MjX#Js(D;(TAy%yaa45y;G^Fx@#ApOGASf zxG@czR4~kdVMYTUS};8@^p)-6cwlHokfUxE(u&)Hj+X9ADRc@qqbaEYP<;ePk`=Xq z`@s+j?n4f3AI=Es0gez@YzmGa50e~%lHJFT945P}%fDQ^*6HqBT}@V&k1T%gUDy{q@B+UR}NP{M^XWhp89N5u%#ZhT`;Zw&F8MAU%CBm#kecoPs#3~@#xJ{iBY0dc7z zipvd%t4#>qLPe9QR$5%7W$hIGK$J$O^B&@6qT?6QEWogqL{BE4#CDc6P1$< zQJj(p%*^J@!h21KS`=T@5i)_5-x{J=YCwF`gpg5`x7QIein10*ly}DwN4uL4GK%tO z9U-Im%esgkYAuSnCdAFUfS;K>k^ibLyz26?!-RZ=hX}vae^VEA1O^U>zg-ubFCN%$vaaY7bn*Pi>T%R?h%-2@=uA@%V}1$VsUdfp6J9e2huLB?9^4}`2a zO%eqEuD6B`n49_|*FA5fwGh(22ycZ+7<@uZMr*dl9BVXKx4AW~bE9<=BQWV|G_jkT zA_Q$4cS87Pi5d`)+g6LYeb6;+1|!rpoA;gu^E`R{Y&0)vnNARCaKmqtx{h)`eydEd zg6VMdY5NXdS2<9+X50uG54+oxW7|x4C*oviIKFl|aH!PVUI|6k0ewCcVf{eRXAVEe zKw^tCrC|iNjfaaDwm*4DbL_sg3a=cZ0Cge0#|n06b$46mq!v*a##Ej36Ir)`940yR$9`Q&*6KH3lD*J!oQLT TK`7b7W^y|RQ$u|zRZ;!`!e;x; diff --git a/clustercode.test.integration/src/test/resources/log4j2.xml b/clustercode.test.integration/src/test/resources/log4j2.xml deleted file mode 100644 index c80f67f3..00000000 --- a/clustercode.test.integration/src/test/resources/log4j2.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/clustercode.test.util/build.gradle b/clustercode.test.util/build.gradle deleted file mode 100644 index a0d2d71c..00000000 --- a/clustercode.test.util/build.gradle +++ /dev/null @@ -1,5 +0,0 @@ -version '1.0.0' - -dependencies { - compile project(":${proj_impl_util}") -} diff --git a/clustercode.test.util/src/test/java/clustercode/test/util/ClockBasedUnitTest.java b/clustercode.test.util/src/test/java/clustercode/test/util/ClockBasedUnitTest.java deleted file mode 100644 index bb4d53fc..00000000 --- a/clustercode.test.util/src/test/java/clustercode/test/util/ClockBasedUnitTest.java +++ /dev/null @@ -1,159 +0,0 @@ -package clustercode.test.util; - -import java.time.*; -import java.util.Locale; - -public interface ClockBasedUnitTest { - - /** - * Gets a fixed clock which uses the given instant and the system default zone. - * - * @param time the instant. - * @return a fixed clock which always returns the given instant. - */ - default Clock getFixedClock(Instant time) { - return Clock.fixed(time, ZoneId.systemDefault()); - } - - /** - * Gets a fixed clock in {@link ZoneOffset#UTC} with the given date. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @param second the second of minute (0..59) - * @return a fixed clock which always returns the given time. - * @throws java.time.format.DateTimeParseException if the time cannot be parsed using the given parameters. - */ - default Clock getFixedClock(int year, int month, int day, int hour, int minute, int second) { - return getFixedClock(year, month, day, hour, minute, second, ZoneOffset.UTC); - } - - /** - * Gets a fixed clock in {@link ZoneOffset#UTC} with the given date. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @return a fixed clock which always returns the given time. - * @throws java.time.format.DateTimeParseException if the time cannot be parsed using the given parameters. - */ - default Clock getFixedClock(int year, int month, int day, int hour, int minute) { - return getFixedClock(year, month, day, hour, minute, 0, ZoneOffset.UTC); - } - - /** - * Gets a fixed clock in {@link ZoneOffset#UTC} with the given date. The date will be 2017-01-31. - * - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @return a fixed clock which always returns the given time. - * @throws java.time.format.DateTimeParseException if the time cannot be parsed using the given parameters. - */ - default Clock getFixedClock(int hour, int minute) { - return getFixedClock(2017, 1, 31, hour, minute, 0, ZoneOffset.UTC); - } - - /** - * Gets a fixed clock with the given date and zone. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @param second the second of minute (0..59) - * @param zone the zone id. - * @return a fixed clock which always returns the given time. - */ - default Clock getFixedClock(int year, int month, int day, int hour, int minute, int second, ZoneOffset zone) { - return Clock.fixed(getLocalTime(year, month, day, hour, minute, second).toInstant(zone), zone); - } - - /** - * Gets a local zoned date time with the given parameters. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @param second the second of minute (0..59) - * @return the time. - * @throws java.time.format.DateTimeParseException if the instant cannot be created from the given parameters. - */ - default LocalDateTime getLocalTime(int year, int month, int day, int hour, int minute, int second) { - return LocalDateTime.parse(new StringBuilder() - .append(year).append('-').append(preZero(month)).append('-').append(preZero(day)) - .append('T') - .append(preZero(hour)).append(':').append(preZero(minute)).append(':').append(preZero(second)) - .toString()); - } - - /** - * Gets a local zoned date time with the given parameters. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @return the time. - * @throws java.time.format.DateTimeParseException if the instant cannot be created from the given parameters. - */ - default LocalDateTime getLocalTime(int year, int month, int day, int hour, int minute) { - return getLocalTime(year, month, day, hour, minute, 0); - } - - /** - * Gets a local zoned date time with the given parameters. - * - * @param year the year. - * @param month the month (1..12) - * @param day the day (1..31) - * @return the time. - * @throws java.time.format.DateTimeParseException if the instant cannot be created from the given parameters. - */ - default LocalDateTime getLocalTime(int year, int month, int day) { - return getLocalTime(year, month, day, 0, 0, 0); - } - - /** - * Gets an instant with the given time. The date will be 2017-01-31. - * - * @param hour the hour of day (0..23) - * @param minute the minute of hour (0..59) - * @return the instant. - * @throws java.time.format.DateTimeParseException if the instant cannot be created from the given parameters. - */ - default LocalDateTime getLocalTime(int hour, int minute) { - return getLocalTime(2017, 1, 31, hour, minute); - } - - /** - * Returns a string that represents the given number, but a single zero is prepended if the number is {@literal < - * 10}. Example: returns "08" if number is 8. Returns "12" if number is 12. - * - * @param number - * @return the string representation. - */ - default String preZero(int number) { - return preZero(number, 2); - } - - /** - * Returns a string that represents the given number, but is prepended with zeros until the digits reach {@code - * length}. Example: returns "007" if number is 7 and length is 3. Returns "012" if number is 12 and length is 3. - * - * @param number - * @param length - * @return the string representation. - */ - default String preZero(int number, int length) { - return String.format(Locale.ENGLISH, "%0".concat(Integer.toString(length)).concat("d"), number); - } -} diff --git a/clustercode.test.util/src/test/java/clustercode/test/util/CompletableUnitTest.java b/clustercode.test.util/src/test/java/clustercode/test/util/CompletableUnitTest.java deleted file mode 100644 index d968678e..00000000 --- a/clustercode.test.util/src/test/java/clustercode/test/util/CompletableUnitTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package clustercode.test.util; - -import org.assertj.core.api.Assertions; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicReference; - -public interface CompletableUnitTest { - - AtomicReference _latch = new AtomicReference<>(new CountDownLatch(1)); - - default void completeOne() { - _latch.get().countDown(); - } - - default void setExpectedCountForCompletion(int count) { - _latch.set(new CountDownLatch(count)); - } - - default void waitForCompletion() { - try { - _latch.get().await(); - } catch (InterruptedException e) { - Assertions.fail(e.getMessage()); - } - } - -} diff --git a/clustercode.test.util/src/test/java/clustercode/test/util/FileBasedUnitTest.java b/clustercode.test.util/src/test/java/clustercode/test/util/FileBasedUnitTest.java deleted file mode 100644 index 11f23a20..00000000 --- a/clustercode.test.util/src/test/java/clustercode/test/util/FileBasedUnitTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package clustercode.test.util; - -import com.google.common.jimfs.Jimfs; - -import java.io.IOException; -import java.nio.file.FileSystem; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Represents a test utility where unit testing using files is needed. Any path created using {@link #getPath(String, - * String...)} is placed in-memory. The default backend of the file system is Google's Jimfs. - */ -public interface FileBasedUnitTest { - - /** - * The variable used by {@link FileBasedUnitTest} to access the (existing) file system. - */ - AtomicReference _fs = new AtomicReference<>(); - - /** - * Setup the file system. This method initializes {@link #_fs} with a FileSystem, by default this is Jimfs. - */ - default void setupFileSystem() { - _fs.set(Jimfs.newFileSystem()); - } - - /** - * Gets the path according to {@link FileSystem#getPath(String, String...)}. Be sure to call {@link - * #setupFileSystem()} first (e.g. in your @Before method). - * - * @param first - * @param more - * @return the path. - * @throws RuntimeException if {@link #_fs} is not initialized. - */ - default Path getPath(String first, String... more) { - if (_fs.get() == null) { - throw new RuntimeException( - "File system is not initialized. Call setupFileSystem() in your @Before method."); - } - return _fs.get().getPath(first, more); - } - - /** - * Creates the file and returns the path. By default any parent directories will be created first. - * - * @param path the desired location of the file. - * @return path - * @throws RuntimeException with the original IOException as cause if it failed. - */ - default Path createFile(Path path) { - try { - Files.createFile(createParentDirOf(path)); - return path; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - /** - * Recursively creates the directories of the given path. - * @param path - * @return path - * @throws RuntimeException with the original IOException as cause if it failed. - */ - default Path createDirectory(Path path) { - try { - Files.createDirectories(path); - return path; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - /** - * Creates the parent directory of the given path. - * - * @param path - * @return path - * @throws RuntimeException with the original IOException as cause if it failed. - */ - default Path createParentDirOf(Path path) { - try { - Files.createDirectories(path.getParent()); - return path; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/clustercode.test.util/src/test/java/clustercode/test/util/MockedFileBasedUnitTest.java b/clustercode.test.util/src/test/java/clustercode/test/util/MockedFileBasedUnitTest.java deleted file mode 100644 index ab438065..00000000 --- a/clustercode.test.util/src/test/java/clustercode/test/util/MockedFileBasedUnitTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package clustercode.test.util; - -import org.mockito.Mockito; - -import java.io.File; -import java.nio.file.Path; - -import static org.mockito.Mockito.when; - -public interface MockedFileBasedUnitTest { - - /** - * Creates a mock of a path with the given structure using the system default file separator. The mock will be - * configured to return a string when {@link Path#toString()} is called and implements {@link Path#toFile()}. - * - * @param first the first occurrence. - * @param more more - * @return a mocked Path using Mockito. - */ - default Path createPath(String first, String... more) { - return createPath(File.separatorChar, first, more); - } - - /** - * Creates a mock of a path with the given structure using the given file separator. The mock will be - * configured to return a string when {@link Path#toString()} is called and implements {@link Path#toFile()}. - * - * @param separator the file separator char. - * @param first the first occurrence. - * @param more more - * @return a mocked Path using Mockito. - */ - default Path createPath(char separator, String first, String... more) { - Path path = Mockito.mock(Path.class); - StringBuilder sb = new StringBuilder(first); - for (String dir : more) { - sb.append(separator).append(dir); - } - when(path.toString()).thenReturn(sb.toString()); - when(path.toFile()).thenReturn(new File(sb.toString())); - return path; - } - -} diff --git a/clustercode.test.util/src/test/java/clustercode/test/util/TestUtility.java b/clustercode.test.util/src/test/java/clustercode/test/util/TestUtility.java deleted file mode 100644 index fe521236..00000000 --- a/clustercode.test.util/src/test/java/clustercode/test/util/TestUtility.java +++ /dev/null @@ -1,38 +0,0 @@ -package clustercode.test.util; - -import clustercode.impl.util.FileUtil; - -import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Comparator; - -public class TestUtility { - - public static Path getTestResourcesDir() { - return Paths.get("build", "resources", "test"); - } - - public static Path getIntegrationTestResourcesDir() { - return Paths.get("build", "resources", "integrationTest"); - } - - public static void createFile(Path path) { - try { - Files.createFile(path); - } catch (FileAlreadyExistsException e) { - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - public static void deleteFolderAndItsContent(final Path folder) throws IOException { - if (Files.exists(folder)) { - Files.walk(folder) - .sorted(Comparator.reverseOrder()) - .forEach(FileUtil::deleteFile); - } - } -} diff --git a/docker/default/config/log4j2.xml b/docker/default/config/log4j2.xml deleted file mode 100644 index 16e50171..00000000 --- a/docker/default/config/log4j2.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docker/default/profiles/default.ffmpeg b/docker/default/profiles/default.ffmpeg deleted file mode 100644 index ec413d00..00000000 --- a/docker/default/profiles/default.ffmpeg +++ /dev/null @@ -1,52 +0,0 @@ -# This is a ffmpeg template file which encodes in x264 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. -# Each line is a SINGLE argument. If you provide spaces they will get quoted. -# Trailing spaces will be removed - -# This option is necessary! FFMPEG has input and output options so the order of the option matters. -# Therefore you have to specify the input in this file. --i - ${INPUT} - -# copy -# metadata -# -map_metadata -# 0 - -# copy chapter markers and set languages to english --map_chapters - 0 - --map - 0:0 - --metadata:s:v:0 - language=eng - --map - 0:1 - --metadata:s:a:0 - language=eng - -# use x264 encoder --c:v - libx264 - --preset - medium - -# set parameter for libx264 --crf - 24 - -# copy audio --c:a - copy - -# copy subtitles -#-c:s - -${OUTPUT} -# Specify format (clustercode specific) -%{FORMAT=.mkv} diff --git a/docker/default/profiles/x265.ffmpeg b/docker/default/profiles/x265.ffmpeg deleted file mode 100644 index 85055b36..00000000 --- a/docker/default/profiles/x265.ffmpeg +++ /dev/null @@ -1,52 +0,0 @@ -# This is a ffmpeg template file which encodes in x265 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. -# Each line is a SINGLE argument. If you provide spaces they will get quoted. -# Trailing spaces will be removed - -# This template is based on http://unix.stackexchange.com/questions/230800/re-encoding-video-library-in-x265-hevc-with-no-quality-loss - -# This option is necessary! FFMPEG has input and output options so the order of the option matters. -# Therefore you have to specify the input in this file. --i - ${INPUT} - -# copy metadata -#-map_metadata -# 0 - -# copy chapter markers and set languages to english --map_chapters - 0 --map - 0:0 - --metadata:s:v:0 - language=eng - --map - 0:1 - --metadata:s:a:0 - language=eng - -# use x265 encoder --c:v - libx265 - --preset - medium - -# set parameter for libx265 --x265-params - ctu=32:max-tu-size=16:crf=23:qcomp=0.8:aq-mode=1:aq_strength=1.0:qg-size=16:psy-rd=0.7:psy-rdoq=5.0:rdoq-level=1:merange=44 - -# copy audio --c:a - copy - -# copy subtitles -#-c:s - -${OUTPUT} -# Specify format (clustercode specific) -%{FORMAT=.mkv} diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh deleted file mode 100755 index 6e1f077a..00000000 --- a/docker/docker-entrypoint.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# check if config files exist. If not, copy from default -echo "Checking if $CC_CONFIG_DIR has contents..." -if [ ! "$(ls -A $CC_CONFIG_DIR)" ]; then - echo "Copying default config..." - cp -r "$CC_DEFAULT_DIR/config/." "$CC_CONFIG_DIR/" -fi - -profiledir="/profiles" - -# check if profiles exist. If not, copy from default -echo "Checking if $profiledir has contents..." -if [ ! "$(ls -A $profiledir)" ]; then - echo "Copying default profiles..." - cp -r "$CC_DEFAULT_DIR/profiles/." "$profiledir/" -fi - -echo "Invoking java $JAVA_ARGS -jar clustercode.jar" -exec java ${JAVA_ARGS} -jar clustercode.jar diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 0fec4209..00000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,85 +0,0 @@ -worker_processes auto; -pid /run/nginx.pid; -include /etc/nginx/modules/*.conf; - -events { - worker_connections 768; - # multi_accept on; -} - -http { - ## - # Basic Settings - ## - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ## - # Logging Settings - ## - - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - ## - # Gzip Settings - ## - - gzip on; - gzip_disable "msie6"; - - # gzip_vary on; - # gzip_proxied any; - # gzip_comp_level 6; - # gzip_buffers 16 8k; - # gzip_http_version 1.1; - # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - ## - # Virtual Host Configs - ## - - upstream rest { - server 127.0.0.1:7700; - } - - server { - listen 8080; - server_name clustercode; - - # dont need to clutter the docker logs - access_log /dev/null; - - # only show errors in the docker log - error_log /dev/fd/1 warn; - location / { - root /usr/src/clustercode/dist/; - index index.html; - } - location /api/v1/ { - proxy_pass http://rest; - } - - #error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - # - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } - - } - -} diff --git a/docs/nodes.vsdx b/docs/nodes.vsdx deleted file mode 100644 index 920d9be0be9e501e25863108b208890f03ae9b3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 182180 zcmeFYW0xpFv?W-!ZQHhO+qP}nwr$&W%XZZ*+qSFbzSq-l^~}8a1>N}}S7gS?lW}&g z9VavPNd;+O5EK9~00;m8078JyY#@*kKmY(4XaE3Y00Hh`;;wNy%I#7Tiwtmsx+iKpe=UpXeT%p;oB zs47RWIGtHnx3T#wUIV5qLBp>SHT~yEPUO&mriygdoFhZg`^O+7 zrPAHAg0x?vvK1xBmc`BZksY3S?Z~cg84L7~rYH_E8zY9EB8s0P4xb{DpCXo?BATBf z9-ktjpCX)C0E`2E=)f)RoC4tJ!7c8YB;&Dpv~cqlJYwHVxCU?f-_4&Y;wi`7z{iFX zFV`wsyeDq{La##YU>upxoqjf7Y*M?E#)^;SC<{YNR zRcfpK(Ej0hKcGp{0Oe)gc;p@9qpPc5tJerF6>n7TYa%%^n&d9>qZmQK&4R#ux=@xq z3JRPOSBQ#t1l~fC=IKZOM;}%%mStTmmB(fo*8F*g{M5N6R1gRhM5rqO8XrlMAlJ;0 zH3Y5lXmTQ5B`!RUM$wrOwxx##!8O>kz2FlvCBGX+mU&Eh`VNCOIJ&M-hKsn3i!`TA z@93~Ln&s7CezE>r{j$-|3e}x?6}hv!Yym01Z-(pu-6`T|ntvU~OO9|`4++veM6Hku z7IX(ca`;SLCI25#|6f-9vED8G#Q*?^&jSEJ`e)&0>1=6FZ(?ukYWpv<{zpo^(zSKo zr(X~u6+XKj2IQ_T9izF2*Ty?L)8N9Me10om4BC_yX9dH?ls2_}I+fQ7<( zDLaa(t;(GEAy~X%0T3$|%=0mp?mF*b;CLXu@7U}+Z^fq35u@hk;l8z1)?N=bGs?g7 z_3Dr7dk4rAo3_acy#$s5m3(;On`P(6#UqpUYI98rZtagLF6okuM^pT4=;D1zzrGKQ z#&gxOkvr6S8ZN8&QvR0$*H#0<$G+I(x^zMiW!IGEK`T(Q+8?8NKw5C)XlIusXH$M+ zRJ-Z$gv<;w8eijaMp4gXa1kXdiKVW1rqmsrz+x|D+iR1c*Gzv=^aEocUibYl6QAjD ztP>w(G@e}BKULUU;aIZ4)YH{IOgwz!wOI7bmZbF-#_MLG@C93Zy03(0xK3in#T%&L zYnZV3j?Q3$kma87NnoG6u|F$xM|MEXenUKbxL!QnF{P>0!up}i)urN>;QVzb%+lpv zSdRZS2?a*fFyS|ecr^d!Pb$>7A3!71P$CsamWUw8^zg5_Ar?QI96ugAcdyyug^ADB zS2p*UJhIkgA=K~$q4STp;0I^P$S*}iws6?@f{&0INE^Kg<{WvmX7&NoG~qR-AA|%t zjhus^r8X_pq8i~gBo(F6cLO3A#P`Mi4Hb;x|tW;kJ$s;ktY6!kcu(t=u_oVY+>*M3uUIGtjwE zO6Xg;^Y^y|9L}Y$<+$bQ?Ej>82-%TCL%> z7q0)q=*9;IABw1z`*trpNa8w<>+L__nML3S8CLx_OeN)J>efrQTue)ty5*Z1es^;f z_O5?0?3i#Aat)+zxRSjU4ySJ5a{lLKy_9|w5z99YA^+iV)BYLkh{LJW&3oG}9cKx+ za(i&uHogK5L*kmNSP#By2>9LL|zDy<;uUlf!{ACcd?b`r};cU8*`OfAllB{aAha)W>1oDBtd zx6Jjom@RTYN*%e5KLcbe(G`P%vtyS`nq(I(auEmo?)ArHu>dzo9X`ovAh6KKWcPy) zUjTcow=#SLxUzb{25Ln}0o+N^2DJY=Oc#5!tb@eo^kcwa`qzA@pX_dz2f58#wR>Ir z-II%@2c3Y4GIu>G-8~yNtJ#gHw!k_%TcBl!Rx9F&=cE7J#wd^U?2&JalhvhLqyZw522Ts}kA) z{pxFvx;LRVj4VQ9#GJ51aY|i+YHoA3W zIo@y#J`kkYw}~p6G)HtwzW<~8X@@4NqlSk7L8J01_i5Q{Ui0g@y(33gm+jm;EL zIubGJ_zgz@J-9Q1Oh>fR{E$7+Gn?+2@S92d9Su=2-;FB9Bf@OKM*hIUx)KrX;ke%Z z+z5I;QdYmiZ~sCtJ!fnoOI?d}g?0ELbWkU$3w8oljWcSwAyLMa`#UET1nNFahme>zO;ajT}B9c_jo_ZDXq$$8d(ax zWig)nRck}vEKNBBq&UJ3daql75Im>;+*ir*B%8N$6%2UBoDGxhIvAo2YG6}QKcN_v znH*6y) zE8%!^r(1ApgzaUZd^$SK9y1RPMET{l^XlxUO>gaU#bP4(n zv;~+UR6qmsw~Pmtf+mdCFsM(R+mTRJ&H@Rv@k-hN~Ak2I*Tq5jP@j$ZJ4GET_)jsh*$j!>hCUTVM+-OV>bdy9ovj2p`ZN`p(j zrrl>V#Di2v-@`4(ffG*Grscc@M-;ETiU`&4VaNwQ>BX4MBRu&!IivcRPEOXA>I0u; z(*bu#8gRBtakO+Kra_E~6b}`lFZQXSiWxxx7f0_gvjtbKp#`^E6!$EKDF?mXm!X9- zP#y15*#CvI1Da0(y}~u1?TT+VtF&whZAcRl+3fa4b^*(LJL5?ZchfOUb9GI5)1zpu zeHY3YL2RIpHodZ6#6qYnR?oVqK+YFFbCw{RgaK*_hnngD8S04co#`E6GMLT(WIXl- z0Cj&Ya|#%EeIhe$9DL6H{i*UT5N$`#X6z27L9LoUL!({ep!dNZ>3S}?8s(lGETi~R#FgVbW(_Z13s7a_OlCJB`7P*9Nuxz?j$aLZsfxpU$ zSw|ZYSamTKped_9C}`}7p@)%YnYy9qmRy zUa`@1MT01tVQt9w;Qg|Lt15mAJ3eX`BK#vc8kF|L2`HeK3SgJ%kVaXiU%UG%HE_6d z5=AfFP*)8MZc5WtgH=uzx}J#ONYnIi#lSH^Uw*b}nwIIeGEO91jip|{G=E#E&Q5fBud<;mt_*P+TLr9k zR0~dTjLCT+qVRqaM;EKiw2dH91 z`B9%F!cps&S9IdIf20uu6stlnpJ+ky5K>X=&~SwuRnx`5xBrx0MxW#HFv18Aw#h9d zijK(0jVCVSdqdR)=dOyydZsL&=SAuPplBrlkH2|IXgopo72iD(Vt97L1go%Z2Bheg zFBx$nmJG3yO9s%TT^kEE(@q)%)#XxO3o$ZNH#}~N(#8Rjz|EerDQlqms!f=_y-MYe zuKksP0{&*`4cdt5rr_w|6Jy35NkDckK9d*+Lj zK#Egv5&pLgZ~>hh(+RmRDp@CvcXQ3sh;V19kU%OO2io<8=^H%Nsta->@}1cM{=qb} zqf~|h>cDKn*7UmNfr!bDI+?~pNWjrLb0DET9a85UjX99Po*B7dvDOSoaL10!LH{s~ zS&^2eam3a33&U6FQiFNkqS_Wtwv#Onin(@L`7D!@i zLq%ay@^6V9RpB;0ZIF-`7#m-oo4A0cB)!V;)LJIdOag%sN%?pJDSXP2r}AY%t-cB| z6Ko`v6lOSx&vs5;$V* zRY}~GDLgO2k<%IAm=hv(u)WloplsoHb0}U4UZ)>7cfK!1?ze8VsA|?Rqg;BTO}Xc6A}k0ierg8WSA6 zZS3S_)j0a5VhfQ}Q!z;@Z(H$r97)qf2ILI(m|5Pbh#B3&$b8s?k;uc!jga?pX;eiX z`ArLy>Ub~IYE1Wh?N+LwX!<1m`lN7EZtavbihH{ix+0z93N>*NqY~R<SLZdZ}k7T?-l z_+shF{@dbeAcny$6Ck>tT4|}o1)3Dm#qnku=&rRUKc}077xuUtz>R`1N`UKk*3!udst^NVz!$AD>(uyaNBQ z1MWHPub)S>UEiGp_`LSFp;OAAB|e(LE-@|g*7}Z?-kRyrqzBa2-zFx1>FfTQ>0PBy zdg{ma<)3E#%xb$h0R$51-Hah)3T*VZ)xs!jc{K&j)Wdfj??vE++F&(kJ>AN$dy2%E z*e?Z;I{&u?5c&GcW8ajUDne!HKjM&kaDGi!4gc(kpnB)V<}(xP72M#%|wxvAu1cfb}|qh zytb7f{}R_wHs;h=ZBlEs1ygxRh`PL|o3r|YYe)5>#sePJk)kw&eBm9kswC4E zJpZh?d35Fp;p#LDHDn_-DNQhSxM{+#!foZS6hxg0rRRtC9~5?8syCn3$DOtxaT#>~ z{lgulIZN=ijHd=;&DA@n?E4b%9&bMSOpY1#~h$?n|oVk@Fs!wY1HJmZInIta6QMY&mV|BG<-cQuY=~p+(Y8k zs5~r>$j_rE@CpNBzzfuPOZ6t&gKjJjZ4WaZ4~}0@B!{H=Tk*F5dxzLqIsNm3f#$OB zrqFA`$H1SmA4ZtAPHa%9)WImd57iE-BM|xEK=rKtA;k1D-T~3)hcjF`pm%lg49Aql zBR3bp;Pq4|O+uBVJ#sCK&-auUcQ>pFK90VmO&)Y*&GG)XKM^_bQqyoE^_Ej~d#l)t zBbIdK;PrcZsj>8P^jMi9uyjd_LL)F}o7w7_2KDb#_WJoAl%H3;eRPqZA52j}YXulD zULf`^{h{h0J+n>od#*^Z9)L_dV=Aeph$EC+p~s6A{ZlDi*GLzZZvpli1$cMxc;)`o zZx-^?f84_Zejg*fd|_4s-!XO0>*&u)#| zEg^yHOY%xAsjqh{>1Jgq3oy=Kix?V?K=kgPT)3t%f6Q0PGWXaqVu>|y_FMy&b3Vdp zXb)d~sbS3rXz7yW%h)FFKh?JAwbA$|+xRkHI5xwMK7B78fa>K(-TelTzC*w=oNXQ2 zcH6=BdvAVd`${h+S}UA>s4bATJo}!J=m&h8?rWOA;bIe~V}b{CfAkm}Qr9McukhK7 z^qp@=4@wO?^L5{55ImuKWdEqanszH1pRay7Ra_ATm14U^f89WLHdxk4O&l+G4B8zE zce7{CNwRIMnt)@=P-+OdEcV@J4=W~3w-fsJmxF&c+Z>i-z-o#w0Ec(Bn%d(ZaJMph zESzQ&|I96=)MA$}9gMgRxhI67>wj=Oiu}Z z1t^Q%>pE@m$P^LqoM=xB9ecULDBW>lxakmd>oE`eq-5=P{8U=|wHp$uI-2jY36~FD z@;rwbU3!#X#$H@)QeoTQ&+E9oa|`ZYKE!B#gCHJPRo0p4@VG(8Wa~ zLup2K3cXIB&6<6ha;+LiY-j?CrU46A>y+9O@A>|Ey~5CtSyHcaMgzrR7;5kA*z7bU zN8*jBeU6WhhymJ4`OSE$&|+c!Q==ATuY(dJC+8jLSHf))>L0!$!XjE#(vsj6EWK+{1Vg@~N+B?MEx;gV}0HB$7wrm9vFCh||zL-#L_p z`;Tr-$*B+V$(>1lf>Jy34xdZY)IMx3tz9jzzr>&v0i>Z?{{r`aV|33N; zDe6B_ZCM$#3Kl>Zz4tD-C2+34%orviwYCe@k^TUPwo|Fx#m`&XiecXjU)2w z|JGkUJa@)w&W3folLI~hHCbit!__I@|9<%B38x`D)MEBBXWg<5QT}e>>RVqR`Ji|i z!eQ2OR20r%{3=qh@#4*U^mgf zWuEkFBQK2X^R$-2?x)lq2xNg0eOyswOoDAO6f#Q&yF2%p69;&7$UM$^7(qdhKr_iDi*Zma3Cne z<3JQ8av)!-^#ylH|KEZdKa_dZ@vjH^AFu@f1*(Igx#|Ce^grAG1*p#49Mm5M7_htU zJ?@k(U3Pe)CO39N^#yPW-K(fe{4Ao6m(lD;3?V=LK0l98QJ%&Gc}2ms76OJ1miIH1 zt-8}~L=aVE^5&8Vs2vH0v+J_{JtHdog)3%AH6qFk>lt#&iCGL7R!ZZe8QxR_s`!x! zo2wy?l+QNa@dxVwy-cGVloZDc0iE5<8G~w`;6po^YCsg9fTM zIo%=pz~x>Y)p`8?HfsGNr?AU^oHhJc9EAG+iu%9eT~?d2J7h!IMnB`XC{L|TC!G>J z3cOyf4J3i1A9tx@F( zSxtjRRx+rT7ce)?n!8SwKxjtmB}!kS8A*9Qn@NvA@fmYBPi3pYMBRW&AQD#t=Tssf zT~%yS%CfskDrIk+hO>@g*VwtHhXsv25z=w17>Hl2%%$gadFDvz>M#%dJJnt|p(C?Fy3H*EBod^Bq&pv^RJli3RvEdd{l(A!q)ZkCfk*BxT zA^Jid9_71rcxkb}yqynsMrg7gL$<6pu>al@+0c6rI!x~Ti+FG$zRV9GGjq=As4wo| ztqC!c0}Rv2Q*a`;6A%59FgpgEEU-~uH*>9X`bmS# z5>#}&)bI_r+!;sPC1{;BeArFFkPP*cB;<3Sq*R$W>4C}ki`dS5TlhfT^_zgBv87M% zpYtb+8f&O&oqeWoKMp_>Oquj4?d>rz&t_`pB!YXq7sq=L z*e@9;qPTQ6)4I80n@MHWL1(%tZ=rTiVApdqG{UZt>|3Kp!Nn3U#dSB=P3kt}4c&s>i5IjfWs~@GxXa0}#OqJ3O*f7WDAuli!HPIJKkWAIVr$3P2fG|B08A=J z7*S1miqwm;0+odWl`X=)RBMp1ylsDL2@A~xCu+_oKM{wSNqod?CPIz-?W7z9!fjnA zAZZEP&Z$WiW#Tk!bow)}ehQEnaoQvOMHKP_pu_VF%wiOQn11+d<`4R)RejH*n&AR0 zx0W2&_=aRt?WSNJ)D=C*in5iqb>vBRT;_wo=AxQtKr~B}9cE*=+hE-|6B;y%(A$Z> zZ(7Y>(Q)-==nd1hGig2u6ssqk+$5L>2RRs=sivNtef9do35mEp947nczbigGupN(4 zsqZRp&AZ42V0e`1UPo7bU!Po;b1q*S4X!Why)G2%)U@{C?I#gdj>|NYUlK(*bVYj6 zqVe5&`oI1~J8*T$6>VEJUM7Rl@;7%|d+H|0nLdq*l8WsLxkxIp{r~OkAdz~9h>ijP z5St7Dfcamu!~aY)|4WLw($!ARmT2;G5BUYQ_p(?xAcK_c=p7W@xcPf8Q}5kl{*ovr z@rO7NErB%;`LsWuzPbaYWxmwgCvFTZ{Je^UdV)$}MO;m7HM{@2Pg6QShy?cVtgKv* znwK)-%L7krDtm`cd)!R^V{>z@ey8Vg5yl4T-%?Fk`()EKe6Zj}eNb$=p1lx%Dz;4z!^M3QKZ(55*d{^HmS~Pm4^3}evMJr4*zaw!2qp+c zv0xGnZ_Fnd^?f$eML&u5Jp8SvHpv9AiSmM1wl(E0XL~P&Z*qHNpN?~06TKwKC3%FE z;8?(uT9*!(ir;o#TJm0}razNE(~lkl z!yks+;fpcv!YDuH=nBE#?R?BO-aI+uQAIK5CF3W{3JF-0Wz0*dg|*-E@=d)dkKF%e z*S6ZpIwm)hA)I&<@0#-_-kgg#Ypb{_m_h$rQxEr|^$qQVl|ZoeeIL9%U*V*F^?sin zCf(h>PVr}aHNgZ){xHS61Yf;Fpf@v%KUA;Mciw6zJAIBoEw}RjzNWtP_j$CROZ>N= zo-QN##6+hX3oC{i17G)tWN){-cM9|l*b2h;PT5gZ(U0*?I-=F~jWc*f zc3vzUt0vXo3x<~6-%nQT~e!m{$4`QL@+}lT{;$9yhZo|9%3$;CA)(l_X&v`3b z;j1U}h7PmzT&cCtuBojAacW@A^YTX^k?W#hJiUA1l^Q3kXyBi+f5h)R@yVTC6g?n; zFI+z2yHV_&vtAzY3i~5Z2eII5@kcN(mp@*U@A0;!k|I-G4_tS^HXJDQ1WwbhJ+@I2XYr(W>RQ=4<%KTX7SLFmKuH&jk$cI) z^)kzsA5g0&-afs?NS7;WjN|{vlT*DycgYpv0kK-Wv_9< z9ch@3bfrb*B>qL7a|&C^^MiUrR4_eXV+xp@2WRamPoDB4s9AXAQ9U>C8=QLFUAa^z zehElZX|Fy|ytcR7fzd#}Kk;oyOTz#2E=%8;-7IH004N8WAtz<<5uIMtY%N?OU%y&V zL5diR0t6|BG+J|qJSt)!Nl&bW@qW3@lL72wR*e7yiuL_LcqX!>E0n*1iGY#-ty_FJ zN$hJi6?TaAFpPEi@E0qd)|5>NSNod40#1V)VzQ64j~ODXF#QZT@dwRp@Pvp$3V-D- z>6SbYAE4%Wi6HA$nIEzp(*oM(_TaF&KkksW(d3OuH<%K-4Hu^O+?63Ewh0{U{ZaC| zVX&V>b$q&Buqg)+pv;68Ilvm!g~}2H3s8y5aU@lm6y^A@KG}^Z z5&68CMh=HfGdH z;S8Lu8`vB)OkPG$;B!-cZIZ{Xeu&BJu*)DAB$M&WH^V(3HT0o*0tsWLXdus8Lx>%N zdy3O&7-b8}BBrPzve}@Twq3%h~ckR8j2K3 z!fmcV=zZg`H&EvhX||4MibXe53LmQk+6JmS@s>7TDF`B?n%GDZeB{>-#?|qkGc_zHP}h_PqR}^Q|gat?wpiEF$FL63nB24R6G!^GKQ#PZk8en72Zs6 zzaa`M9YfSbtwl>y)Xvzw7M29R7owT_Moe+73@bL}$yM44&L>*4IZNct%xOco*kIna za1?T6iLNyMVt}HOQ4n?zx?x#8Lv0w#nEqcSO#XbSUuLsyT-inl|Ekjr9$8b9jW)vx znB#<2ScgP{ulJ_h?xT52);jv<@v85Fs4m6)nBbH%0qKl^TR?T5_F4t34Vnf>cfZ% z#OS9^%<{tZzPRxB)wdX4Rt_g-MM65BsBXjQIP^6(kE{NGj&q`dpejZbb3eeE1q8tr z>M*oTmA1{3z(*;22^Hk=NV}^Xn>J33QupDNwwoXwd>1H1@z$#?sfBZA_8 ztt8ImL8#wi6GmiK<{C0&U6C_}mraH+upAI`WRE6>8%<@i;bJR|JL=fDz|{6;D7qlr z3_`s*`PSC!9}%y<{of(n;7n)kPZoO^nxcePzJCG;)W2Tn>zgUFnbOG>^ib4w_-Fz< zH&JOu^xF#s>24ZA!06KnwWOgc(^A(8E4Kx4XYD~FtgXW@Rz)uya7AFVHc(0P-(RI8 zxP!2$U>FQB0*=8^$tvpL<%+|Uk@5!4QaHKBgwdtJs#!B>q;~1!m*-UA#%0X+Amk1aSOxB zds(p4Z?j#Hi_^I+6kb`v87ves^XWexg3{gF$tbq&I|~l8W2cW~ghpnRF@o1E6z-JH zdw7u3mohVZ6BNex)}SCedDQym155&=Q_T*Xa5;K=%uC<9O5*Gt3)Sx%qnO^%Q}t_y(<`4;0uE&1?8wq%4f^Q-`=k_T>O zP`KWjX{MZsDk$O?za~yZ4#GmbJiFZ3NDK)=()E=HPkbNUrPk*rgtwqcmnWSgNF+?2 z?V4F*Hq3NbYG9{rVPC0f&ba|GH$3`K^cY~a38{zfV0suZH;~0y%bWOc$P`rtpPCZV4 zJrF^b06LGxCIod5QYtA@teNdGXahzo0;Re0xI~FG%5C^kZgg5Tlo}NO#HDTAD$z8D zk6CmH;YlO0bw~X9s{oTWMz9GkNMibl5!lKkVhg!g?iNrNQ)ZC8H3G7er~vGXNV*cIN)-2%&Q zr9$zlO_)L+vhFIPjWQcq8)dezh))yw28MKj&DyovjyqZR-e&%uHT(KLHuAo}so+Nb zShqM$=Bqozk_a!PQbZij?O}IH5KS{&h4zq(NspzD)6Jhz{+i8+Z;y)RdawXtJ#rx# zJVPDJ;eRhYE{>rd!a4Y;6$^34#T*oXdV+4ub|`AiE%^FCEvRPf`=$a(LQv~Ao{MuO zv*@wZBpytSpce1dI&HeL&1^WuOw5CX?k2h#!zlG@J84(O@ZI9?dEnqy;LLA5JR^F* zENE&HxD*R9k)8S@03441)|IQ3AQr@gS4|@K=L@n#5s1O;kMr7OJ15ocv+E8)W>dp6 z%mzy~7czzsLr{*+XOm%O1VY`mnN4=s8}svK!m7-1oMQWuQB(CTvutbYCk7jI2X6RL zCqL@iRI`7K`7+CRkP3LWd5P}ky=H9fX(qZl_=M0xu`!fBMH%$|UNouloGR{~c9X=X zp3fYsUJ(y-10^6^#}J4M5fBHNr(<1O=oLrr?~FL0C(3ZUaU$^~L+>s8fiAzNj^4Yy zR5!!}>2TQ46_G) zl_yD{&ZYot4+aBU3ouMq0*CwSNehKLUYP?Y7>+e7SVlolI)|BKjaYJ zbe;jF^V0~rFq|1nwq!S%Y3ni6MtOnr)+Y|$moCfcY|lUJAKovuD;=Pc4tPBbR?sEA zhoO=01=hNiD3L>ZQ&zbCVue&_eW+A>UID=qfP$`tj@_&Q?8=mZfk4XCU1kRSGW6 zrkl8}|0Kf^o4|`Dcw&O1v23b}4sWCE? z?65HC{Bl90EsR(k&+@|Jq)J#@l|Ha07dP$td0ljH(p~Bc?W!R?!ypt70P%3WOqh}A z+bwaYTjG`_?2p5H(;-TgA(%>k*O;yEJly%@`wt+4Oj@u&-7k9IMHC&@@&?fYJUK0 z3wNF;7ZL-<@-wA}SKy%nrmD%jRwfZ>QlRTW2@6lJ?+kXA)j^m0mz(Tg5Ef-SkCU zKJa$)@L@9>ZSaCR=@`l7ZIOQCVzN&xW`;uRT$b&1;|4Pf_&VZ98oz*!d0G?;@bBXe zf#~!Fh)rP&hZ1J7(uEJ zB*||!Vlr~ri;EN?x6CN=8m&UwYWtuokYrtWd{-q9q|x_5U$j)ISVyUI&ag|0n`XbS)n}zZKLW&e0Mq2qRQz13OIv{gw}XU=7hCG@)V6_5t<5Ydyt@~4 z_P%!F3gekn?2qLH+k`5n8GsXsR6!(>X8VuP5s1(|-V1qAoSaNNLvc3%OaHmUSO~(j z0pf@E@kk$CSnL10nU|r=cJ3#qvvclYR7Dn(P7G|Z>_ATJS=3R*;|w#RhGTAqDNcOq za#J#@VSMY{7*p&_{yn-Sxi8i?XybvejM6{&%dELrbQ{m8BYe;Pwyke$E@M-QCng$2 zbTyEfVsF+7=htsx>9H{PbY89ICo-MqiSJCNm{txgzYI6;7Jw%FJ0egN$siK5*5gKU zi}&{|V$@d%C^i^#H%rBCpxRo006P%s8j5AGL5xs~848>Po^(XLO%I-E|E1YTM zDYa-+DWxUwzAC2#v8`klNWKwco?JV1D+VP(srM}M>O8$^hhNM07b$EKOEE-hS5anG z0UhCkbm+0Er1bByshB*?&iC)a^Nu0jAY>G0G>3{9sVS}Fkgs#eeWyn8l!&FhshJBF&4}vJyfRc8Oiiwst=zjj-*XY}iz)u439HagfHVoP-z~Kh8#QqpDgQ zGxX4yS%B};-_~(gz@SP_;~KgTuG(r}mR7FDG8?i}n-*KK(Q4I_2f3&#jT@`dL-0re zYBST8ZC0{W$#Pq&OV#-f0`5z4r`Bp#Vvq4Ew!=1SwMz$tN6@Q#L+i54RBK&tpk*js z1>ldr!a(%5=QiQsR{x+?wd!SfU^91jH_IPDYs!R0H`D9Dr57fdp)z<8&M`^k-k3!} zZNnYD+qIAhLL$lR(S$fpdrz#Iw%Q4yGm|{C6|;yH^7PSUgn{k>OSZq=kb9-sv1Kd9D@E4bj7FNsd#`i0jPx5puoK1;nt zIk$zHg=)ADdUZstuAFjyIScelVeAdBL_i3JA()@=cb7AHy|b8PiU}g(CYJ$PK(~>@ z;GN*IpnqvbQ2}tJCQ=mPu4$ARb$s9?y9E;2&#u(4gZF5lTt7T<-2HBsiO}2XH#=tw z{5HlU+=;tp2bAgCgGmII;2@+3;@58Z(!%Bg4(q(?;X}(CY)vGYN)nqPz zoY%iU;`ZNd`b2TN?nHnq-vIJKw9vIQW)tE6SPK^p2O0Sw>?9OZ7|Y;;a1g=~u_ORH zi;Ke<-?Lt`Kd_M;5B-H-0_Z#sk}PxiGwlnHr#I*4ee|N2{>vDJCcs#J4*(7rH5i2K z*VAVopaYjces+xDZBnPuo9x(djD1=Ey)yXzJt`+2M%)* z$_uv9Ow&CO<%^Rh=9nmQzow$iY=BnpEf4U+SA0@BAiATx5&H4c2}|IM#-<$tMU?+Ji>`-_vo ztXa%CVZ-FicI{CdvCU>oTH`8C?A+e=rGn)^08$|N*0f}TYsn?qFIsDo1#NRvn?;RZ zvc9S}z92t3z}Ixt=^rwE`f6SG(|)2`n6)wFOvta}o1)Ha4u4!PO$9Zr;I}`fK>stp z^Y!0WG!SeIWqTz606fTn{I|L(TB(cEuMD#8vFNGz?vv+nFhM*wRrOZB#)_B zG)hpZ?Wq#Di~!LZYCa&rc4qkJl~Yqy)3f;@kOrW{guNH}l1<~i%k%NXH}C6*|L4ua z`)Y#Ae>!Yx#H&qX7q?*H z;O2dQ#_RjselBlYcXygk?$!(cbglY$`{BFGz4zd>W+ zqh{mBwzNghFN=2m_Vf7S(8YI6yN+*JHzsW@pWjHs<)C~{t$FhtE4jBj)4z+_`*U-< z{QUXqeZPF=%C7Ch*6nTRHJyD)zPbBj7o429hK8G`laCJ@tr1ipRP^QrS=i|77vkuW`u!)yvD?zZ<;! z+@IX-m7j)7(#sbWK>S&TZyug|7&2c}x1Xvn(n~wB)Oiy{LJ>_0f2z(uyX|Ns+ zzb}X0j6a(6gZD~A?w7K`kl-uTE(JQ1yMg|%Ud_|P^Z9~>4<8@4ZXXvnR2;p%S+grW zOds{{*vQrQg7Qzcyr1_+%EGCW+YCpX?W?r|hTE^vJNIXOynDZH+}}a~<2kj0*ZOAn z5O%+;9lt!)PZc@OyNea+)m>&5`wtDDpEswjjBU(~R{k-^4@WlBv1T*iK@%g6(ZxA< zaeB(o62XP3i3T{T=dy#CHR7@Uw_bKiQ_JPe=PNC`wMQf zRDvIum^Zl~xwSq(YtdKCmU;7g47q946mmuT(?b*^kX1-TfleR^hC#Zar5aWP7qNsG zS%Fi_0HO-=wx#$(ujxS-lS76S0zX@N$)UC>8!=K>AAhh)@ zCfW2XpwP16v_a+ChA+Wur#MGc_#cf-HOsbdfeXSoZ?uW_e+|!esflZFCDCBj0z3W$ zD$JTD_s>9;2?Nxne;%d4szrAF2vnK=FRsohNR(*XvSr)0ZPzK=wryLdY}>X^*|u%l zwz}?p{h}lKGqLk4_sAJD=UPiW*zd$Y?7VE;!Z@Zk-G^$BWZ9NYpF<`-o`t1wGq6 z7kxU4G4lg_y{~%PPJUaEa!qxJ0QjIJr*+oG)kcTcqK|FXXqtvEhi_X~)(@lVgs@Tc zwc3MY<1-Nfgd#$gqk`KnffC_B58+~tRhGaEk_n{G}PXGB~o5QU#b z`ZXE1T@<{%F4#&kRP_9=^)vCKJ-F1>W+^7P{4H%pD*KiChb;FUt61->;dXrDso3}u52WJrRQnoBUl?ohWj#zJ;6d)d4y3)Pb?X`}3=bI|qIs1Zf}wv;%ju z5etHG!Ip7B3g6#??L-leZZidq&y&U4-El|MP;Qg3!9)zi+ z1n~e3`1m)W0xD7UQi8n`PJFS5@uKvV&}Og8@ZiuTpstq1FldePNy9Z>$riK#spP^= zl~6<#@W8{6%(XT{H5wCl3O0CAL9pth>bT;UvWtizUNN|8h%R^dPdOm^LFOhYsGzcl zD#D-@OyF=s&2fY|)I|`{kB2K7B0v)efD&fKnxz$jQsPy(;rjAotca5O4kEArmPi4JMc@_@m%Grj*@kv53QvOGe$p?|ur1Vrc;dme6B+rLzd3B483J zs2xih<=|LR3qK`skZNT(vaf1IL@XOx%Q(Dg zjnkk)rX%Kkg?1tR@dd1I!L6bE!EzTyZR%ATYK$R8g4$jXxiGK^zcb#FoQu~Vu)b<5 zq{w)_8Os63RSJ&NaD2dTBIktD=rCKohOdjwOvo_>@nI!adU@^oO;QV@!ul4s1LO4o zd4;s+*X4WgD1UcXf`%f7sD7Bey^-MHFh|DMQ(TXrZkWRldMFaVN=uqj!!Ft zK$kE%hSiZbkex1E2_*+4%=QTK=>mE!W;XhFeQg+z-2{S65`S%hxGt$LOncj^LbFik znad}l0K`-*X0-P`J+q9B8p_3cl#5SDMjCTjZA^@N$F-QuBKstZK%hMOlSrf96*8pg zFR`W$70?B{iySt|P-wszPRV6xR!a?>-bIjMQi!Kb8WSMgIg2IflD(q9E{HbusIh3m zfzD2`NV_WR8bu-&;n4x%=KDE{DjXVz5?PFwVq2|SSJBV7Z|zoG6)H(MC=MU99p>fK zS#M`^4|RE@p=xtcN7?Y}DK3mJV-M7+>o-XI6b)sG>sx3bU1fISfrb{t0w#p0AXD}s zya!0ID|C>mZ0hA2a%cLlj>RqK@D&-QK+$IcJtmn1@0hDSwz($E8z#+yaR<&kpAFhF zup_eCh@RY3@jqYY?ycKukeMYT_wUaG%E<-kD1ui?7nZ;YpdniD%8f*$&9Fjz6Q2iS zTodWERQ$m;lJ$dN3MxvuO?W;nSoBzvN3!ULI9v#<9hK2jK2%bVsz*>pN}im4I5_?| zywY(zkGc6b@%>mN+Yrp1?w=RukR%`3O|}@Oi_6?EDNR0yCs92V$&LuVD2616545Y- z)bG)k_DtI1k`chK6nW}&wx8=o>pg=TEikxXdZ({fZ@f1gb*upjqcmsM$FVJ{A4S(U z{)0WUfGTCNS+5#vOTT)LTp=f>h5X*2B~6;@x-8^{c#W6QZ`K8d*p=lTZ0L9j-Su$w z?)?^nXr}%V8~hRbm}oX9UJ! z@5|hkIagbFl2R+>4)pF)^_3BbG)tfOJ~37qsdPJ+`L10d_Vo%0Kz2KmctHNz^R_>x zv@Syh8x~nSDGc&N8Qa1h+KTGp-3B>p7#c_^`ltKd!JF%kCy}p50U{zy(w=wQBEC`> zOPyZ0M&GpTkF~S&K2M9LxP3HQ0??=H^Ld3Ay7))u=iSnb8oAo&M`yb`Z;EmD>`mBF zz69a>qXT1iHX{nAX<;I;)4yKZX$3(esp`-)j6Cow-sNK87-J=%RclVjly32i2G=sx&*Bc6Wf}MHL6G_l5^h?*TNAFn@=Pf4_B9`XB;p0 z$q$Sk&K?fWyLLaoXD;l$qTvx0XqOyb{wV-#2pId$r{2W{Lc8okqZC_3*?);{n*A%V zZlp6~WirX+a$IyMIusSqxt%E=(TjRar2w&`!Q04xL5y7N|0M#f@5`I z^_Z`(^|Cv;HXN=T^*U&FC}^k-bGz_v8onvHSC$lPV;C|8Mylz#72jIBE7<}dt~}u? zgFew-z;IXOT@1Ac_1`@1Y}?=Om3&^zpFrNegQw~;Uvcwl^cQ>FBcY#+Zxs>y62B)V z97a@{J6LL7PyjSUMGEIRutkhcf^!Od4{_wRu9RA*pfbC;vIPs0%X-0<&3Uxdi|nmu z@k`$9)4Yb<<2>#Fdcgwcj4$8BFY(C_x?>Z7t53$pS{B^%5E7E>w;N+{DLo1D7bHBg zHrY}JAnX~TlDKU0S92Fxk5K-Rx0hd{+Es~X1I!lLBM&2`Id7N^rnxDc^3h>39{X~z zv&85z6Jvxfa{0195mOX9d=C^b4Ne_#LDc~}XeokVC3h74%4a{DEE9|}{8Y8_N zd{ab1464ENO|=v7PF@J6a3azQp+Ey5Fx@6t zBlVZ#4(swwGuib=D2(_lG8|no7&nFL1cPQh9(V175cK!ouy(hrhT{=uUFrs&kp&X= zqnzj{dhD~EeB5ethlBqyA?e~LW4B;}W5T2jQgp z&AyMZz%9V$^M6L=;u0cG%c?+pWId`f)d~V{AC_et|HC?cyq%5UZ;XF55m2;c9Uht2 zHIa#f85~^is1*|xDi{nPl0;cSs8EnSQkEK8XIed5I!dxfZmLYK2{g}kuRsZZC_^GD z{yu73ges)^#WC9`buX#KgoPm#HQu?!r<{YQ&%{Ul(;z@rXWF=HTSl#yPUL%g`4>}h z)U4bOJkR#yUg(Y7lVxbMpWTO~agU%EB#pJ6fVCUuTK6Iw-3_qXP` z1<}sYu}HQS$~CU;m$mo=OY;6;s6v!w5W&Tq26Tk;%O{zJMV}9J0*c#U$0|O@9l{cs zN|DF028x&yqR}zQh8K*|RNIjJt1~E#@dqvlIyP6G(*g#B^hSpR3YT|vYes1(wL!Zi z?1c9t43yh4YpiHO1)0_SN+S?3XJJ^(IxUmV`$5fhrLT23>zg+JyC3leiX7<(#*W=# z5^dYpGY3;g7|!HAammK|5o~F75c&2Qj`c%u$fp1!Q()hN*|VY)lu+e>=;5IN)WQ2G zph6`0ARbq(WMguMv6tv{mb^TIgtp5``cHE$`xqR^k)o|QWO|bOf093yW&IKMOHDl) z|5aN?*D#Wac#CPvyx@}uXR7j_`#(8*mP%zU{;H!bGFeHSWLi-MuEr*Zd6};;=CW|| z{VmQ3%p~AqLS6OrsDplN*ig_36%3Cr3AV?O0~*W^ck{AQAEmrSIYA!Bk=aJTu%UJv zrbn&S`wyt3eGN$X@ki8sZD6h!jiaf znF#3ryxN;CvIN3f?ZcB4jW$`8&l%1>ZjHGmAY?;S%N7mBujI?$^y3)I>YAkd3Qx~* zsBD_0FYu}ji<|8Z^~<+d-(^IlHne5j z_jBfnV>%^*DRx152PGCbfa;Je`v(XdqEZUtI0X|yM)wVB+WxcvrOmz?;h3+JYOa$N z`!f!f5ouyMN2D-eNKQTd)$~=BBf*P&`ZDnQ3s43=I5lWAv+wcuD$CuSg>)d3vaDZj zq`ER+vIAnxDbfMy4d$Aek^wJVnC-oUxQ;unDFW#BM!`Q-%bUkTn49=TgxiT9s!fr$ z{wtqt_y*108frRPB)xN6yP~>OB~zx2v)b7Yzm@)Hq0<;4Q6h07P zDNDlDwN<$6wPFe?gSq@n3^jAv_cboMW_{WafoPhdys7=X#22JkStmJL+NK{UaE?@r z4NEq}f&-$<{%>jafdP(tAc6*&0^^uy??nhobdwkxXs@k5IlF(KznQFz9U}*tQ_ES$ zKzudZpL0NK4EG)PQAkd96V=dF&~AgrsE*=6n_|0%y=x%}=Oiu{rU9e;mu{#oi4t)o zQ#n9YuCasai>4{TW?Xe<6xzb)y+*&g{lm+94qjfB2N1+ldk0pIRDq1rz7R%Ullt1s zk!*_QHSwd1!(l`?3_R?z2;d`y%GKwyBe_)K=g_&aA@@X+o`=rNYCNHKRw!unL73_$cU3a-Z^@@I-jNmJ$d2j*xEW`J>SDq4lLSHo zOjfCAWwUkcsYD-@VytC>1sH9>12RT$9QKh$X)S#%(E~2C0JtJtUGvqc0tV``=D1GS~o5x!1m{ebi8OeDjLXdwg{Mo#cO z7^oL9JAP^5o-xQOa2F()ops6xBhEhwxLoV=XY+!?-#0|Ti~jyf&ix=dI2A*Jcr<*} zaWat!az~pAX}$i4W~KRc8Lz+%MKQM6q{~al`FML3Ezb#k@9Q+CsfVVs)=JVf3d^6B zz0>LQG^WdBhL^A1$infkitWW|G zE{-feRMS7!>cM5h{L9v@T84Y}RgKp5uWDB<-H+E2T6Z^}s$vpp^l({I1zKlCmc{5z z6~zXsw$&}a>uAh7upf)0W8N{$UL34ZTFDTEIM=}+CWu>6OLZ7&xHY)Rin%pZr)=t^ z028h76U-=RBkKOP1nT_f*8B1EX6$e;V~OgrNGa9v$6!tCtdt^Lt!^)XR2rWjo|bq= ztyCHr=$7jxI=VHwN_*08p;qH7$SPX8rB_Xp(kfbqXTr{ZQ?FMKkMt)gE~f92%~Jm? z5;K)9Qo3dURm`MH-?j_2(3qNCCvYGppms&~Lz*d+?Eg9ELZ zMxp3&hkJ`@=Ka+GZbqKu&H2A1n0V^& z4kdDm-0f3+pB&#!nz^!lK0%)Z!1?Oinf6UxoWvnQ$z3v*b)gQY5$P#@6kKysmRWmT z%NJh&u5||;H0ww@DIoU#E^|803TrR>#^$RLJ|IW}pRWdJORkd?&SqlSwH%6oSPPO6 z0WDG_H&V}soTvN38|fQrgs`u!6~2U&OU&=I;~`C%4aJN?z6As!pt+SHE<^`6k**}K zaE;)V+XvG7UFOF6ulTt*yD%G_ZD299jGYWUd3G=Iz2;Nju#+9`+Cbwu9?WR=zJEJx z;%8@d={NKi0?g|gCFAo|OLZ4ot97~bwZ?yI<|k9*>(QxQ_)T_G^fWk?Iy!eGWHN9^ zb-#U7;GZExm0VCFg{KMWauNW1z5ivWT)k-setA!rcH`(0xwVswQQ<}qe$H(BRy&#i zd~!X|`zfj|RWsbUk{K6YZ-B}^rgi4=a+roaWtX+|j*&D+q;}mvX0hyrW z&Z;Vj)U!K@3`C!Jn)-a#uhagPoRRQ~g7Uw5Ul3S2Xuy}8Mq2{2j8H@Xz>!A&vUE%u ztg8AqJ=T3tpr32`FJmmApLZkS6nOwUmF4vbm}oXi;OC0>`_3U=_HV2FjlDz>es`$? zc*n=bY|LWGn^5V->Wm~b$4+NWEJ1lvHPJM(oOB|tX&_d zcHzr>JbWlWI`ulx=twxSD1-Jvo<;UBX@@}YM?=1+2k}aLcOi2CH@TvTt8&cM0T?g0 z{ghC&K+0lA1_@}70Zde3^Vn|64TAAn5Fp0{(@1<0-sO{$ilqr0k;EwXU=ZtjtSo0j z8le$}uWt8os8r+zDdx$n8i7!El*(%?l-|#l)QD!Gx3k@4gJcGu{LtV;$ z8(U7a6iQ6$A`?3jhqgT1TbIdyRL+w2+oGo0?X*uDwZyr-Ok;G^tZa8+WAsY62gdKCy)TAX!yW7@t^6;(JYu*Fcn0hh zv%U8)-Xl?92{GxU1r1-KO?|B8o~QhHSj@4_qdis_mFo`@-YcvED!iRjgm<4jcH_k# zq@{;faXK|z{4t;0viFuL@tRFx*b19rha-1O(>n)iFOr7@W5v5;Hd9O1(vxWR76+dt z?NRI{>b$OoYTE#Y77fy*hCK8KiS^~tSkpq$j#0HrHkoOro6Q;(E3DQ^XYa5s^A%yu z2?G4-rsq*&b1igZkAQql&T}q*%Zsquj7Si6|8=aSan+X?5I(1k_*b3&fQC|I$mMMy zsu;YrhK@V61VlD2LU-e*SNXP2@!M;Vlaa}>%6AGLHYvi&+ps)`jIPwjYd;3v;VDXw zh%lOyPQoo4hq-$U3#`U#Hgg1AUNJ`b-EWvqYeL*KP!_={^(jEw#dnkoK$xonltTFy6(JS;nOVGE4+ z%ON(Y7_=qAK$1u&KBzdBUK&~o#9#@?5UPaAW&Zwt;?4)))$_lx)p&S5dd{ z4I$V?RdVhH6Efq-c9xISE#f`Q^>sLeNs~j1LjL)5V7RZQ;CvzE0~BYbxi&r(7RBTx zJkUa`AJ5J0qa<;na;bq7>4*r5frdP7rus!ga_fMco#J9uGS1tMa-vLb%v~m7Q`Iz6 z06`&F)u@eO4j*3~f(ljMlQEtU%nCkgHm@~}vaOn&T95RS}sZQ=}=4swCvhlsfc~e70%X#8ngSMq9w@@|;?ERQG`JCmo`$>xi&71a+1i3&&p@qcY%esZ6WgLEG%U=CwkXWT%th<@N$WC8E9*=2;Cy&|HDHk%7R4Gh9dPi68#D zI7tm^4I6A+t*vN4)PljozsRIecb@bK>35GFV5W?gV9V)q>Be4dU>cb@& z>%POy_hn1n0BCsxgS-tPDpdWP_xi~7l46}vU;4-vK}E8{gx%l2XT~KD*(H(p)UKzu zIj}cOIeJ`Dj@<8JQV(zSa`Bset70-qoCprnL#X0T1C0~FeEbecEkc5%I{WrvLk7ap zk16>TMcoUzu`H13pjG3MhvIXGN85!(iKkL6;bc09#KvQbYKYf(lA+v4HrDR5Rjj?c zj7KGNxR=Qr|6%)oBj(?d%_uD7piy2UvKoNNkU-g}?E;fKDw2%q!x|aChkE}xv8AFtlj_KYgCGL<-p z81MlTfvnqUo6jvNcM#-luGs@T`$fe)@C-{*hqJFpF-Ph1(EUSz0N#V0|Fx#Ck7LOP zmehHz>cq~3C%$S{g-Z1VuJfyG`LGY4QBzI3|3*vmolre1>Oe~N*TdA|`TpThfRO_$Zv_BvPCzguo0aJ?C}166Lm4UQraLJf_^Kc z$LTV#emMMv%vIGI039i#&n347m@;T1WJj<~A#)seMPE9KkrYaKUa!u;$BFnfHX7Ay z=~psT(B?WUs^USIaYPjiH%q!S4Kl+iby1a;X`Bs;N&L(8w6E!HQAP2w18{b*W02?< z(3F;6s1oZ4_i|4Mt_aEHzv~;YPGIfRm@LsNhp|wtTAg5j{l2hLPS`xEq_G>eR65X2 zAhe4kJwk;9cAz`iQnc)sUe0%f8(erF*x1wvUBBR%1%I`KLb*kiFIEtU$r4X%Sq;Wj z&JsJa2uyfFO(ti^6?o~Z;iQn;MN$I)ff`pbc^{+4irmomI-*Pksa!lLyjR{MfyBMj zO(L(JWT%#!Kk-vM8gqf&iMj^?H7LACAzmv`n7bTTnj2)m++OV?#rqxZ!M8W(cpo|J z0|Oh`rV~UAU0?srkYlWi_adg71v~zu0>BG~2iaMjjYiCyMz?Dy-<-`n1L_4L6T!|F zp)wDxK5UUjOs6VrFQULDA50Z{o5nRQmbA|#mix#(hMt^KUTcwY>=E>WTa;FQX%n&L z)prSq1@M%wt$V>5R|!|ZbOiIm_Y4FNr=}?d>o?R?-i$3A;J45!P>}TqZ{d4t;4 z>G8rHrAmUV;wZ#zAdDcOT~p9OQ*YUa>Y7Sg5bR?nC=U}*f+}4J4v`Nxf33z6ErC)ofz*?_bGG`Yj1E)ce&Y{B;I+4 zpoV|qoN!v~iEV>yp}j_c>?Dz1Cv9D-d1`FKf=nlZvR26{NC(nVRrDZJm5seA+I}!~ z5^{h)5R6yK8K@465f&H+8USPO^WPB1xR~=txx^@$7^YkiVeCvd$v)fStS zlRio{LSkFmk>@|5@a)aQs6>l37jjA?+Xn58Q*SMit2{XIHn}Ai1Eq?aA$YYwmwuM1(s0s7p6m|Y%M2eOQY>y*Uj3=fk983a! z*Kkx6qyUW}05Q6NOvNa#sck`fb zash7w;aXOc;@=mi_1fw);4edHGX#saypn;3f8keqdW5;y5r99eZuAo z1TCokmJkv4M!{VDzlff*OA;8AZcCv=2WTKR2 z$AnKv%M0Si^Ebz@oUim^H#+L#2CsFnnqPH>>B1{fA{Ue@jP7x5@4Qk|HtstpVgEjK zKsyuV{xOqB7w0DqJ?BZ~b`j^SP0t6G&vgiSgt-zf(ILxsGHbs~c2HRyTdW$#M6H8c zRyzByf>my{Kxa}=YD*6SzCO?;7qH37Caa%t6>m3Z{*|n#iX4rMj_F0Q*DTPXGAuIz zQ0c$g)w^WYuv9~StZ{)R%T2heuXF77&&{a4YH_*Eh$KH z@ylePYWeH$Is=2`nEyPjz=2CbNT{7OK{DPvyi~{k zU16UqycL|Ub1J6XB9#QLx2L407%Ga;Sj3J}*P(Yi&&{kCkl##_LB{HR{30zM`4xz)-^`t?xh|S zwfwT9-Et$T)-lWNw8NiR{7w+!zUcr1)q=mW0vb+a#<;>5Fh!%#-*=h=Ebk4Dx5W;l zd{a?rP`yO-yb|q)wLXi+9LO+pLNfKdh)Q3+OzZV{4oUdT_A7vK#zu~xlGumV@wdEl=o|RXz(uv|kUa`|@is5=+iDG4; z;V?@S*#yNEDxo9B>wav~lzjq{ZyoJaw=i##A`A%G7OV9ht-2fZ76hLdt1$7Vp4R|s zBCGPe@X1BnEL=V7td$L0rMPP3JjjQKuss4SRlL7xO}VRxXi73NzOH|v$!Yp|c`~-C z!<+1@nh#kI*ps(1b~_rJvRaiCUu&LI3gk% zd$0v_3;^yPJW-0(DbTav90_^pfKPPrzuh=i%-baB=^0zSJH?Pd)3xI!Vu6+%X(cVC z0&OnJ6#s@BL;}^ZfYtf>7cHdx`)uy#_Ms+ZXx^7*4Z&|CTSS&i*6u+UFaW*QgTO^7 zl4!JKRB(6-LZQN)LkJe!E88dIO%gu#4Z)38FSpXtx!hzomA`u`(MT*$5ZF}S1TZ-j zNdu!e1!+)z4$l)i$$2T;X9`XOa#DV@-V4|a#fuV24J%O@Ym|ki<~+|7<%Lx$ETDc~ zETIH9J*3y(Nqoa^a2q)4&{z|ZWDS!PIs$3~ouH&H(?(M$Zswfd9VL<@kUa&9i&XjH z09K<#!~gpyAQV4cec7`3tE1!S$$OmOrWmR*v0?6YO2K`y{RZ}rkM^nOrA$g??1BsB zILTCr?XB(fyG=ALx`PB{h9rLBTZLznBKJ$f5l~Pl*wn;vg~W&9yo?u@lB}%<(QPjz zp;g;*GST3~nxC&J|7haI(T#D&K<3(0tqWR3$<4ZP-XS`j`uego!R4qdi0yi+GfLrv zvjc|T=Lk8$&Nd_3dVcEM<=5lGm1iVfh7MaZs~!JBvk1rm8i$iy*T57&ZEsZoPotlO ztJVvjx@6eEJ)NYGdTU3f|70GF`k0a<(Ery>=%bw+(fYFY8z}1rc-)t zJ6kAVJB@|j(89Rh6RZ(kiz-28ce2kcjpQcZUp3VOBaewBiEon|8v_XTJtr z)pGsDYcd?MU6O#zwvSDaPj*d`-8kDeHZQGWNrT0vYN4e+G4T6R6m^xlm{EG|>av9# z<2hEM_Ud?r1k5YRZ@TrZlFn55du}L0RM`{;p*?@)Ml2nCw*%vKQQ(x($UY8EY;AU? zY1uiVg&(L_1u%~b_DgxVZjZdBzGNdla=gkWop3QD#RtKKXCFHZ^H-zOCu%MXMvBt0 zIV5;w&cBy)Fb@$ES|EOS0C}+sZcLyY@eO8i~)QhZ#BIG#Nen%9Yb?o%;k1M%Rk8(f~FAh>!;X+Fk*^F;uMhg z9iTA<7FVCtI{s9l3qRv=TMeoViVv42KQg!xT>airZMqB}@kJUlW0G_8|%-EfFEJrR6+mCrRn5@lXSOU4=r;t!_cxF%n4t6IN}8VTyl8#2{C^ zCz+l;1AoWemp5@+;?R>Dq6=_=8@qKBh61>_hJ8R7Pl6Nty#?&RpR@7My8cU(=~Wv zm+f7o5VYx!CIzobj>?3l0P@Qf0iD|rs5fIr=FiII$=iF#kZ5?ST_JMKGndZva??kH zx2bF^B)cm?l^m0RkMv(&9@%3uCY|?4(44`J9Nel>w;sISWNxjm5>XI5#xstv4v_Yp zZ9*bL-;CWDg?kZa~NPGxMq>8g!dY>=lrHKC@lg;=t> z^_C`b^jOkedy-O5xCd|4ofF|32t-G|-J8B!97m{-XWP{nvyC@pQruLut~J>Zfxdcg zF7dR1@=>$F*=n9P+7={XUU8@F8V!`!;j;Fr(J}CfBNFRNXlAF?uFC8;ffXArcM%`J%22!UMB{9$tn;N7a0in*v3XfdehS{TZ$EGSa z{;R`nkE;r;4b~#h{_5#Q@JF&cHmiW*A>b1dM>L=*esRDg-Eyjj$s$`L?G(7k31o^M zQSk2EPRt{dp2?oDqJlj5oavuIyLUM-lM>6S`lM^H@pq8lDsF8feo|~yRu-e-UYy)b zXqM#Y4Av<?kEvn0#prLM8fjJLPHRke|IlY74Iku7(OarVJ$2(*oCTjjh^y zjpJ$0=c6jV;6_b8StJ$fg}601T+_{$5~TcPXjnJ&H5~l^?FZ_Y)$EvE$!0#sBKP%>U@U{ncN+2b-Nu zw#UzV@GUw1{}kVh;EBJB0ka7jT&8&rC@K>yEzYa;|N50TVZUx=b>^mLu@a|v%_Ew7 z_Hy%V#VprFN!|2cC;3bz%{x%{`2_ z6MW6R&^M@EbU+-1!L)d2U2x(+%VY8e?IXBzcV#LxTm>sv!D7|(bBJBG_{e<@b$1d{ z;@gFWk~0hzyuD3%~8F}?EbGD%q_f^&IMvkG>gg^6Bl zq+I3l0n~-~0ZUrDytCo`x_1L4A>kvWEG&oZ^9Ad-_)Ul|2dK%Qv(PmGNSyqBbr7Io z!4*=GMg%Z2`61W-ZG;d^jnaghPUJ(8E^BCQ(ILbp`=n(s!Out`U2}Hv>8B$R6T2`Q zf`qQIc!HPIuCk~Xa}YLVJ9e+o?8?Q6-`O^;eB}&D97aaTSQ>6w|H|BZ^I*pYBOlM$ z;Wi0q31I*pD}`(kDv|CZwF_m|8=KjubQVZtFSy8dq&E6UQ&2kvG6dR4S#MRP=qr?g z8F`cnB9ba?t7D%o8lGe$mPlL~VE;C$SoBdbTgsi>ZutM%q#mk-U7=e`yD*1;NLrAJ z1WBhOoDjY<>MW6VZ!rvF@|U#Nq6SxRDNpGFdH_WL7Z_ymmMDJHThXrW+yy4iI9K9e||TeHVhaz(%g6_*c&ka?BqeWk5A( zWCNgxIAd%~~N{ms*RvF&E~@3k$n` z1AzUVR1Y_dYn0~Y944ZW4eOsBh;P;WdAm_&|Hs|HRVfdvPj?7@J)jv_mr^{@}fBJSLc8S{5?laV4LBTY)=(4ym(Pq<)-H zsN{sycGx!jORG9sq>4EJzkxqVt;DZ`fYER7IFBW4tJ(LYhk+MV_uTj_LA7Q4HCdMR7L#jdU`tDp+=6G zul@G#O6z}fYwt>yuEYBdAM#>QU8@ux)znTh^>*~E@w_8>dMJ3=dOAN{$|9bblI}8e zM;u`U`#Ev`ObwirHNlf?Tp*hIZ;A<35A2=t^cVOX`~L$mQwXQq-J(>;llEaLj0Y=7 zu6N;-!_yEY4i?Y3Bff)R!Wsh?Bfn$zCBXv`Ack#3Y9@Xb-#ZyB#CgV~%*t`sUj0KH zywHUvc)?9BA!m768Jkyi{JA{G<&(6-cH_~{Uwf>$kx2>QSxcT5ynqsEc2^xWTA`Fb zz|vTMo~~D_I?AJ+UWnRAdrI^k)@+pzO337U#j+=x4zzBGa67>c_xkf4^0p@ftIZA+ z&s>Ncer@*6nn}wHnukysN^jroz5e*cmYp_CsnlBvo&q0HX*THRM(x(cv6v=)RA@%` zWfz}AC?=N<^o$;nb9M1V9ofi&U3lv(+>$hE&)%y?DVsOhkKPg z(VM*p&vrnxdtK%O0}1Jfzc5?y!szZdmN?wFnY#T&XzZ@I1I~vXrL8i^W)>-YM<{Pd zKISxj!3Cq9+#uu|I%?Or!iLKPIW>gI1bI@?x>BySL!*LxD4{#^cNa4sByM` zE$+{hDHK4}q7zn27A3(076yRvj`%fLJMYx79U+u%vwLJZ5EH#y=a4j@#t4ZDR#Vy` z?7!H_3F^B}BJ&{Q>%MOed@Io7VPNxoJr(}QHwA+bV16kdRrICKGO`Be@B~?hrYz+# zYeosxwSUtG?&*{0mMo^x3`;bEor1qb-8nY-*k~YC2MvXQv>J$j^ogn(fQ_?gQLfe>-=6Rlh0ro#Jx;hfH>wUny-oSEDw;7 zHmz&?OG*|4@%OY>D8_hbYUpa0d#txv{7wCQI(#GjYUECOz`R!zH**M*bL4$+F}T#f z>$kOgbbb8ZM0Y2z{|Kkk)dP+a_DMi9Z8%b)T{6NQ31+)y?THhlR@wd(_qrtYrrs^w zM9(wCMWz{jzA}Tf%|f*o8uh&tL937Anmb(*gNa~14h6&vPCqMTYe>WKmFj--4l ziK=zmg5O+Oi6)x8fd3Z6F|;r|()7J)UpnG%oV4@74671h=zze?6##CfHc$A;(EU|=!0_A=5_v#y#%~-48 z`9%5*?c7%VRU+gZq7H#M*$o_Lho)YXM|Hlpkp(+<@D-ykiXyB;od0ILAsfc=mS&VhfK zzhaL0xE}X8sKxq-B6gs=e%OmeV~M%(KBQT4)56Epm;#ok3s~Pc#_RH+&gcS2ty1Mp zjdtT`L@621Gcj7zMm|@AR_ks1;3ZM z9G4Xw@~<+-Ik_dX(@O?9Wh;e&^8~PX&g5x}P4h6ELF+LNq%jReV;p+ewSyD*kIhT- zC21^~tG!VWrmeublqZIPU-=V?k zcmxWUins#MZTV~4c&J{@wm7I>Tm>rela6*hPU13;SLdb_@80waqY{S((}Fj~&NjR$ z7fJcqjbzas0AMm$k?I1BzzWBT#H`*jT7DIdV>;p$Bo>49q^(_uxZ+8bV2V-U31(s} zC73H`z<;5g5kZ-8=48)bjmW6l4$4y2uQ)@N@#}2KxfQd?^dyrfHnG`)3eaBs*GJ;w z2woUrX+7}+`8f^L7NvQ}FUQHTEowMv-~~O*bH)Onv$=u9N#V}o24N}j!K))ON47|S z0KmnvL0W9#GK3jK1^R*@b^Zs7)xTIo@fXDBHAe#sJ1x-=s-wnVCi0WH7Gtr$ zu*|Z^x17zeQSzv!o(3KQ0dYQJxTq+>EL%o3%NR-XHxxn1ZfSa(3%RkUyVz3AMb*-e z-J~1lKZUBobt?dn!BvCny!8wRiqyt4AM#E~{J02L7@;u9b8WPJK90(+hzN?gCd6F>5E%_>UTDTd;37u8Oo) zbTO5a!Vw{q3Jtula3lrfu-4Cwkrq`=ZE#jvna}wrja{9;r>eVNO|mFvXfWB+WdiFy>ay7C1u{qjE7u zjHHSvE&_?4%!RHUQ+P5|yys)^W>H=;-wh`XGv1C!z|A_N=6A#Y$ahoPoYjOuFKSI) z=$zBAoZ+1NApOQs^Nu^#cJ`K9P$>c9TD|NQdNbNcUTV`BQM!zq3HhE4*U+g$S}+nm zlqF5S{tsX87$Zv5ZtJebYTLH4+O}=mwr$(CZQHhO+t%%G?|p9W&P~qGN@gml`Z4OA zV?H&;w^sn#Tjeg}3(j7X2vfSR#~g&yPn2zhBW}vsV^ZxjkBlTt^=Ay3ACQY+vH+gz zi^0d)Z_gWc0)PAnPwj0Nwz7gBrUS*$pnT~Jrd4u8`_mu($?-=OGOFF3qsanz7~z( zHo&vRZn(3IOCTf4v4T zwu)2Mu@m+loUA=lDXBMVSxIcg{FKiO;jBPQY*6K-tRl@sLAkVfH()!@RE)fD3Yeg^z9;k3p=>ww(0_%?c3#Kwqb2Q;J#$*j(Hh!4w0WClC8r(io-Ho6E ze0EMb*fy-9lwe3xuJniHi$?V@zVpjrih*j~e&qT%o%BEp=}7D*CG`cn$gDj`^iWLT zv)p)Hz3_TRY31Hc|1l&}EQYFmV3j^}Hu_iL}w zB*>NfNWDO_r||QuZU?B1xnS3W72=U-meq9WRIFvD6$)chUlazTnGxTNM&^3@-rR=k zHpix=;oT&mYmG%Iw~=xNCXed8ZcCkmY$wHlYA%gm4Qod26bLUYJ+ZK;cv@<&ph<>x z1LUVfp5m|(_DV1lRQhU06*`fwsC6;{@FPBF5eV!cRUfyW#VNgXEfN}p5CVQS5&yeN z;3nOj_y)C`8}J1|nY^Wz+OSg*Q?08TOcAp%|`BmEv>)3%_| z5dhZEPvzf$($Ly&z6&BH{zPcMuISj|1qVi@5 zrD9~T8h`k(g#(kz%r5h`!Bax!!Xy+stX23HAz`YjlPE_mFF$8u(Jcp`w&IEF8)y%t z+eFZqI(&szEk{tl&MvU_QN0Vk-zX*3ug}cWc8wNP2pmmV_)R!AN#hU?-u?0OLVirm z{L1RsQ`bNe6k9JKL&e@m4tnski^#(3-}mnC%Uq`b7W~Dr@kno8`^O|hS{NF}d3G>a zPDCCI#NjFnv=QCI${DyUu*HzI8w$LSFE3OJBX{QtjT`Sa3c+Splu+Aa{jnp;1um4Q z7E?7i8)Qov^_XBc2saLlq*BRD--hWfW!T&T5-GLKZ514qv}Hv4HxLa%dZf~yUDm*8 zpds-RI1+TvOW4PEn*>|gkr{;pK!J>*fD>;)HPEh+&T;@ILsM^n;gxKUbxA4OzygR! znorzpiB+@#>mNNL1J`C^bZ=A|CUGiFXDwXNr$!0dp$Il(c>}2wb}ya(#sPOCxu$3F zrV)>qiAZ_ii=&{))H8mF3_Nc)7)u3kIFX^E6mF=(rh!1R>~h{wNoPS{scm;Z25%x{ ze&1dKvdEDpr3#MeN8C$`aI)^fF0WOvq$495(^LUkPJf#Yzgm)lV{|z?SJVM&HMwhv zU6v!LaLd51Qt4*wu&A~+(Nc42?>`+n7gQ$;=+?)OsPQdgk0|wlELiNq;i>)%;oJaq zRmH8^-Md?t{Z4&D$7SSV>$(r<3FJO&)!V;()N{%yX7^71|AfC^~v8lbmi(KcSnr#j4!ktS$w`_K>C2g;qU1k-UC@$0RJyItYV7*L1~a)+yb16|lR2 z5$NS?ing=sL3A7$f?e+0%tT*V{?#5|KW9?R0O$iJ1Q`P{5SscFlLlADW~_P!|x}#NB`y(Rp8&eL29x3C~tD)vPmT?duZ!V84MUV6nEo6n-(i zHP(PjT^)5ht>xE%R28n`-vZjXIMiYSfDuBl&TKOc#%0xv4%yPOiE|RdA z_z<~Hmpin!$SHwpBU?RQPp9Zk%Yxj2z~WXNu{tf}4`0>sP3vWGm8GC9@CF9D(Ns#Z zx~+cA7Il`8vUdzL^CLdYj=RVo=@ppEdry|qLB&GUy$ApF3PK-vmPfv1C3OvWM3t-I z-9EIE8`74ajB}jHBj0gD@Qjz1|JBD*n2umVbIu7sCbtcxePO54Q<0UK)8-TgOVr+u zT8;3v`vQbr$#-f6SDayvyKlq3y z6WD$l>NrVhei(6M0}pLSZ#rtYJ{p8U$KaHK>zW~_(swPKW;R(ocIGEXa_O!M<20Nm z{=_%*cHio0-E=(&lCJrH?Z9kT!3!vN^SP_}K>jSix$>o)xcuIJyy%`{?BTrn!`vNW zWpCkU^&!TAhPmfH593>-I67P==NR*K07T7n^@pYphyLXFtSX)@dl&vimbs7DRc%32 zV*Zo&?F_cyKgo$wsC=wgp`LiRulbKt(EPe!q%DeRj}&YekV%h#M9 zz(i1DF{HKqF<_cxioSCfi1%+u68P3jPlns`z*(g1&c{e(cnJ6|YOH!ye?=Df^MS`X zR`SO&JG5R+c5CqLQ((#fr^s+M#v6fnXD0Aen1+uBHgDrfttKWc#&TPkCe5ikg=8+J zzKEUFZV>X}#bu~Odu;TmI=kf$+d-ZjJXcc!ITmyIIVvX8rM4mEr+%aQU@|MLMeWE3 zbj9`y{J_50!kA;$W8uffux;Ab0L+$E!0TEc?kl8X5yTMR`XqM$A@ zqAxRQLIZ;4U}H65zLpz-eBSSJ_yXVJUU z4PQEv0&v0NDK?!*uch_TAhuynX5j)_s{ZX3aNbU+*ydXy)8u9&!-t_&cwPyZ6#Y#d zT*MU`s9~rjy@oSK%nK7R*=`ag4gB0XoC!3fPx z)yj&(M@Y_oP0ulUg6gwar#U_lIYA+x6y?Cf-8Mh+)nm z>uhiBIdRh1rR&?>K1OzD&gz6i4xq$|%r}mRVa{L&Pk#X3^8-X0nj;qz(MMs|P44Unz!Q%=}n>3YP&FeeMa^ZJ3!Arpf2p0+h7D7u!3_W5OZUHSH3u|DOGZF zq^k^tS_kBA?l$tSE^z^;SgnLEkUeGRSG?7n*04o}Ff;|hU?7$S>LOCkDG?+-Tt?;&?3WBQ<0JMaK%1`upVm7vd3yjR7NTs82&&9eVDd3G% zjBzj=uA%aSBm_;7V;^A+=-l|ZQIBhSP?AtBP;I9KTu=Nf!J?Q{^KCdJxxyp^mSolN z{w#5Dh~eymluw_IO`doAA*eCa-mcH)&yQ>OLlZsJDK*+uDlz*bI$fWRcN-rMQ|}cu zKl+RXXE8E&7nXOAYb`a&H9B11=ahOFx=B4=t=8LxY)jrQ-!)GkeL5;BDm_`SJl<|@ zPG3A1JfJxl94p<<57jx=->t8%cQr5LGCb8a|F)aILL)089xfaTYkr)*j90f@eogo6 zf9X9nED1e5hXPqyJvuhqnL1BZoSNRbv3PU6JRx)AeyI5ixcogMw^NMBux2xm=x*6t ziEucFq?TkG83nb^AgA`I#a4vj1~BDol9vOXQ0|&P^=4uFHBTC}q)I6aInzE*o~5B^ zK!+|o0k%5l)5LXoJNr6;Q|D|M%#5PFT#PT>=m9$hB@$XDvQj)nlhqEp z3;@>-vWzyE>IuvYcImAiN_yCTUN4a5?g|N?f3~cXLYqAA;WS1Q2j22ikt5vM)OOpT z$Mw+r8ZfI!OeV}hvi@mJ?yD=oG?3#Q`>G6d5bz3zMx6kT7hx4M{e)X6mzD)knfhuI zG^5=l^^cG&lK1k{yN1Le0k0Z0EJ>0ku_M8i_VNOJq+=W{6&9bZA=+uYU8bS+H1OSF z&UV15ZoPz1HXg0SpiZ1jm_(%TaC#(KF|8GtwN#}6N-^o)&Ej<8C zi<^&jrlQ#1G`Pkr<@<2&aJsg?-F`d;u%N1d{cY=PSM@;}xB0NXySHgLktmquc7L+A zQcPYBJhQTB9a8SG@n){(IF>r+a+&$JQ;F;AEMxvORlYRO;X$cA=+mw0v+8cOHW#u< zrLV)&gd)(Zb(3kM_gk+B$^SZfl17mI3}dRDVDN`bGn5=(@m<24biAP18i#Vj9y)rR zKn*lL`_FzT=nRq!jArK8vZmT^d37>)9;dBNs%8gVXlIiYHW2&-S_f9xg)O6e=wTjH zP|Tnb2L^c4_n!rkZQ|a5i&euCbcokGLc6FKVGL8!?704!Fb$25NF;Ho&Bk?;Oxf@g z5Y(sx4ni@B{?`0sZvFanhZGQ|7dfwu&KEwYFQD?tXEvzV##IwZ+j0MWp@he(xawmv zF00D;9jI)sG5=aH;`z51{@V@e9M07@92gCM7L-bpbRS{wn#nHk5_<`i4thso;}AY|IZ52<2sGCdj{B= zg}8}tjRGT4={*K{!K7F#TN|;RxcT0At64nZ_gFY=H(%JZXVbL?w*%n362kU8q8?As zT`Wb!zzz9c2O!Xy)~81af&}#&?o<#(1X+4ZgQbRy}U@ z!u&deM0j+HVVxYq5|3Xm>Xg+21XM_Bz;P2$VPm>xzkkW^w?~as2{Ori>j|N-PJ1xa9FfUoUWParymz&A;CF-iGrPya z9G$z2l_EZSL^k2CUMY9(2swOu2+v7eNPS@#`}Xu>P{^SR}@-eI3mR9Wt`gw|o<$hCfmI|BrD zvK*avlX$`l4&Um##0A6EebrQPt!>C&n!R2w&x*7PZOalAEVVTO{)`o2YQh>p zx|DK+C-+RsSiN>qIZTFRUQaQb_(v`QD#XECwt^-Fwdu$kFd>pAVWs^cK1Cb>m@=RZ zN+vZ#?l*?tPH`;ixe>aL9-;jD^K#77J_mqsGL6_qWK(ElRh^@3j0dt*c(F1LsRHM! ziWo#Ax42GzFRhKS5qr6Lnhbe~r}#1~&k+LN3_ZWva=_|2VH61X>OW|5$3H|2W{`3_ z@aPnLsc_3)&*Yx1Q}mCmGOUlUxFl(nxyKhJGmSNM`D;r{M1t3R>lS+yz0yvr(&eR9UYAo_>=)dIigUQmSEskeT<1~ZI@Fb8ZewYDu zS3{vB$*R>>eB)C)mFi6sPVe5HU^7lmR&Ex-399A^J>o}u1Mfv_BXzI;*6%3|ty~ee zwvea3BU{Qyx2T;`1&>fNTT<%!O+OrppNCw^G_%Nr3eX0Gfvj-%Kw1RLIriwzg2_v8 z(yo%?(5&3M{={=x*b2XQ5a6g!?U&zBVdX?LURtO`*M{d{^)DS_Yxi*}UOaXqPKIs~_8h@%n{jHW+8vVVbmtDcjckz>|BD zFzu8(Z4`Qgmjx+pq5`VZK5f(rsOZ|thOTfCG`h`>I)2aY!Fi@>HC(D~)C!&@aq=Ra zCE zuD&>=#vB&E1i|Jozh;(ivFWiu!K^ zRtV?!%bk1|ixIvd)LizE;JEPqVkxZZ3a@N;u7O^{5Je<7p0zWqc}Oz?2&Py&=wj)p zd!UP(B9h7u-aDmjG3Qp^1*9YRa#zrXbTHgQT5VAjN5FrPQ;&}|Fyw4X=t4}5w1?8P z^v*YHcqyU#ky$bJ$-)^IK(drK{!vxl0KPaHvQ08^pI?M3OJ(9k#;6C_Cu3~K?T`h! zXwr=STsV7xs8W8yluR{pU*HuVo`E-D8RWh+hpQBItsER9sa(npplY}nXFR{!r>{?(wRUM+Aa9Q_iwdf#IG zTR|G@T2?-*DIM-5q{bdGD{C_K67w>2nDM~fo0lJ{J(7P5cTU>;>-TiE=|_(GWeUDN z;_Gjv2NEjOY0Ps^N7M^FskE{i8$L_W_3!NRe~wsh0`f8}wYu@FBeqBW1E^@h>mM3| zjfv_bnu*Gp|GUX#GbjvkgK>@rXDC9oc{2L4;0BFG)-QwDCdlTVu!flU|c#cwz1 zOOs@Y_`{BQImFknn|YS22Anf0dQkiv?l=vaCd#8Pow*6ia>ywwx$DYiCn-eKa-xvC z9!&={6i!=Ivvr+t;JYii%szvb8K$2^Bxr+28a+n3n+9f5$if*nUA}me35ZO)=?Az{;g4nqnO#s9z>UZR(GX=14_@P`kQhxsl)cQUy_t zqAk2+Y=}Fv&YxacDo0N{?kf7vbRb>GK>GTlTT={utgSFLR=F>WYjyKHJ~5>tvRTP8 z{cD&3{~~>gKOhzrhG@y2>Uc6yWkONq+d|D%cB=sl?r{5HO{6~}n?XUrN`6sn(4N|%q2Wzkz(qXSggeB5{_MVbp@h11Vl70*+_r_r!#Vu!e|_L5vDI_ZO9OJ9~^1jv|MLA z)}z&P8p~#44am`y)|J-=ukt37Og#gP{B2V^rQPF;-D3G!`WEYh#T+aad0GE1ztC>S zXP@mlKC@(#Ksh4P%3A7$B{fOF5FaKYS7v+6K{Gpa+N$8SMqSkS7ZtClECiutBibyz zC2=c6VBd1B#7`r9)7OrAALfS8w%P+?WWP3V{@1~|Rr>o{&pyp6fzWcSsP#85al`cp;A)j{~!(zFMjEA-$Vhl5o>*mG4afUSN zO4hYbWA7YxprWqLp?{N((=x+Z0QqDvjg>QU>&EIH@lefx({F4*ppkRH^iv(jEQ*JY zM=VD*SSq0o-zTQ8%&2hNRkA5g>cc^TdO=~9mj$gFf^nc*evxC!7tbEajlqZjM=T5^pDF=?r3yrRINP{2%etooZPeV=RD89Rd8ameF**fK_g+0Sq!nBHT& z&L5#EMdXYupnh1?gO}opR<`6_&pp5>(K(V7#_y5&?0ym#?9A%Bt<;sN5H#GabK6FOD>UpgKVfrKCcwT|RDm2uZpb;!R05o0ET;ug<#UT;pMDkD}&p z<>;FWLGnnriM2h>oK`{|2TH&2DEXSdC0bZ%_#=vXC5)5H$ULXvkY@TuErHjNcPq=N zr76Otzehxt5&J+2$V;{rv#9^;!+YP;+lUUXX4S6+^Uc~LeBLPB#a4u>)8q5=2Zni| z1OpzAo-{LeCntSM?dRr+N2iiU)4S7@ti%Vkurwjof+u*0E{V9*LSC(Zbu0z=hxtv$ z<9ek68f0(^_eH>2r};P?q^UV~$Ukg+`I9TFbP#Z$yc!F`49_sD;t=+jQ66rt@x=>+ z8^H&@B#z3}Zb&A;@cxzQj?@OjEH|eXyG{gxhm?BUeo~Y>NJt(y25{X(3IX`Kzys%4 zfbeZvY+3=@@ew|8DRYM%V@u@C${_~k8fi~k)O8Q~a}O9qDed@7gJe#%L@k6)qb9s6 zwpM3J8DleTe5z5nun_@?E|aqAia=ilWXO-|S% zxBGEd6YSd@%*1iar=5PDfkIgnf)%{EwE zzo?HCf!Z+#;7M+}?B>>-EZIBWei4hLifKh;KXquUG;TVfT-<^YY7QkJ%_t_Oy0wE+qPrl69EZ-wmfQy#0>UA8 zah}kPLF{%4&jTe_@lp5VH3TeCjR4s7D#>gB-c}}c{3lzffV7)d2!QKsE{EgnfANw# z==RFfN_I60E#UW_2GLlW!kGy6GXPPOx1Y7-~70p5A{1(ensKxV8=4^wqGTlDc zNDOdzcLSFd(@Hh#Dw!6)`{XJ5B86Gsbxvbomm&9-C6DLr^Ma_e>pN)JW(OQ>4$z;vq1P2TOw~KK6E<^t1`anc1;TxhfLz2n@H@21h z{s=vz!h8gML!=;Sr56l1EQT9Ch+Ij#84V19XI!vzlMar^J2A{rQr=*p^l*FO6naOU zrUvSFT?(pp{SZippE$_hwP7QQAegC`@h42cB$QHo?4gI>z11}78od}PSQiyZ#F9Py z%5Tp*eas@+l)d^&sjT{loZO&Tw(r~X9mODPJ7NRCG(mG#_i;Bd`bT?Jy*sS0cvHLd zwIxR2cQJ>b_)#NpJ!b@bdd}Vth)^cFcY8?CYdEWi?{97OOK#4bmjOsk23&@Dc*{iN zzj4lT0^m#keV72myc1=v*|MS>h7DlmFT}>op%C1*XU0E{{|sGy(4dtXv20~KG3?l@ zVXj#l-?9OdpbG8=!mlA^$s+~@Ol{b1{@R*cJ_IV(D`3YKb_x~7|GQP^=HjFbOUOT~ zuhnU{qLv?_$wdw%_~a$uv3LV1!ad>28`S?t~_BXUjiO0eg=3% zc7d+AuNI$04X+hf=q@#GpPrY^q;B*ZsRe8hRJBp?QD4(K%@O)!uoH$_#AnLzA=`}? z1ng~p#6Dk|ku^`W%Dq0c{^yCdl zdL*PRA{Rf(da|tvRw+Y1t=7DZGvo;eJued`4r1@r3kN;G@Njsel7*0X<;{-4gWyw= zALp5y^0hLkRmC{ZORUGCAL9r}Iu|{A{VSRB)$je+2mHzo--!YRA7njN+dOA52*9>w zS3`tLTLvk|JtvHRgHnr+HPEf}OL#)=RB8ySWUF-Y{v(|FQ) zPA%URv=lr9#lm59m`Hg?Dw33R&aLP*7#sQrF5I?wy#O**I^&D*S!k-NFqT0qtRyu0 zLS)4kfG(1H%j~yEV}fvRYM409M``lMK7;fBhZ`p)L75$7`x`|L^1^Z$1-)bg4Pry4 zIw{oqzcf%*D4sORtc|9t1BYD<6SA*$6&S;x##Wxphk`hq=YKSC73c)&4V&FG112r- zcpr+5^1rEZCE8iOxgOWt&r}%DMaNNQyNtGsK`=ERB>%nO|C!3^|9dKT`>&~7R`mZe zmCO2jRAr2xMDv^dfj5?>WF^5F3;b%u)^G6@;di!6_cPbAs~%fS@1pL^Lyyb2w9e+* zwN!4B9Hlv6JKN->aUb5*OkBen2?5CS4R9gIOxV5^WS`LHc-a^rJd!inWogvoySGM) zNfgnBe0GjdK8{=IZRO%b0Nn9pboX#AYR=1p=UY*>MaUgN)*4Qb78t!M zlJ=D_k?}K?DqH!7g8n2>*Fp%o6eGGCk_rk55;bq;j7gIv%lJ&A1}Gt@3kvme*Ftv; z5xK~IF{5~4@^E1+)R;tMkiB?iH93*j99@#$_Dygc@ChJMFtNu4s*#^Xy7|dhWBj2? z{V}+TCEZ~4p^|jK)u8-1JGB)zWXxvjSS~2Xg#w;ytQbWZDL;utXtMhLLqQ8U@EH>t z94?>d^SKF^i>D_>iTj?%rvq+josyU*TyOE7@AQyyy{xJ)LxTi$@&BOX^9~e#Z_sJv zTvJSQ;Z{hiXsL2CW!*4PepElIHq|;c-94U!qPx*R8E4xWtRG}ae2$G`oV96NAJnnJ z$I8Zf#Pp2`wn8;&Qi~xE1Q;g0zSq>!*Wa?bZGEH2|J5Xj8f; z!bKJOZyG#fad%6y@w2pFj6%YKSbFrx*jxqd=5(<-w!4Q&6Tu|1OB`L9e(aYguboR1 ztBmNVdZseoqgOu(PZG)=t`j6X*thrk{0d+E!jm_c>%zGz+vaZrBaA>BS$(=~ zAyc2>z0vK^i$Qef-i&CUVYL<#%!c)GS~tN`BO3GK{*((FzRQZqEPAiLCM2wI`vV~+ z>#@kNt(}I^&r*fY1_^DX!gV(PAciZHdLZT_k=kFm zfk1M?N!q{3AHZgC0jAb)m@b9nTfiB-Yxe*!?JKjv<=k;bbq!%obp4((#n>4KpxL>D z@tZ7vt>3|)E*2Q!UX*bnHZOBKIVyn@)x4R>xRVxGP(DhQKwT9wnQ1OSF`7~7vkgH3TbUOI*O*<% zG)1>Uj(JZyEB>2=_dSN+bXAoVU>M0oTZNA@8+LmCqYJJj*a!8iwCvx%N_)*(UgYc% zo%aO#TL>PlSR$rv?D`zt&>k8-I5^KfXS-#!x%D5}B?O@{8^uUd@+A|&ZF*>8;AT9Q zjN!d92uA#$=@X8hs*^lj*g*YS;--2RaS-uU& zQ6k0=qfs?Bl_6~Lt#W@pNK(tWvSoOO8@4jZ(*lE0%U%N*{wG_Kg(-qvb{rR!Oa^Sm zh+PZz#9i(K5CCNExM?SFl6h*&IAirvj7I&9KeW3Ue@^`L0SR=r_?D4l0XP0%7=8a6 zNU*mBbaU%`OZkS+Lo2e@E~to~f6*UBFI#$PiG!h9(7j8r?uev)EKITSV!aKNKSKB> z)cKQ1s_^YjIZvo;#kb4IRPMZq1~5;kKt@ncyex$hgg+vh9-hVpDi8(R=)a7QMg4dd zR||#8;V(r5)pOI%&fB_Wq{<~|>U;w6T3*yh3i@;)K4aDLppZnwH*qHVOrvsIdBBblK%QfCKL*NI_Z&G{(3k+JbNB^Y-uDq~Xu9UN z3KCZt(Jk&@UM+vm6TV%)KJWD%z6Orp-kvy`E==2nkd>5RoOSV~jh0-$;Vox@bL$(r*73VUnE_>)jnSaA)@Mia^W)`dVJIQwCyn}` zoI64#p+TrQ)ldja>bL?bi5K+=Ju$mn^N0aiEm2ybcb`xZAjlc&sPyor$2uzYDlQ+q zU|l}`@V2;iW{^+;V?+!j*IMaN3CUzCZ!RbyMqJ~ba=%#EtU_KbZdo($FUPb)B~fsA zdakliS-5~k8p4tCHJUn7t?s|6FGyn*OT#JL)2Y}d8l5iFK^Ke3&d-g@xBI30rwWmp z?J8)amT+624ZKB_|9}DfTF9L9#^=5PB$urxSXU$>V_muC$}5h*?z|8%-k zt8s>6dfea6_FG;~GM-d)euhd37I1U+SJw8=8f>PhaD1MUcwG#Fdb~`TbfsN)vVJVS zy5%lkb@Bqgow+|>kB*j}eky;kJgu*FA4K>lUXUdnZtk>Vk5^PuZup3f*`N&#O2=YG zz*|wNTb_5o_UQ%ct_|q2rV*CkPQpgbq>L(SgH4Kndz>0H*UtcORhC#Vy9I!(h;2xz zRSnolFh)m~JiZN9aP$5C!|K0rD80_&K<_r8zUl_H?!TCOMo8}ln9wy^WNpF_s^kEx zOb^nGC4sn5&*^mkR$~qCKE0bS+}iH-=@Q&uqEJLfFFT6N*|xTObpHP-eQpWtAUmiE z!tZGe+P7r4UrK-B!D0vb#3ubx>A&%OI(pqDwaC^h0_&{2WNGSa*Gy6~J$e@-J9*?_ zGw3LDw~;w7LwSuPcqGqM+^U3C41x12_+oi{-Wp-dLhy=5xoGUqC4~r&u&&VBQlSPC zczEU9V+VQTQAU)b+LFYne7U;gMnPpvOA94MBm79@liJTu(PnfEc!1y8;C8{c);(X}msztVPei%oCe2;jV!I z&>a88=5ta5Am+*yV08cKJVF-ADq)j*s*7Vsp}xE!J23X1K$f>FE7swwvma(Pv>NMgNCoq z^fNH~AUjf3GjT#la*9t}^~?jAAJ(w1jNS+AES&7qV$i*Aq@!X}PD?SiJ@}>b>BpB2 zl(1!|w8aGr-6Whb!9F1xv|{*4rX+!BQ@0q7nfX79tUNW`W?%6$&)@F9J|bXo_kirt z8~8f5$HHT&bDCgE&TKnCYjpo_Y<_ANG0Vp7sg>;$wzV@7si-~l7eKj8n5RbTT>vq( zGbRVj_9PS5Kw;+7lFvs77tkz4D%0<;432v#p|4m!e(Ju46YBY z{}-B1PN0?RH}p4Kl)^N@Jt2c$EH_8j>+d1vqduA-|1&ES)e;G~Dq9sgd1}_jIacBY zX5$v?a~7$1z;%n_l5^R3Bj6tXG-!H*EO=&v3Uvya7apopSR?iS;_^3sV3mBoXG=k( zj4FOcw%eXr?KR9^YbIM(eTzh$;>vxAe5d;?0n0LJs%bW{!-?PV)&*RMgw|Kebm2c(Gt(>eh=}wLW10KOW;< z<@}#$-!6ztc|1{`oc+I<2A+7obj6O>2zGI5E5^?&_c3AQ_|8`>*?JK;V)$Zd_pLJu zBb|zDwz6A2Q2Bns&&LQ}quL7G5y89{w&c2kuDtfQ#$Vb~ukvJZgxZK%L`PyR%YvIwuY8n%7QvfvGjf@YPXp5yUeNizeAjI#Vw zS>$%&SAlmTs7WYMAT15b?>|#jOa*FMaWkzzM1rjWA^QO6m~vJ=g++ShTUv+`u?}+(FP$KER_E*ECF0NpliVqIF58)9$R*46?WH zvylDgBwy2)?0NiWBDDF0lgdHUvwW~Gb4FKS8!;0;SGFc!0g_Vyk0SuzS~W z=hUE#-8BuJ@)bUa#}hkO(gErUlDANF!j9jZ7@F3Q0mhqVU4u!IyrwX1pLS74X@*6A z9#56Z*+29pizp6U%)(ADp4-hJtMDStm6Yi5r zGmOR7ngBCnCzB}@xHz_u%nZhs0IaYgcGY6FfR(?R2Zd z1ejLwAF(yPDpKn+XduLtDv*(q%SweD7vh97kVgaQmXUe;5u{sp5|AM@9z(0vUTj2N zK>8zhVcW1|LU@v0jqJ`)M0@@vZyz<^jl`2tOGFJI!lfKWR*pLE1_R;jV-th-wj^!! zBGB-XF{&e|=bzU-d)u>p#=%dQOKfg*Y6q$et$4T`r;wRM^6B5cnqrTbxzdDZ?p=f7 z^}snTEI{)7+r$+?F;f5Bvb81OOgF*M@*s^=~Vlvs~1Xz@E)b(>T0kKIg=s^?>_uK|NSw;li-iZtZyP3M0pum zn9JRB!)zq3xfAZ8tw1h$SJPIGSZ@sy=D8#!y&1I)DPy{|@^=3rf$?el&{&4V?c$BR zb$lPpW0av&@R8Um^boWkR3WRaUGTwE=H8iGb=cI&^KTqNQfnFS z7h3tIDbd_xC7q1wCg6&B+xY&KFWs4w%ckao9JcRzWa8`;$^3@pgGv7Gq+M|tW5kD( zx&nPFb11!fjQ^3_;1}T!#{E~3wNmlio6{O4PUP1AY4{`LQC!#m{ckEEMZPn|noPAu zt;Si#h2^UpwHRccMcAmG+B8Gwkx>_seLN#c*talsucczkJci6UGnt|psv|YEVm@VC z0TyCs?ea4;^Wjs35XQX73FDi@P(g^y^P*glt$K}i&uXrzQw>>0>Q!p;t}-~`r9v(M4fy@|&U9)T>eJLRNC+0Xe=m7>q%W(n8!xM$mX ztU^QT;m4M_d}YK^q592nLL_5G{Ew1S#rVW@Lu$OO;Ge(6X415RT=H3GUVTbiIKZm^ zz+v+e+nVT^!;XAcdYHFM#`MJrB&0eHDdle5S`FgED3KKg{nDc@!H49xBdfo*-we9G zmiNu$S{{gUrpttd4NQol5~fU#D$eX!paQP!%9bonX*KUL*!z_I`=xqd=Jb`<9ixMa z9UQ-Jwe%L)n7FWwjdbPJAf7FAgX>KaaE%YX_c}JSp}QO% z@X`Iin1_bLvC5!*vdU0bAll;LNF z+Pl0m`Gb)6lO+jD6hC$ih)NVqI8+>gAkAe*KOQk(;%6bWx$_$jUlV5olnNuuQ~V{@ zqeoG2`=!qo=m3D7bcrx}iOX7pl&YzPx=8KB+5;3d>(jql@Z$#(%CQ2(2+y;g(H86x zLtKO=kErwR_BfxU80{2?p9u6E+-X)Fq>i)nl_tj?NvJzB!q?ogLlwh{@}@2_Mlwuj zaPxe!<%yTS8JwFQYPN0fcs!1xA+S4n?*w<7t+}bp*yFi@& zP85qj9}(!&?32V+EThjKC64o$%TbzJjOz7UkrY}(ovtB$By0g*eE65gdahlfSEW(K zAito61$=@sU#@>j2}D~^IGH#XD$vuE4UiNwYU_>oAVY_YwKwVIR;7a~BX=v|oAMyD6P*_JG@$YfePhcaGm ze{~lEFs$^e;|4BujDmtqpc=D22#c)_`I{W2N?A_WpNfx1u^np;y01*w_lqZ^8}$r^ z>LSSwE4yxxjw(tq!JF&`5H6OD-yX*j_o~n|F4qolixwl#U z4#JTo#$!hK2;F=bbz87Nm3*_F2SM?dgX4sA3c_v0ob0a$dZfZW4H?Tl?E{mGrILLk zq1Y$!rpvzVP7X~%VwE*s%mT6p4^DCF5F~Bf8WM?)T4x>Y(dPs;@wn`(`$r-#Nl6g5 z>$!-Wh63tM%5dCAokItcpZyr0xwI|i8J5zS>i7_?VsQiPvMBPkta4cPuc3GfSi^ED zVkCA60iq-q>cz&V!pN3#V*}UP+ly37rA*4Pf@;vl=R!@>NVZ;~!v-54 zI0et`r4^ggqyYne;<;)r%G$@vhRfbX@z3^_l?S4e<4qGbX<-Zty+!hrrZ7P% ze5d!IP5Wt44RxOIllFCgN>*iOOL`Idlb3_fi@yoh#tZ&XHM(ORy952kgsCUj;zG%> zMORkZ%V5@&3g*Q@D(3U%d}O3iBcW1Ghd@5MoOMxEwU{t?DNr6vvzg;m@^ZNC^(f1BK?|kLg!|s@me-;s?hOJzaT9`DBh?? z04u9t(uMAxI}9Xxjd?5sjZ^OS7ixb$Tq0FwqZ#BgN<4Y%m+gti$$ds>f;S`1Z8;BPnY{_*1B_J`XCWwTm72tH_! zQG0yj0C{W5ztV=z`TN8djqG2+*)?#+(TbA0E9|d2M8)T>uStqprXF ze9*9U{Rr%FB4o6h72w(67NsnksL~?Snd~-{cN|H6vcWrsTMqD zKi@C1e7!D~+bjM*{!_o;0{25fGPC(p{DXMdGA~;E8Ca ze?rp?z~JH7pv6eV_*3xkf`_*d9&ZGYtD{~vamGBjD7r8prtp?W7fAVqNrzI|V>~m9sZWk4 zpw}y-U%DO332k?XxIb#4r1@3bKW-4Jk$KdXoOAFqk6egfT@a~SkVWsVOXM*( zvTm}JTPWvEw?iD}C;nP7)6UDc|JP>xTFH@m4j(SQOS*jsjyibu;KFd(XLwOH)Icx3 zTgZKKa<;QRkQp@{ynt%OB+iFt|4f=HrN7UbT-|==@Z4k$Ylpi`pq5F*mRWdGy0b|l z67xK7mhoZQkql*PBU^L3;z;iTec;fbJ}&0|IxiH0*`t_IZf2cBYhR3lLe{W9m)M}5 zRHwSc{dodh=r#M4P)K)f_SaccWs{mm^MtA=yhrlW1xhh-M3CW`fKvWsskD7lKM^O9 z!Ko`zB}dTVpB?5P{?!kvu&hPiEfj6koX)GszM4bD$s>J}bx9iTB46)j`)s?<@~t<@ zSWbD!lprGk#)j3vN<{9!l*=4s{Yru;tL{JNjYy$Btg2F*Z~zlY{4h#HV_ojrN_EOd zZRQ}W7QOr8BW4crPSL5SOPC^GM3+XWU@a9su~z9E%t4{q*lTuW4pQ#@qp5A)yTV^J zsdS9Unlq(N{QQTv?{1%*I>@^bTG-yB!t$Qke?RIVOAN+hO`{I7U+C3GJVoG_xTdX} zf$|9%mT<@BPzPmPg2rMqVju(PDD#D$ZnR0=|Ee|7Y_dAaE}FEzt%qAwWtuv9NXMyz zh7x%kr4BkGO`CAWPgOf$=2}?mgRpux`8lbBj!Z4Os>!=}GmGl*D4LKL~O4_~a zWjSFbE$X19@*@Xuxc3&a4fxU2K`?NRKJ2d#TXK+0E$Se<_cO@UrVd)3B-7EL-3WuL|57=?^?Xl^6$8J2ES(@ zzyRFEDlkZVDTg|il-8^z@wjMgi%#i{bZ+WKAu?V<(JQrso@^7$Xg4Z&)+5Rj0#G8j z<)%)yHp~facfqY=qc#DLp=3a9hAM*NadR?s4=+TSF%NF}fOUn1HJIrImv*`nMQdL@~_aY#<~|_BR{P5#j?CG zSF+LcqhnVxAjOjOr#}dJpEpb=#qVc|vHStXC}|6XuIZlFbkENx-P7niVaEi()uDPd znGWbs|HvCHvpS!tx4VoswNBNDAg4R-;>!jTe&T#UW>(z|B%%+aqnw^*LrbAAvGef( zna;%yGaEr$Q)if*xBZB(P-oGFldJ z1kV)cEWB>x4jH!;xfR8TRhBDuVW|uC5x4|0R-{UB@VnKk$!u?({1*qPxe=XFLX{||`cZ>w+r^XqT_Yxl+Awi$te z^N~S|k%~T2${^iD)z+C|#4)(#D=wBH{12Bl8iBQDO=GwiKimhy#qEivhMV_|;X#up zS^%{xFcxp!Tv>8^c>@W++M&d;^9~Zezp~`=6ky1@?`U|hEV;*6a+Q(YGh@ySj4=kw zoQO$qy+#6KYuuj=Fm|t?b0K55-O%B=3^caZ%5N&41dSu@M}Xp|42{Rl_)~<&J#ZI+ z#w|pCkHfoVDK`J>+5X}F=a=eP>_7glY`k>*e}zd&VrtNi`umHU2mY)GFkap3-zqMj zSNH8N&Ea@IeGS5Xb$|E*<{foV+fE~0X*_5;Q?YH`7w@zL`T}>GzS`_xPhZW!N}Cq2 zHPBV_?|NEo0>r*L7VTkzjCI_#%u1+jQstwBR!4mP+i4SCaxF zY*tZ=uq|!t_r+VNvajeS%#};Sy9u!s&9oYZF3%=}p*gv$c^2C5NgE?yD(khAV02au zIf8dqNgIP_kz`l#jls)kAqmQPLC54j$Gqi}-r0QKoL7&|NZJ`$ld;puFg~&HdL`|6 z-j|p2imn&P+{h58-)pn?f-1>wK0Xy(2ZP&hmbo$tDa(E z-Oka?KSHxP%^w;5@Qcf_I^)A1)BJ$FIs9ljwq;y8mKm0IbRB~--o9nLCbn-|kw@Ae zesSTBN@6aWSQ2mk;8ApjOEq)$r`000qQ000>P004Grb7^lcZDDhC zWpZ;bZDDhCWpXqwcx`O#9BXspIP&{d-G5-%x`pf#$2O0^GMOy`PC^~8!_G|dY03d7 z@r|)LV=|Nb_1BVYs|Cj95q4*HFCUVCq*klD)i1U5{?~6aC--9c3)^!q3W_8ba+W*s zrndWZQCJ3#r}aYa*AIXH+xxz`2rR$txq;;diyRDd7Z-)+AejGhcD9&2TQhSZ&FqQq zExgA-ns~D_@A1)|SZ6Ob06$Y@MLnCDwp(ChfAQBwdvnVLxR0JcGlPZX`A;E;jyGA( zU|Iko*Uo&)F#|w%@odi*6xuI~xjC^e3Ul9DSpJJu`0!qTHs{tN{G01`E(+B`&Ul?e zuWR31&I`Go?OJ;9>cBVULhj0T9RA6$z6J585AWNS=X5of@@zRW3Kw zN?op&D~&?#_M*`57tVyv_bZ*{YF#Ne&J#L+vZui_?Ol-?s-jjK^@>_m$SeuHZ!G)i zIS7F(bw!cYa!pm`%DGxe=-%@tG3KgNsmV395lsS1XPD=%E(#QjpJb_u*TF`!r9e|d zu2vO3CmznwS{>hVpB#(gDr2x$jy-3@T>Cnl3-=yd3;VxT-;WN$)#$hV zVn-WkgN{BN6qS<`43Nsl$N{mTd}g#1*4?oebH{w`Sud7Dn@`KKTw$QEZ(HL|k$oY6 z)m}hMI}c9Vu_s@`MH5_xfQF&n8K1g?V!{C51A&YYAKwmIoz8IZsWraVi~07|l~T!< z@@JFT^a6H(tmM~0^p{RxcMgI>w!oF=TTkwkk%LmvxY7FB$5yYHC+%&|@%(&A5fC5^ zmtW#xAhEFGgmEeitO_Ib=jqpx8GvE|wGgkL4rL^kh3*4y`0(!=)15jNY<%diV3uAx zON;Dq+ET|YFe+}S;D+vk>~w8v2ZrfCS!_!Q^4I}|*Y{qm_OtCweT$(fObh$t%h(Id zfK9yC7OI<7Y{{^;=}bP|tgnQEv_SeM2{6ar(k1G78-m?f&fM_k_Jnr+XfHmG#&`G9 ztCYyXV@TKlgm9g{54s1=^7gT;xIpJe4q& z$80HNRdm~R%)sP})~yNA=0v~Vg7ya8J@&qmPqW1f?cN5`K6$Jsg*gI$ISH0Nlbxg)h|vSX1-KDixg+#WJh~4e z2Ienm23g_?ydpO$+@O%H)V|s80KhLC>#p2tpiv{@1-1f`| z@XV4VR$RBJBx8*@9vUq}>$NVm9ak5MJn>lv=uimjp^c+AeP>mCSnP!0UNrbPVbqo-ZJRAbff9Z zhXoDpvfvZ4GcWS0)zh`D2~F41w`LCz6%XATdpfmTz<|0YQ6$LSkKOG^wB^bEzrO8u zHmn1Ok|`ccC>B-_fDTXgkYYjH4J5#GEaFt@&)!$?@eae2gxjB3+?u#$C0Q;*{EB}l z-XnvV(*xw1PhFy+MKRC6*}+s@j{1X<=WU`xEZ0QtT7yn;d%YXFX@lE-uQkwgP_`FJ zX)pu_V(V3NPfx!YelofSY+qx1tDWrRQG8^HphQk9LUf#{49-Gd9?~w{sfV6(%MV@w zXz18qg7ViKi)3_YSe7IYf-}!|GV`daB?44hfg?fi3W=lC1xNdr*$xsyVnopXV6a$# zv3xj%`6kw4(WLQx^BN+OujXqr{5IFI%_rZSHHmc4Fr?Y?1N+gO1WoY8T|x`@DF;y~ z{D)l_-SdNgTCa2#{Cym(3O-!dg}rV+#K{yw73y2Yf{E}4PTG$Qvf$T{*bAhesAnE< z54;>%@6s_b7Qlih&`zYA<(}T^xD6s#%bl7K5kV`RKnSY{XSy2=0N|ZGBhV8^YU|7b zm}rfNNBHYZstN=VsVWfs_orc|$4X>iPg)|_$E7Lr#=c`ojnd0BpOL8h*b??6vheCYJg#QAUd>lI~TptFu&- zu0e1_GECVyr;~Tg7=w4D^&nR~FpT>V_#LGPhITg^7a1p+`L1yDy@F?K!cR(jPmE1L zra{3pN)`|ZH@-vb&w@STA_{s{*cQLP`6Ari;0DAo$(smXx3em5`TUL|1R!>joWGhY zLU@T>h9rd&rCkoxB5g>^To1=xO7!rx2s$@`0+IrngpiwX!2O8IYaW$rkno+gQcZe!r=Mke`>NRKWCK zKZbdu4SG9D$iImzb~(HqbXwzkICJh^_SWJ6mWtoayI%!~d622eS84DDLQuvCeD^}o zhtdV2yf-B_-jw@&s81Ndf&J$;1;bfJ_eWBVh5 zWmB1yo1Mmx(a?R9vMHNN$k%Qi)gqks$z&&67`r+G`%dVI(1tc4c5Odf^!$}4_>)KBeFfEyCLAq9 zrviw0V<-<9x=zAbf~figE?rts>clJGgtjY(oaWMkI(~c*CG8CJiTYjycZ}cVG6+2P zDyoRXO{)wMWw*BzU;74;M9UzcGh2gmsnT}HMwD>Yid<(1S7e+c8kJc zqNpMu3=tt&xlJ)K1`*;Rz<>k)KXZlgo7bJbQK>CUxFlEFiUm7lLR@Ie{CRO?*uGlb zyJL+mTAJaTZd9jBBnZ{@(yZlbS3!x9Mp{&S5EN305|#K;Wh06$)FjY~X4(V=IWwXF zSth&()#EXJPELM&3Yw}~E#2t7;?1qbn^0Laae7+Drlx-Dt|*rTu)^5Wstf?olvu~@ zQuLc$)Ew9L(sjq54zKhjOuP1>oDh!oH5VI{nE$kt{BgZszQn6^Q1t*26mD*j#2}-~ zi*bHsqkNT1$xTkstb`mX<}c03mnWYDRICOb zcungdtM3p}f&{NPQNjT^8AGM}`&tG?Y3oHqWK3KG(=ZW@=zhl)Y{ZjllfesK2ydfJ5Q=X1j#No-+@oea z@a9yU_EK_Oq6mEKh8_dHdSv{|M}}WHGW_b1;n$7~|NO}C>qmy)I1>ECJ$`L@Gb`{R zC|cdx1CQ+g^}uG}k)p$-?@4)O;`gpOPx}5}OSoIs@lAFEh&nVR8a3boB3x85Jcbii z2hlw{q9);zBC*9`M1p;cFc_iAH0%|{9Hbl9HZT)eOEeQ!3~Z*2cNqE1f*-c;%vfa{ zGP5G_3ys1hN`fzrcQ(=hE0j^z-Jk_1lzU0H!jS=Lm4$2j$K+?C4ssn~jQrk2v6 zl}nk!BTT|ABvn@`PI*|5?#Zr+Rk`2)iQLy5x+C++(kD<*i8mt!&UU|)28JDuB>)qg z7e<*7T-sGL1`aNXt>Pwt!{NRx$!T|jVguviu@pddxm0{sKO~^@d=&u@75yt@^E=n)S(z!Y^R*9Yx%Emz)Wpey! z|Bw#FV3A@-HFkn_*}~Yd_<&R}Mf)i^Nh#1^;OSc#MVfDXOQV>2KXWS!v2G}iSyp#PH7RiDl0WXN-f&#fCaAyzY5~-CmHMcLv_D2WvI#O!ftJ;E#SbqL+ zTuf8(%t8p#cvKedhs(D7TX&Mck$E#GDgB;!SYvy|l3e^|ZtNn32N9={07_Nj3(?#R z>Rm3O_DDI$c6{|h5%+e|+<=X!sc{;OO`3sQB?&iCF)>%gN-3%5GW1Foxdv?_zgBpN--#jN7=zDq z#nHhb=@%{$;j!K??IjtM$)(I=J_`5HNc$VVfI>k2HU~3%Jda|03*uwF^rwGf!NUT> zhHoQxT;f*7Z@Gh4BRa@5qdVe}&QMAWP0>FGEVtgT8S{kTSqmHY}zg7wW}UH6i*05n?5C1MUB}rND!WpDTvug@RQ)Rou2g= zMVYy#7{wGDb42~=g{OK ziW{waeFLY}>=}wuq;XC{?X@*Z+;c<;c#e!v*Oj)kn~Vo&1QiobN{65bG!;IqF!c$h z)I1irJn61l+SKi=$AW~liEnAJp&VcMLWmz!NUWnuTju1xkR!&og(72s=ETbgfcuKM@Lj# zhG1Zm9Oiy_fgu%|h>j~gkD0Nf^HQ1nng4r-5r|^~&U)Sx4Z0_THlFX`>g-cviXk$=D4DH-x^BJ9==WVhIpEcT=V#P-#XP`g5_(6Dr)jmQ(~XwZc!*zqG| zXmMgJ3|TOOOt?x(wZ5l=+WhUyNLvb~}aIVxSqkizF*mRjsHs?Ua$N0|2IZ!c|Ob97~Lb1!XSb97~LGcI^-Z0ucYbK5ww{eG+Nf6!!W*Y>QV@F|Jw@oYJM zB&m%(zP2Xge40{h#@3B2sU&%3bASCk012=`HX%x){931KXUjGTHX8jn-Dos^`scrI z2aP}C$+SNj?rykEXQL4hFGpAX;q~su-R$e@&PL;(KmYfC{dC-$&f>}bXgG_9vuOjb z4X3*sH?!IJA8+1FFK^=8-n4n!znqMwqp!2(<>>a!=u7S@n@yXe$#pu!!RYet7N5l+PV3Di9`t4)cY4zwPvvO;n2vjw z@$SZW5>MmFAMwV|Kb_z7#_=@$|Hg-d-HmXg(fvNgQ|~6DyYWWjV}BT*&%O`v8-Js5 z)E^A8pStnCXZoj~f7*`+gT~43#%X_evC;S>_!PA|q2ot^8?;(MC)#NIy1Q|Fyz$0( z__KW2YdS5@bwelg!gjmGJo;PzYIY-^b(((Uw;iY5X$OuQwga%gF!KAje|F^K8#=)1t{-A&PE&Y&~%`c~iC*8w~?r$GXHn-?tAA8^9$#H!9 zC7v*JK@yHellXdgMaFvf>)zSH=7*!ry-O*3&BNhUbN?p3{1#trJKOG-@c|)CZ@+OP zIECjipH{-x>vlG}8wz8p227NhtjO`PK>@^SZ!#JEc@Alp?|6h_&v^80eBB?)ht=RE>#W9{ z^H?@sg^z!Xzs`<(6Ht(h+H}0O@h>r4U^p>}XTokR{8o1~Uih82quFe9yYOq>f6ppf zO5q46qrtR!Hv02-mrgPnBQasGou(57=0CZf=IhPRxdEs{a6o6?DFi~oM!+IF{JyIVgd4-={T<;(vH$1mm7SSgV8ZEre`FB4yC z{Ovp*;P<@0c^?nP5X%0g9P(3t`upkG#pmYb&Fv3=X*qt|qpP_;8h@W?f31INY+i0P z1SV=6E6mi`A5F%iiNrR|Mq>|{L%uj|q_F0yNe7k)=X^ZqGt>*q{%+E{>ccS}4trk) zanY9@?R`8yG#2`Q?%+Qq?D^E2^d%i~hAsNpvv@oJ*b=;x6dd$sy^OWzU>cyA1WOa( zxykH1z!-Ey{O0ZWcKG26KPx=-sW-Sw)Uo(J{AvO|9VdUw%;4X!>b-aByYesgL(VCS$sX3^y6uLOeABj znVAV=_D&8qi=y}G!Fj7i zYZ~OY*350gUp%}3%P2@1HIB!?fZE1N7kYAVc(EC7gOlT?FwFY(W@1PU&oGzY`fX~_ zh($i1^(M0&`QOIo$!Pfc1M&!y$=!GcY`nE2f4YsY;B;c+hj|Y-ARj=>au6{^vgFk8 zzomcBhTm8~)9}|~s|YbG8vZVTz;!(NWr8sHZZKoD2wa8JLVO|IXtu;ow#2znmRjiC z7?iJMxdkmd$MJO9yN-7b5zkC^_Itz2c(Ah%pB@jV(#r0f_pgV&!A>`x-1diJ>5nnF z{&;|QrkA}W%acsyXcMzK-#b3sEDG%Q2Pw;BqFNf&6t_y5@Vk`B^to*S%emiiv#}Qb z&hKChmHb7%>n-}qeochYES6lKQ)rgf$ zkYSxFyO*`uoJ3KoS)TL5?q2uo@F-K(oY@eH_VJfr{`KqWrh4}9)85DIhl|s*&4OCz z1~F6@yw4;i_h7n2TYtNr?YwrkYy9Fo64jR%L@zn|sYsO>*>3+fhLIg1*(3rmSk=_H zW+pW;!_@v}R(9vKH|Y%q@t~oerypHWj)gNi`K3GGv|3HL0Z1zAgZ%b7VzJyB{Z8%d z{cDL!ypoNwOd#vhmLZ|= z`;^8inXcu+%(cPl!$B-6eM;9}u85K5N-HyD4v3&&GLTw+L)7Q4KN*1Xl&t&t-Iv!G z<`Q|X9ZA>DJ0!9G?0w%!f7>|d_pT?s+nv+VXt0AEV7#+8nf1T+E@vWC8;Z@c;dKKW zlwuFE#4IM|ZRRoBlvmvk+Rky=%F#F6u-n zGv3~z+HQD+vLHE8`X?IW9Llm8qpC%^TH^dQ>m}!tzY+opTt_bdX!Bh>JBz<&n+AE&HrQ)i`4Nfh)&bB1p%IjNyr8jyHR%2{&* zrP7|4vf>mC!C0?ZGb~r$tQj_MWwRzvq!@B0lXs<3mi%=MGM0zc#RXVQ#wv_=-!e9z zq?%;jb{> zeGC75k_-{4OZW?dpGW@-3Vz<*&3oOL)WgYrF%@CTDwx456D&$nh> zGW0I#v6R@f2LYYH(wlj?V9(9h3ayFWxi_1i#k)zD z<@Dvhhd*M^>3#o7He}Y@ro~>C+oD+*h4t85f2UgEmWd}C?~bOjxl=>*dls)J(#i|M z4H`OK0$`@1@Zmz7`^n_s2!bNng0{2$FOz=K5&He%vFyY3>LVKT4aGCBwgZDw-5MM> z0p$Kk6-XL>+Xzd~JxjqCTAQbAMG0NzhJu6sgt>_TzfFYR$iIo;Ml#M=r@gz`=v|!3 zIbB(*x-zq3^rFhljo?C!dRl&ugdiflr@@%P93H$j9@b1SSW^oVJR+oOQPL|g1nKDw z6_my4B3Vrue=~)ivoX0j>t6qq{o<6ZlSz4C-qN8&mGDYUnlejI*_ei&vcZTou5!Rk zx$NeXY}pO!YFKtts5KJ|)~taEtigU4k8b1HJKtqrT?IQOLH5AKZ-6r>9G*B1)`F6GCCjj z#vIwQ&-}rpr!U2TUYiF_gkR|5LY>hcad(t(gfos#)Cs&$ql#7}T@bF}$wbC!dIprz zQsZ`9*YW(8=Q%;!3+S6lZ<+*qrW6T|D~cFP)pXl!w-x$f;QLP7=>R!o7k((U4OoJ_ zDD>K47~#4HSI|b^)C9HVtC`mTk0?c)A<3o~h>I6k78woxHHR$s1Zjfo^l(~ko2TMX zGEI%c5*ccGEx+9+Tw*!Y%5D7TI21B=#|tE9Ivw1S5w%-}C@6|rCNfMZa`b8?lEhNv z*`=t(P(($j)pA*i0+u2-@*>ywJ7LFb2~lJzf)Hr%$jEh@p~$Lf?^4s{HZOP!ZPB&t z-Out}3vyJC7VSap7BZ=IRfcVwn8?R$3|m3iYGndI*Ivo7S7LlZDf&vkc(G}PxV+3J zIIKa0__}T1^E!SY=;MeAc&V}29~}8lyrtLt?Eann;BURjCCzdMfHRR!WZ_&kc!_ZP zLC5jkAZp>3wWIL*CHty2QTc)%UM&;BHVcnUdSAOp0tV_{G8h$$cY|jSG@?>L%W}% zb0&+sxcFsU`)v0?9me8&na)a^Lf5vywGv;H+>yv#G)PFZFKEP(R?f6++zSZ=@+)bR zh3wrqYKp3YERmwp71c(*V4;>p3W_GgE@S|XMcQn7n$7wmr0`wAK${#CiIYQ=+7N7L zFvM9593hTXObg?(S3l>gKyrGC6myZPiYm`5Wi?r#QdS`(q`a#i@MMkSHR?$#EV(dH z_4m-vs)x*Q@Cn6(R)DL+og|hlm2bL4+AffYB_G!*RK~|UwUETg_6i}GWipycbAnV3 z|>@YkQb4dBs-+lB?vE7ObW{ESxIJ3u~NceweAY(xFURWy)aEG8o4{oN#Bm7 zSr0(ddLXQTq;caZ3s#pUh^9@c%}_3Bc@B|4UIYbnCYnQm9iUR#QUn=^&xJU&Bteuz zobg|pL!OzsW3mC_dRwW?GlKKJlxKhDh7Xt9Exc zU}(e!Pxg}-L;sY8CKYd{KEH|MSu^}W**e2*U<_^UEcrDxhF%b35IJ({e{7p}YLtW$ z!+RtHFEB<*i7|+IE49t}vb->FrMA7!vw_}9O?;ZXk~*0gb#?NN>f*1_iw!ru^Y=`D z?0t_X$MNkKRB?!wtHv&$5E=BMkFUkWp7M`k(<^O;f3zveqMEu;Ta3ROZad=Mwc=m? zHkj?~;$Ac8MF&;#eUF9)hWA;ab(WE=+4X0#G zam{6p9VBpvnCL6=#65i7tSSft%CW}1Nj$v#F0MSJ6V|tz%WvtQR=?|92-m#(%z4p9 zjh4GD@0;G%^T<(McI~;uF{F3&{+yC3X|fX$C!3I&w;)5AMT>@-l3Y|)4j4-5S2rf6 zONY{;+||9Q3Wq-w|D-y!(dDAGNy2vlVB^1B+iZp5Yb{PN%vtqFKvpyuRa9wU@EBuB zn|oy3;sXYfHcF}U%}dD}31}$P0iJn*IvH$xfjaSE&^X!Ms154;VioGSu6@tR1%ETbvv%#q5qKP(UFBIvN>=`MZ$HQzsgDH{RN@55~?u5 zg-S_B5{o@oRH5n%MR6FaYiJ%$g}M>7LJx^6EVSylS`wR=lp?a|JX2)hCB=HGZWMZy zg^DUrQ|p=*VJW>e&_$JTG|zJb<&r71h!Av9iZmru!g9$J%j+2d^!&SPZbRrq*m(nBdy?g8R02=to+hQyP6 z8q9Q%;<+-=)uzd3dw{%K*OwvmH1BYL~ z;@iv7#2l0d6z(lx|B^%00R@rOW+IF`+(?TwaXEIQcNFG5mrVm%ZXU=0e zSdneXt!uNT%!zh|Z@$WsTc7qmZW>UdcCbsl>SK4%2_7!n4pe)2dLKo}q;0d%;O)Km zX^U-QO`G=9mKRHmS_P||e3JLm3x81iY0I1J9F?E8`Hyst75y}-#-FF7wv_}aW2|{> z__&0DsZ6+*B~rs^sWPhfbDr8s&nhPA4vuy)shD^7lI zRF35K*u|vFpnw?&FY3pYd#;cgsN3ad<5l>$4IeQ6h;YB)jVMCOFXNV|-bf1s%ugDA zN~~bnw$xhW$F*ZfbK z!n6vRJJ{$rE_yJa+d<0-IuZJLASs<~ohbC+IH7~1?H%85hXNl`A~S3pxS{X0T2AOY zCEYiocBg}ab3Y3FR?-k6$@Zl6N5b4%dxJP_kQw-47z6<_UmU48MjrXCP5^3K5ql`W z3DKm&ZF_Df2uco|qr}{A2SM9G7m_y4kBj-{^HL;cduMc>p=>5+KbFgcs-fLxnj|&T zrfcpG!!Vdxdis>Yiy6l%uC3=n{vtOB-Igx|hz_d65T{G9`o0%LI1$aAK%p;HqJ%r# zVMF%`C5HBA@?V}WOKBl-6T=neTnC*`$^?f0O@iHJ3kCXGrB5>_IOrHk2tEF=9{=cq zNAu{pAEGq|t1BB?MexIDP18gD2V$~LD}-J3vFBd2sF8@dV8`6B<+hDUX7c1le(3o= z>`;VWNOTk>+fDS{ASQKTrE}L{P|>D@C6yXfUwV+dMNLkAFo!{kTL8kp)VK%WIUNUu zAd%<8*oWwVL{>~WY-NN(56-3KMreA~a*h0X?MFjwn>z)vlGZW`;w2KI6AdjFt+d*3 zc#-gU-1M|a`B4itSo8&Gd5+O+A(wSnbR(#cb#PGF(uq7T=!lIEN|Yuhq~>mJ*1^!D zbsra@VHA_0rXfl`#6*OdJLn*yq$~<}%CVf1ODkGb$ z*()jf`E-?MO;?qRh~VW*q9|8bnXpTVi79T>#Johtd8b?&=Fk>C1h6d^=7^n4(O^@p zkWyr<)Hf9xizagUzf&u#pR+xbH$k~9>-r;Z>gFtv9!Yt0B6LC*C^8CiC%QBD=s2S3 zq91r}=#nE-Omso)?h--QdT1NT~aBK(tHL0>>NVObZq@>y- zA1(?5ZDAn#jv--y3f<7aWD1LUXfo77>L1f^(BzTC#+o+pFl}=ys zRw1?D3DX6>YMXZ7n*6gol0L?N@KIWXpwJ{=>Aa``4&RvVJN%m?a=1Y^h< zmIY&2Re^2O!}$0~jH`dj&Uih_qxp#A+X0M=5OWt6kTPF68D)yX0DWWC7NlV&lci2W zu_{9<*=UxJnG4077ObQnMqvk;TAxkf8j+M4ieYM{uV=JS>pC|QGf=>NMq=iM*3$^9 z1RSk>Kl>#>fEtvP*XQtmR%xb`I`;s<73&KN?Nx;@<>ZM0rBMy3I<7_Vq;G^Oi=VM|QP40;Gvb9lu?p{r39 z3Y6=$JtP4-9b@%1Raky|Q!y?8#T6m0=OK`6yCRjr?uCVJXw92vmY7D$_y79;Pz^r?{jH;*K35 z2Z@--)FZ{K(DMu{uX$AqRa^&iC{eo=wonYZ85x4CR~@d*?xrX^RdyFD zi_4juw7i=)z4)TB#j6z8!A(BOW;-3!Ay7P!k=3zN#X8Rr+rDVyo%-~M4y>7lx+|7h zeuTUvyj0>(;57(J(NrOCX-|c--c#KCB9u#%L_mw_$cn5MtOie3D?qw50+v9o6sk*K z&d6#fyXmD+gLR+ch`>FHRt3$ai~8g>*Fh1ES~wr&f<&Iwss{qKkPJYoBJ@$U#^SNk z3xe=1?{C|K6&kR16ckfaz+fr z=423vaz3+((tAMJsFQA00X`BH)2r^GI^VkLtoFKXAYA}nlrx~HkiVuAr3}1L!KN8B zI7Bi4Q$o^-rwgejD_yMiin}}R7kHTG7px@GGU{|@n+Loo4z^nrR(pLY z9`r7C-{!S@nywadK}GD-gdIbo5H%xmR!IM`{K5^cizkdw$8oRXRY7}c2I+}wFI}f} zk-7E7nV?c!s?}5h(?t+(!4z6n@elIh&I4Z&zTbzkP zl$E}k+nUkJfZ{g04`6*JQsBPtsvB=RJXt*8Ma3N|6xIEd#+#a`wu9?xZSalN&{Z5c za$bl^!E9twadeNeIisqDTb*rGu?o6^TSARxS}5m+Fj_{GpHT&zV5N%D&wH1v3TjKY z5T3l+;s6(-Iuzh~9)d&c=rL*wki%vL(N(t7!Zrh(Nq|a9e%0~UB?47!5Oi8?7zG!U z83C}eCBBEin6o9=-~}-_o))&-0XDeEgtRE4n}}UOShZaxdPdldJQ7X=$?Y6$esW|* zh_9_0!s_iRxdzxb!7gm|ihIq>gM6A>sM5B_&aC3zvt(6JT)NBl)D_nT)Rc-S_*mHA z?Sx45LpvfL0*Q?XP8N|9UOYs#p=w2y0@drGic{QGs9LO~hRMTmD!aqJo- zc|=3=)S3&7%A-(}L$tAh7={r-MB`nJt2!u?v%)}e4;>jlI**zr26wf8GlRkW7M*^~FR*o;6rDXr~+f{}$&T8na|lI&+4X-3M;3sus+ z5><8NWI3(1VabG7C2XpLAWmbKCa8{eW0iHKbz7LKD#}V1^2sZ!CHEV_1_`pW_E4XTYx-5yd;D{)>RsS$g$9irBDGF>!LsD4E& z=@<3buQCCiLW2Nt>{~6nI>vq8zaI7m>SXv9UH|oWh{B0Gk<8BSbfBJ90c?KD*@6>N zmuv^H4BTfvPT7%SRr-{2{^GHP5WEd8CG%zjZU-qp6N~VBcH)$vjU*PmTbANo+V$Dx zyW%!o%W7or^4)hBl#GDpv7uy?|m6tEhb5lW+!Ff`30zUx5Ah|aJTgoprwKz+Xn z2WmnTZcv#ll7sTs5S1M`^PF~0Qz*4ol#Ek7;*diJrxtLo6JZ_~!5O;z0+NSUN%%zC zx0r}PIHYPFE?+LRIN0Q4oAuqV@dAv`4mO?RV*@oj`8#%$0W~8viUeMO@jzS}_-WUO zWsdefo*zn4cox9;_--)kj|cISgT^*+uwH1~6#ZVX-;FKPHa1I3y5pl#6vbxgf9>0* zT=E-7mM}UnO@Px+ID&3~9~Cp86)Lu`ap?e(cFFljg?W}m@hoIUP7o(6@F9^<=Sx&@ zBoRkIao-10mFx*44kuZ`JD22MGM=b=z3A~Fy;-qBTwxRCCE^OyTb0EXsGldzJMj}~ z-bs&VJksW!>G3pCDJ5 zcudB28A$qaDPH)4N?OtF3hkfEFEcdosaP#}|3v8(3pFiR>D`J3Ic5xDRBNp*&H0jq zH-M`da;x~IfN#ym{^OJILXVz#uAIB3VSjWxj)&9UtUnqm(^J+@)piqjp`R+XX(ywh z{Bd$Op7n><>N{)bvpVO}M2aL*1VIB~UA|HMUggp>Ig^lYqAyq4`~*o?TxDpSa#V;W zFp`SP;(IpKSo-s;-&4oL8+0);^jq2xzx<}_>}5VBPvUp==tkqi#qP$ls_I@8rLJ4p z5wlA8j+hp8EjZ&$P`r|LG|eMbCUwgC(qCVTsH6Wtwke2vFpVHz$z;hLjcD0nx#c~3 znU{Tm$A12)+Rc2}<0y+Ku`fsBNm2H7FPA|eV8-QsiTsGNE-FKX5H7?Ow^W>@kf~Tu zTq=tmG2)g|G(d^Cfs|0<5o9^g4iIOE%r>M(cqv*m;slf0M`MT7;iW9{S2-Iqyu>LZ zoL3Zw1w5Qzz$__kiY8-X$@@xwU9_OV?Z28D`nvwU@&4L3suqtRDOy;p5a}6T$j1b3 z%kxR8i@6Q(Qm`euF6-N0aA-WiWce%o!nI%Nmjz&RBDIPEow?3rOkNPE;e>tpChYs+ zRevU5G>SLGUy&ZV?J<8_EQr$fu=hQl9LKj`;z>#;0R^>Z4NB2q?bqTdPOCJ};qBHJ|@?k#mIkGUdg`H7?Ly*OXz;PL`AIy8Gi zYk!(_IW7(Um}sYo#ssAhBysbGnx*T&d2^wwrJv@uQ>@HT2(qjg$|eZeyFB`2ilM|_ z#P@N$yK#Qg8^;?BVW;%1Vh;i`vtCF^$8E}TrNVmQ4Io7krj1Q%6ouvtG?)yX7`|qe zQ8X>aFea$9fkH)vF_lM;Db2+4Gp*WwL?9rK;ggcvq&VKJffrB89S8z4lIP46vv}@t z7k`!9Belf#)|SqPnmBB6y!|jVRnLS!cM!)V%};xi-e3?9@-;POiSwnSwxus3TL2+O z0q!Y~>1Z5UM2Bwb{*TV65Sx^JWQ8aik+CGIUObC1`U-IFKYNW zgBdG++{!aOb$cTn7B()u7Ijd>72jCLwFxWbq~eGbM$;l0UHU^Zk$DMIA2%@BvKXFr zVqEp(c8yZJXxgQNc9>HH*`R8|4KALkg&d=a){6Ahpt=0TO-l+(vkro|%}3j~HtDs- zB&qz^GXXf1pB*Ju%VkyNEUx6Y18uUGy=#w2lh|~MP+CT!>i@+rj(gKtJlP)&XYp`0 z{rUd^009600{~D<0|XQR1^@^E001EX6Ul|TnqvR}uxA1Q82|tPc4>2IZ!c|Ob97~L zb1!XSb97~LGA?*+Z0wzDbKFLVpK1@GOE;=7ij^?wA*}Kcm(d^@s*}HcqN7E<2o-p{6EJ?E`A15bg5A$dL zKCeAJJD;90?z`FiCJhS;4Qefr3286+7znNLqAmrVEK!^!!D8}08G=aZx9 zHxJL}(~If+*XhH5{^Q`ot~fKj*G*=d(}e4>w+(oJ|ide?R3f z-G>{`PfkzE-wvn$xeUMk^B+6Y)65u-YDs&y>WPF80F3M;^hBL5C3ynJf;SEIXenpnsj>O zEY14mr1X5;9KK3Aef{D27%dmn>CM5-(Buc=I=!x$>(*FY-oPadr0Z)L;ay|M8K~6)Zy&>%FjHTU0%*UUiq;f zr}N8`qsi&^>B;;2!_Z8u&B5N`_Tiho=bO!3v$dH$_P6c5ef?%Ld-Uk5Qb4y3-~6!m z=xcKaEAAB1v-6K9-|`Ib=x?W& zPxBW%-psxVBgruNpM`ibhLf*j2v$3&_&2|r7Y!q(6aYqmAJcQ>b|Ct`|cq`G(YBwC5pPuA4)qL#t zKmWHl6ZKViIr)7$-=BW`c{(o^UMU@P{wa=%tfG7S?&NY(4sn2u!5oOAvF-lr0zc-> z>^J#CZqEL7a`{1Ih`)Q6WPf`0$qn&l`nSo)^QU6eH-6*34R@n@c|K+_sX{3{-~4uZ z`DXfV(+k`KCu9}h;DO<*oe$HaU#7?PuiHJjm>~L*D{rQg>q|&puu5d^kCcOzv><{?+7TJ^@Ys zXP;2PXV0ORo14CLFq>cg?`i%y^7kiDDUj-5dRcgAPM{*B;*O2W*@mN6aqEV=cUfvV z1Nvyo(Mo%RPCwUZs&6aktUu<)>AC%C*ATRraXytT_boZ4&8O%oR%l!C|89}CFK4s! znD9ALx{GZjjC)Tvxc+9LDvNk)0`k($!idPLS?fZ+nw?EG`CB#D>-p@wBOZthDLVPl z)`J(XcK6@9r0c#myen?TH!ApzB$<5-`lJWu(fb#qm))xiESh_*Qz?s(04^0 z_tFh!NO*8LIXj-rk6Q`pZ(0K})9Vm3cR>yo5VK|IKO`}olVgbadNQA!o=#6&iRo|J z*av(#dHmw+0v5nHwrM=VgGy8{9l5s|SuDitATaynfvSODLi7*F4RdSt2*8%dkYt z@RlVG@-He$^C#wqx^;dtJ(`}lq(&|1ckJM=ug(w3THXXdvoG)wu8}w?9oxnPcY~U`g&IY}kWrLRCEgLup zD;VxSv2}3Jxbg1~j^-!lQOBMuP9@0Y)mfo`v#iiEyk&*_d%x%ebi%l%6Sj}Pn;pfJ z!uIiF#|v`#%Dmu4&8ZNA`224stg=9+INVK{2Zv0z@iqJ=m2oZ@X&lnB^WSB zS`}t9k-~~F+Yj|vU}yG`qL>Sp(~8k`XXePNZn`pcUt9o+>i1MAUB7M?tA*;dC)djX zX6f&YW_5r4a6W-zE`t{%nIo9K=gJg&Ci&*c+`%6qmQya3zP@ZcR}&&VNFhWEUSZf` zT>|c7ct4#|@oQ$PP~UJyRbq`euPU+Kx?i?4J3XD8Uyuil06II4`r(c?(m!~hzpyT5 z(Fpt+s?UKl&cTu_%8QG=|4`v~JUu@6^fUVVXnJw6Zhy;Aph7xxtx7sW*HuYZr<`8A z81dw~qn}`p_ZBS`lSmO%O`!GH)iFDL)$5E$rOWr?BCxgPhPxu)k9AuqfohCwxx$r; zZQ`a=6;2vHUi|8cGpRPv?GV@*nGVJaBxe2^-lf+`GZej|;u2S5m&5 zQqQuKa#?XBkim0Gs)*1r^q>T+$tnD;~X`e&3_ zdU--vaQ5EAzY%Xk-{-X8`wf5hvZ^p>HV*>^tu`u&%GQ0%@^hf%| zD0PdqR_o@Pd!1QwMfnCR+Mk?#nw&N&vEuUTtTDV<)@T{tvc?yzs3=lAJRFGfG|ceq z(*>2%7Z-0nQGFRv9!_ESn>O5Ct8>QqW;vr}c*`07yX3RJC=0N}VU0fyrvLX7eKIGL z#Z|@0YZkhEUipe#bo>iTO|Nkq^moWKukkJgl=+b=?^@%$ZDd%-*O%e!1&gz0&NkjcMQh~>Pmw~x=|BR( zZX!KXRgWVFahe8k2xQ&|s@3O;p2-h+(_L-^pA{nb}JM5o%fnBd=30FkcRm3K5J3jbs z`}Ll*p!?=?tMeD?1^2F-UF>_N8N`tL&WfmR5VPX?*+s|7O4l0V^bp>Q)cR@Rs_Oyt zgG!gkES@%GS{CYQv*h<$PkYQFN(t8%;;mhj68`aPQNcglKmmFCQ{UhB)b@t?*084~ zgp^}L{(>HJ6>{BgQ{WRC*QB0*{94rW59^gy@{?09@49$eq=cZ>+3{0JeLu}{^QYTC zUtG>7N0(3Y#K2RNY4Mq)ryfeJLB#8!?6MVpDt6Ec#IIgulaRZ26!JBW@ca{HX{AMd7fHp??D&{-CekTdJ#ylBnjp zu0WR6X${oVe_lwUR)JIke-9Mg#kQ^_j!BVPQsFL-YUOUb%7VoJw=!9)uNo!4SXtUB zs^wF{b+_bEb6OEtsaL!&Ac=bQ5-Z7y=|X+IYB#W$#t*oAUf(;N${wJN=0b;qIxNbe zfLfTUh70OaFX%#Yr~&kIiV$i>PhHC9>9^F9{xU5-fytk82_bTvQUM!S{cQ3cTd)8%hJl;Sw5ipMn#b;tzoMf z2!3#EYXs|1)DxigdSJgTubg3zd`}~7)IU>`@5u4}{20)i9TH>e(1QmNKTrEN(%R<7 z@k^%Zxlmy3OyUeuA{GJD(C=7H?(Qwmly=^&kwr=GUPWlK+)cT(2l~zjTjtloRw&2LCgE3M!uw#;qe;G<59~p8#Ad4ySh9RC z-pZcCrMh@NJIgb8EbYn9^OJmg;P3YK_kP@dx%uFwltRlr54L*7KMk9jCY`kMBQ$JD zI|>q3Ook0OBYWJc*%_milbseE(cWx{+wm}|K&l}n>o0ok7rj`jBYj!+#Z4Z4Exd*G z@dG*04a2X+l$7r&zC?SU;Z!lTGMJT25`*q($#Z0|8#Wu zX+CXqC5@D{O;$$Rl|7Z&*WT^1CHwc5Osm;K*_*Zz*$x$jE4%SW$4OmLw+D;b2CBC& zMF$k1C>Y))=jhV!VVCCUut;0jdw!4%vhmoxgdOrlEB5yHzuLve`Tp$nZ1> z*n@Yo=lV}gZ%#*Pnq=KUmLF=UfzZ=&!Bslb+=J_*$WYq@Ap99kDjDzPJdvwJ1^DLOP(^JE5LI z_qPuY_ujmc-A;%7gzg^Mmi=tD!Eqi~6r*cH^(bnEEx&9^@T*>fY^7m$PrD zzI9Js(DZPtY7R#zt&X@dsbV=lpXI*!>gQ+NlTXgB{mK1t((k9G7LSs7cpu5!t$#BI z3*-i{I7NlCW@@}%;aSh_ZQ8J4cZ2iX6T$U%>_DpVo5Jb2`e|Z?3iw>UIpXMD-vhYkAKm2g%PD8@dr_=kJ?*TFPgdoL z*0bf>gDx*Noqoe9Rn8bKb3~OIDFk`y2rCmfz1{u2h$C9%Kmesh2gUtV4%KEDnufBc z#J%M<7h>hdPHi?lCDEW;m?znzud=V*mMI>K`pJ)hUjxL`{ks2^Y(wP=#uD< z`Up8U(i{$nkD?BTG4(2#{;UMeSN1$wx0BL ztwZQgS$;u1sPFMUNhk$4z5Du5;2}A%&Zrl8sXl=%?+GjFAM#{D)A+7DA-te^S4$`1 zqphwS!KeA}&C18&x!$%lo^cmWjdLs=ep~$vIXTHq)WP0y?2x$s^NI6Cou>G4GMCMI z3N7n;@x9&@-SX{xa(qH<%idXefEvdoew5lrbSWn&=4mds%&IePQPNB$zml{&(m!M8 zTX4Lkyr4LwuW|q)RG-aV7Sb)7=B6}B{+F9fRpqM5eLJ0foL z4#z^l!Lh#|e3<-2DzPch=UX$P#(QhLG* zeA5yFlaTW9HDvdJq^*86q!ul zbMAvUhei_{(<^sA`^Dc!>2ImrYxgyI3ifQ<5%MBv{jP34is@HA3{9%9h#q;NzA^v+HSDh}R z<|p#kOn@}A%*&hacqqFz#ac7f6+3+`4O{LJSJrN0@xvaygq*=oJZS9e)@zM36;33)@QkT*>dlQ+hhUqW6fK)TD2cl<~7 zd%C@>U*@FicF%h*&RKe#bdvs8Ugj(1uSRKG&!{`>C1o+KDT&NYkAF-4*z&Kb_)ddi zn)OR8NPkTfL8yeygV8WA%F8FKANOKva~+=*K>x*enqAD^U2g2m=I67ygR(mt8{4O+ z8yqkF;qqc*KBZma*XeObwo;p{OR@V z;zZJ=#w}7Qi!>Q!gW)h8^t=6Dmwl9L)341#`*k)XY#ZCsAU3F6*<&+NSR= z_raP^d^bjs0Z{cFZ^Rv0aL?iFe8C;hW|x<6u*CFrxF9Q;$ zS8M^6q-tblyc&h6?=(GKqFO^Z6YtM|8dTGwx0x#QX!_2?qe&6`(s z%6~LL7+PJZI!*a=wJPEYsB*OLKR24T(ciI~<2Egp6|E`l*||-oOR$zcPDd53cRhJ; z`P!vmY9mfd)I_zy)Mi(Jdj(L#7t0l4brk$n_r1KIV^*yQd^*Xf@7jz!GG#oZ(!;47 zjzhK9-s$Pd`Q-_n>D%+8wz92cn2ZL4w3iOMSvKnS%p3gNdVMV%esZaqS|153eef{1 zfjhX%$E@20_u40iJ@c%8t8IjRRy>-f-R=nY-@DJzw+>Bd_l|os?F@VUZZ?*PVT{wA zH!|*S>rdM)?yQ_PRw;jrA@Y?~>`^9Fu7>}jesmpMiR>jV?dSrng7KnMjzpXtMc-b3 zFaXf5Pj0LiVi_8%shQ>WL{=TJwS5fyir9WZPvbS4uaE-F~3yAD0iHeHO zw6kL-TvFZMwd?MxszY%rqE-2kpcmD)#$vhts2Sq^41(UQ?1--MLrWMbL9e(s-f3_# zX1QO$RzfX#rGndWSD`PuX>Y6>nCq;hxk&k%NvG}6mI)UO+A`&IE3;|QJg$A~Rb0!~ zBVKTfCdnTUGTZWa5FYdV%}2$5Ko9@^S2Rj2R|%aR_nTNb`BHPZ{bqMFGoh}Hf^)G{ z#i(8t+1tzrAv|^uhu>BYmRGD={1Tg9bQ)t*Z3&_wp!i+IW@-TrZi+Z@vPgVko5t&i zIBUh2DZP$O)tI=pLg%8%kKe>+3BQ#MteDniY);PZX}Yi+vkfQ?gTzhHvL-tWlN&-4 zH7#wt!yv_6%qV|!TYU4dZywO+9+It5UkUd8V!LL)g+zPa>71|eXJfnW6qRi)=D)UC z^g(cI;il0qOCw^js!b5R)5-Y-bt*d_rbisLXPk~dRCQ%r^c7M5Mf6h-@4j-oVP2`c z@_MgsjTg10X`D+0y3yZL#D3aK@iAZ9jF)=#?H3EU&cclsyYz#JhtjZn!DsHuEaedu zbhW8&_oX0=b-97{xxwwDms={ z53G>Y0;4ybF<(TRXb+i+QwA^qVQwXT7d-Ivc&1 z$nFqJdNHk2@Sb)w6D9)Z)0~m?+_LX)TfOrA`K}6$-yiYf+UZgq*=I`wW!5sSh=;e* zwDg*KKS11&#&wBpr+FocSJvgq6VSU|dE9H230!UY8rLPZzvM!P?<*5iu9s6{n?1Ho zNb#>p$%a|AX_oTgNP=67b#yz%)yTJ=c}4c!p>QMYlUrA&)laVQb?eas<+E?yS~qeP z_;BeX25!HKCb^kuMYQwP)XmlR{yuH9*!^Zl=axVdn7^80kYKjoC?c~K#t z%F)Cu{F!RNwWhphsq-tgj2ih`5e@iwK0Uk0yN&O2z6J+uX=~Kza7O*j;V2y@-TrVm z8YRg9Fuydx9($%dkAo#>h8y?0BeS(=5kKgMyk%?+G@~x&I%d*Q_S&`XMW*K1r*V-Y z*nl3RSQ9NwA&9yviApC|wJZ4Wp}u3N+SK%V65xkUJES3Hzf+|RZ$2^ENrvNYG9LGb z=`b5mGE5Odo!6+QAEYA+-ILxRHI?btUgk*8Z%h)};^&;|X4Tip`qz8)jqv)NQK=LL zAMvU5Bds)ROh10^t;yG~(l)gSr87$joNsh4x=plbE)|Ilja^XAFk|0nl&2a^9HOpg z?hRNZO1y?P@vz31DDRtx&BaY+R8}j>wXJe{XUEHIz+R5`C3&js^&dBRx>erfbTfOa1$e9TX- zY@ji;jpdhDn3QIca{f9;So-u=4REg~hvh8pCcy0{=2&o(&j`Ng|4jc9mJJMQ*jiSHRS2a+}==Fcn`97(Wk?m2b0lflLRcEZlU_d*15a zoj@)*y;~K?7`0@HZd3fOFo)Q!)}1MhWCZwvBbv4+xK?Gu=e;N2O#ojlt@w+9FU@oo z^IF@mYc6VN+tAN@`nO%w(3YjWx#;;zDaGelN^vhC9s%R-f0@PNeF<*2Btkh>_Y5?u zxAJ3bn-Wmhu*1nPOS`>c*6$651KHb4dkJ}eNjeC<{Js7t>G%6I4#-IW+T=*hUXHSN zlB7=#XOQ)~gJkUTiP15Gich*(Z=8^eO?p_>z8*Dqd-KA-WRUigevbyCe%kN$?50_@ znRXempCu`o+ce9*4%dsf4m;Vf$0XS=YMk`4y~hT6vi7rE?jJ^4XB_>Q0z~Qzx*QMG z8)f6(c$Drv?$=&Sq2O>h9(4z@=IPM*iWU1xPX>eWy;*-a7-ohtA%xefl|S#vz!|7v z-?Qqxi&ZdhFLOzcyF`n_Y&aORh18fS7@FMDp~6zAR(60Hzg`NxD_hkpJnuEF>~<() zEZ6b1@=UmKpC>5>z+l5Pkpie~XH9i(-F3l+8Fh{XEea!NxH^w%a+PPq8$7EnKtS2>eyh(rRwm01)EK@c}+4PdhFr)es zv0Zi-N1;bO`XIB``k5c4huI&ExhY9T-68cDs=6x7ri&xN7NUbb@3a*E^8uDld!u?MGYmGtkumoHzuKG^#=ht(WzZ{{~=2=ljqrcA++uI#^D~^d>*nJ|%(yebEOv)d zpQz`tx3jbMS!{Wkluanzlos=3x>sxE|Q0HJ)3cV@#O)Jw5g)$%%7 z%>-7%2O4(m=A535QubdRW(g%bBRY|~Ir;bZ-LeNR8sp%N$HURMkE3L?Q^7@92l5Yw zBiu*)(LTG=ws4W$3|sbkBPMoZOvK9c2W(7_*fX0N6Rmd=nul~RZXN4uYinZh*BEpx z?hESf?>B9PW<*nk9c?AA7Cy_F=kc=dZ~{_rHcqwv89Qb&TO_6lo@UeBwUFhrSpT?{ zC}Lm_K*-s@m~k%pl?^#;6p^$&AEeOOq%x2&DVR0J4R!y&PKUK_Rf&w|EM)7(uIIlu zZ`m$E$PR`(bP%s&__JYkxpPhg8{G|?opwWgHFu^M(W~-_lF7n*+7(w zoxfOsAydZl2gM=5oAmSeqGAA;ayRMW@BKcJ56w+@xAq@>jlJW0UhG94fQDAHDm=d9J=9vyYL2Hedu0f^rxz)Z3?cyU@_Zi`dzhJF0?E>} zpY?M9HXG|!f4AR*352J$+7q7;+Pu2HuS zEr3rA{}dD>`XngiL9~#q#hnel~+5h}0amQj8mxqFA&vhcFfSAo!%raWpc1 zp=WC%b5zFwSb99L5l^64l0k3ImX%ngUqs(ky}$t>BS7oH2(-N0HSm}xS;8jvW2k`& zLOl93$|>>i(;18b3lI-twK0c0D0UAtO8^{3AmpN_2#Si>sXn1>G~}?1bd02p4N_ZB zZPdwnp!}i{(Pu<`P%Xq^>ctT`TN~A)rAq{1BTgLd0(bxkM15lznpUxHlJ&@=H^`ab z2VF|qxmknF5z|0oi42XjdYXY|06s7bFi*~|UQd@a zV_4(N`Y+1#t37c^@J)7<`sR8$z-mO!wwV3CfXj66<`386j`Pk4vypr8E;D^{TL-0_D(D;>A~R${4Xx}lBPpW z$ReU|^)P{GY|kQ+icS|KvBALhGL8>Qgd_=KtH&uMt=|DG?V^V`kQ}HpBX8KM9W70+ zOf(4}{qAS6-K1>9k7(#c_5vnoTlk4O4(5=@eK|rei%7tWf)4f zALHziY``+I9C5-zeR2$VML5e=1Ls`d?}%OHGDs^%W<0tfrY~X6#(-6C$+dF8T9V*K z>i%6?>0(;%$Pb#<;C0uQUQt#9!cREfRb@m4f+l(t7bOCC?G}6Y3@65OlyNRyCYX0T zb|@_nVr<0IGu(=k$r+9W&}vJQJExL0(g?71^$We`JdRvBEWv*|XjlAa+?Lf+tw(dt z_q(G_;6Xbu!xABYd7yyof_WWHK#03a{LmYdV$)hD=;%z~65_&M%6%k;qAZCm2Q}!y zazp&Y<#e=O{~$(;L7tOLs+33JCg`{MNh2fT(~I>XB^sSHUMOcJ<`d!@yLy;l1hFPy z55-M-m>@>H2Ly-6N$Q^l4gQKaB5aC+u%Y4gFkX&az)XmZWJ@C0h8STsn20_pRNUGj z;D{cl5G3~v@o<~y2AdB+Lnw1su5VKX5XiHnBCUEcf+9c+(ZEe?|!=Emuvo!=OG z;ZQ;WBl{w+#krT2KzIa72uq4m9S@8!UzG}recf2JPN)XYLyDBBlrSkKI)JnI=jbkA zSPS=tIZ6Oc#+;KNC2t;=9E2*@`~1@=V=u_K5&bcw#EI`oAxLyCW6^uSW*|N`So2bx zW`t?OG2M%#&4BtqJ>AXm^_uJ2)KZiCD1KSxUI6!7&Kpi4I(2` z?&~Q79zl$aAPVMyax;N_5F%=eg$13W9UCDkX+y9et47!^7+Q=r1aRnTDx}6eGlr)O zm=DB7yAs^X{!a0^h*vvCz4lSow=B?6(!@gWHWxG3Z)O1kG043D!if(BT>vEXDh6Tz z)0T1)7ZhwAOu-gwWKO8H*28%93WO~OBcoa+T+|#M$RV)<7$W!yVgOn7Fkv=bax;Jc z>JuQYKtf)PngCzaFRrxktQHaCf~P1$AOyuWt*IfVKu9 zqqL4a3kIX8B(V7CFFa~I3Y)YEi%*FSm2X3o6u5m8D-eGVBtro5fkUWgbK{Gzjaw8X zVFmEq#211^fOet_0drEpj}H!2qL5+JsxP)?=!UW>DTDhoaBiwD2#*K5z z{_NL)S4Q_K2d~2^D1TxL0q_^MUw8yS^Wle}C6Y+?9);l=!@-dtg}|43L-k|qQJ@gM zn-;vF%dbB%vtTj*U35b{*+sx-knS)W38B=-m`@2@A{Ji0rFofRZY=(*6JJ~q_hbBU zzf4^s;OH`4e`02tV!kVpVK=@^1U1G~g=NC+ka)=d#0{{xLG8;Fb=Po8sk`<+(U^x` zE+gb?n3q|ln6E;P*nD$bPopYT=oIEP##0gBv-mNj4{XJ9ITyN_tzt$Sl}Oy~DN(`n zJzL?I#x?XcCRQSS()%G|;C7At=Refuk>lnvTD%G=wku8t;5Qj1{8pMGbvnRjTt;h@ zt}Q0YOCD2c*t&Wd?fS;8l+g;bR{lW8(oA?}GAv%HqHTFR@VR;Z4vJ{Ot#B08kN(J_ z5Ve!_*Vah)rKom6p|9wJ^s{3LJ$ggq7H=2TKO_SBQno{F z52a}~a~IYREE0#CJQ($6#)sR-?m@yUP<4nRJ@z`6i6iVQnm+2+-~ zlAEgc5HbM)v>-TG&Q>Ztf{=^YX9(H_-sS#4@irgqDqxmr8YI_4ffX3H^uw`)`o%@l z;A*dJ>hLNgG0HQsu#h_FI}}N;a7(5k*~x^c9DtVb8ff}mL4O8)qJPk5P3^!mBp@gf zq-aO5OxRP&LEvEOB#B`J@Fas^;@_p125bbjln!396t~ATtG!k;P4AH`U9M=#DCVm5 zGS{<3--aDSSE-k|g59C3f(#C<0JuuYrBTCP8KLRf8iYg>rFIRtLvM%<0!hb2xenzh zV>w$um0_P8NuCB;tj(hqh&Jf47$9k@KtY-~IZPV6LxEvNwSgR-qEw$(=cszxGsyRV z7*ajkkP%0qkUYv_L6~isx))8-aI$3!WqSb3MA-2)luAuXA7uZPsAvl4F93C)dIl_F zLu~}e02e9Nq))@nW(nD(vr*7tN=^aOfkAIVI3o&u<<|z`%%XkY8hMNve^TFLDI}*j z6`UCdKr^Rj7ZwKE!JJZ$V3c0TF1TJaph&$beMr*rQaTYqd9tihfJqu2Fr|)bEpL79 zRxqpPhpIOkXC%eNo(%XKad=%aY1HFL?@I%`F15_~Pc&embt;v{w-OamJEd zqNqG}Hi%{u+0BBAaq_C-h~lmqi%{}apRyM6-4wDFRdv_2KlJX1qt&CYg2cJ)P6!G` z%I#%OM^vrx1%f`wZ5YN?cKl+*s6N5y%AOI@yaYpQGZxGbB>;RpVG_Xvby?BSkwzOi z>lGt|UNXpBA}4A3)!Qr!5C7cjQdCcfFm$8T5<`=qy?RLPdeF%vv|b~h$d@T zlgyQ08$`2vJ@aF{6*^ipojtdZ^Dn&>L*|lQh=QrHk)9{YFdJALor(RzgO^(zofors zH29$rOuBO9BY9`=fcH`565;&Y|FgAr>+`#{gpp>&25cLrrQGh{JfgWcG z7N_Nv#IbD9f)tHzg25Xi=2hOR`CFTw$lmqwge4trSXx(h2g1S(OTQ@aH_e&2UbtA) zgy=;rImH!+;TTBm6m^Pvl#)2;4We7=twh<$IEKBoCm2m(f>lsc2P?teL^S3leyURD z@8SVr5yMF3TqPJHyrtg;c`UYGtahgX?od073xi3@`>xDfqn=|MYS29 zjG$!>rnFH?DU0;#BKO!OUPY}%d^Ys{VYuXILf|evom>{#=8D|_kZ6=0HHg&JyI~sk z#9|vqz!P3coJHxf?1`m*`fEnFFx(MiwB9*r$sImw6^Cf9T1ug0=LS|+T14ZVMIX1o zl!E5+NwGM3NP3w4%~)EzE7WF`mV}-JPB=2ewh`1hDh|;^Ha(BhA3&6b!y{Fx5p@GQ zNPnel-@wKJ(MeOl1BgkxV~UH$4hDLdAPy7&VY?)1q|(i}O-ddFWLG~%GGdm>sc#_e(uFSe5!G3oZF76&JwK`^8y6o*>?cDrFfuoECXi8 zMlg(+Mg)UaoB}ay77XA_fjV(D`5z$mO|2a!@{ zB$68ELzISWA0YCKgQaX`kO~6*HJA$=qP~=2!^;HJO_@mGPV6=vR!D`B(TG$?!MXdU zW}(0`fwBPH(ddW&477IBV!@2!)?%#_F@mA@gp340lqyD8%y{TT;F>uCi)jzt=%|v# zsQss}kswy~PBH2`s5)G3$;RN@NGfkhYY`9IR4hcw&={vMX&nohU^q;gerKr$z(@+v zWkx9{O?f{4BM}#Yo8d$y7gRp39994UkR&DFgL)y1G{H$Z8>UOJ39v>WXq>}BE}UmX z7L!x}2H07IMW~BpjiH)R9=Yw&1Nw#EM>)@TEcNO0+1@8a}@q zd2lxhcfFG^%637_3PKkV1_BkTaHRZ&coH{N(;-k3CKlJ7c9=?cTo8sFZba!wk;hmM zQW6uK5S$0Zh|tw>=n_6iG-f5?z=Wk6iv4pe*x{#)zfGGsE!ue2{kRLFr`+T>vG+un zvr*VV(}MELzUV+8E~%}*+|LEg=eE<6M2YRkD1|{ENwIK0*<|Oy9ZsXvsW`jUDF9l2qiDxyXtm_tp0;^XO ziuF%K3r4meR3&d3<1t$J&^)p~1z^5SJO!!(AUe{6=G(6H(nws(xm`jqqLHe&OH7;O zs_dE(N1XsnAUSCOa3>4Agh}HplwriU!B5d)L_AUj8Dz^mV)d>d{Wf3Vmk>abC<5>| z!P*)`vNROhBS(}KT3cEQ?U9@KAZ96~)XELhAQ%o~7vK%yAGuQtZWiW8>(*pqt#dHq zupmHXkFuCJfMIiR1bU8K3VK&lA<$Ba1AwuqN5B`>j QtOMl0T;!ln^6KS0pbYu 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
  • {nZX*3jW5qrc)(1gT5*vPCGdZ&&gQJRSMj* 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 PbPzm95D+Ds|GfKOZss*S diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 31d2415a41c481e7a896b3017d04e4e7f6387690..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54788 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 \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# 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 - ;; -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" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - 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 -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 -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 - -# 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\"" - fi - i=$((i+1)) - 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")" -fi - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index e95643d6..00000000 --- a/gradlew.bat +++ /dev/null @@ -1,84 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@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= - -@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 - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -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% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="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 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/lombok.config b/lombok.config deleted file mode 100644 index fd4380fa..00000000 --- a/lombok.config +++ /dev/null @@ -1,2 +0,0 @@ -#lombok.accessors.chain = true -config.stopBubbling = true \ No newline at end of file diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 7e9c4070..00000000 --- a/settings.gradle +++ /dev/null @@ -1,23 +0,0 @@ -rootProject.name = 'clustercode' - -include 'clustercode.api.rest.v1' -include 'clustercode.api.transcode' -include 'clustercode.impl.util' -include 'clustercode.api.cluster' -include 'clustercode.api.domain' -include 'clustercode.impl.constraint' -include 'clustercode.api.config' -include 'clustercode.api.event' -include 'clustercode.test.util' -include 'clustercode.impl.transcode' -include 'clustercode.api.process' -include 'clustercode.impl.process' -include 'clustercode.impl.cluster.jgroups' -include 'clustercode.main' -include 'clustercode.api.scan' -include 'clustercode.api.scan' -include 'clustercode.impl.scan' -include 'clustercode.api.cleanup' -include 'clustercode.impl.cleanup' -include 'clustercode.test.integration' - diff --git a/src/assembly/clustercode-admin/nginx/nginx.conf b/src/assembly/clustercode-admin/nginx/nginx.conf deleted file mode 100644 index ec69cf8c..00000000 --- a/src/assembly/clustercode-admin/nginx/nginx.conf +++ /dev/null @@ -1,120 +0,0 @@ - -#user nobody; -worker_processes 1; - -#error_log logs/error.log; -#error_log logs/error.log notice; -#error_log logs/error.log info; - -#pid logs/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include mime.types; - default_type application/octet-stream; - - #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - # '$status $body_bytes_sent "$http_referer" ' - # '"$http_user_agent" "$http_x_forwarded_for"'; - - #access_log logs/access.log main; - - sendfile on; - #tcp_nopush on; - - #keepalive_timeout 0; - keepalive_timeout 65; - - #gzip on; - - upstream rest { - server localhost:7700; - } - - server { - listen 8080; - server_name localhost; - - #charset koi8-r; - - #access_log logs/host.access.log main; - - #error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - # - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root html; - } - - location / { - root ../clustercode-admin; - index index.html; - } - location /api/v1/ { - proxy_pass http://rest; - proxy_read_timeout 15s; - proxy_connect_timeout 15s; - } - - # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 - # - #location ~ \.php$ { - # root html; - # fastcgi_pass 127.0.0.1:9000; - # fastcgi_index index.php; - # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; - # include fastcgi_params; - #} - - # deny access to .htaccess files, if Apache's document root - # concurs with nginx's one - # - #location ~ /\.ht { - # deny all; - #} - } - - - # another virtual host using mix of IP-, name-, and port-based configuration - # - #server { - # listen 8000; - # listen somename:8080; - # server_name somename alias another.alias; - - # location / { - # root html; - # index index.html index.htm; - # } - #} - - - # HTTPS server - # - #server { - # listen 443 ssl; - # server_name localhost; - - # ssl_certificate cert.pem; - # ssl_certificate_key cert.key; - - # ssl_session_cache shared:SSL:1m; - # ssl_session_timeout 5m; - - # ssl_ciphers HIGH:!aNULL:!MD5; - # ssl_prefer_server_ciphers on; - - # location / { - # root html; - # index index.html index.htm; - # } - #} - -} diff --git a/src/assembly/clustercode-admin/windows/start-clustercode-admin.cmd b/src/assembly/clustercode-admin/windows/start-clustercode-admin.cmd deleted file mode 100644 index 0e4306f3..00000000 --- a/src/assembly/clustercode-admin/windows/start-clustercode-admin.cmd +++ /dev/null @@ -1,8 +0,0 @@ -@echo off - -echo Starting clustercode-admin... -cd nginx -start /b nginx.exe -echo done. Stop the server with stop-clustercode-admin.cmd -echo This window autocloses in 5 seconds. -ping 1.1.1.1 -n 1 -w 5000 >NUL diff --git a/src/assembly/clustercode-admin/windows/stop-clustercode-admin.cmd b/src/assembly/clustercode-admin/windows/stop-clustercode-admin.cmd deleted file mode 100644 index d1f7a44c..00000000 --- a/src/assembly/clustercode-admin/windows/stop-clustercode-admin.cmd +++ /dev/null @@ -1,7 +0,0 @@ -@echo off - -echo Stopping clustercode-admin... -cd nginx -start nginx.exe -s stop -echo done. This window autocloses in 3 seconds. -ping 1.1.1.1 -n 1 -w 3000 >NUL diff --git a/src/assembly/clustercode/resources/config/log4j2-debug.xml b/src/assembly/clustercode/resources/config/log4j2-debug.xml deleted file mode 100644 index 1776059d..00000000 --- a/src/assembly/clustercode/resources/config/log4j2-debug.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/assembly/clustercode/resources/config/udp.xml b/src/assembly/clustercode/resources/config/udp.xml deleted file mode 100644 index 32b98b58..00000000 --- a/src/assembly/clustercode/resources/config/udp.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/src/assembly/clustercode/resources/profiles/default.ffmpeg b/src/assembly/clustercode/resources/profiles/default.ffmpeg deleted file mode 100644 index 1e9317ac..00000000 --- a/src/assembly/clustercode/resources/profiles/default.ffmpeg +++ /dev/null @@ -1,33 +0,0 @@ -# This is a ffmpeg template file which encodes in x264 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. - --hide_banner -# Force yes when overwriting files: --y - -# This option is necessary! FFMPEG has input and output options so the order of the option matters. -# Therefore you have to specify the input in this file. --i ${INPUT} - -# copy metadata -#-map_metadata 0 - -# copy chapter markers and set languages to english --map_chapters 0 --map 0:0 -metadata:s:v:0 language=eng --map 0:1 -metadata:s:a:0 language=eng - -# use x264 encoder --c:v libx264 --preset ultrafast -# set parameter for libx264 --crf 24 - -# copy audio --c:a copy -# copy subtitles -#-c:s - -${OUTPUT} -# Specify format (clustercode specific) -%{FORMAT=.mkv} \ No newline at end of file diff --git a/src/assembly/clustercode/resources/profiles/x265.ffmpeg b/src/assembly/clustercode/resources/profiles/x265.ffmpeg deleted file mode 100644 index 6523e528..00000000 --- a/src/assembly/clustercode/resources/profiles/x265.ffmpeg +++ /dev/null @@ -1,35 +0,0 @@ -# This is a ffmpeg template file which encodes in x265 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. - -# This template is based on http://unix.stackexchange.com/questions/230800/re-encoding-video-library-in-x265-hevc-with-no-quality-loss - --hide_banner -# Force yes when overwriting files: --y - -# This option is necessary! FFMPEG has input and output options so the order of the option matters. -# Therefore you have to specify the input in this file. --i ${INPUT} - -# copy metadata -#-map_metadata 0 - -# copy chapter markers and set languages to english --map_chapters 0 --map 0:0 -metadata:s:v:0 language=eng --map 0:1 -metadata:s:a:0 language=eng - -# use x265 encoder --c:v libx265 --preset faster -# set parameter for libx265 --x265-params ctu=32:max-tu-size=16:crf=23:qcomp=0.8:aq-mode=1:aq_strength=1.0:qg-size=16:psy-rd=0.7:psy-rdoq=5.0:rdoq-level=1:merange=44 - -# copy audio --c:a copy -# copy subtitles -#-c:s - -${OUTPUT} -# Specify format (clustercode specific) -%{FORMAT=.mkv} \ No newline at end of file diff --git a/src/assembly/clustercode/windows/clustercode.cmd b/src/assembly/clustercode/windows/clustercode.cmd deleted file mode 100644 index f10e32b2..00000000 --- a/src/assembly/clustercode/windows/clustercode.cmd +++ /dev/null @@ -1,12 +0,0 @@ -@echo off - -set java=java - -%java% -version -IF %ERRORLEVEL% EQU 0 ( - %java% -jar clustercode.jar - pause -) else ( - echo Java is not installed or in PATH. Modify your environment variables or specify the absolute path in this CMD file. - pause -) diff --git a/src/assembly/clustercode/windows/config/clustercode.properties b/src/assembly/clustercode/windows/config/clustercode.properties deleted file mode 100644 index 286fc195..00000000 --- a/src/assembly/clustercode/windows/config/clustercode.properties +++ /dev/null @@ -1,157 +0,0 @@ -#+-----------------------------------------------------------------------------------------------+ -#| CLUSTERCODE SETTINGS | -#+-----------------------------------------------------------------------------------------------+ - -# This settings file contains the keys and values of the clustercode configuration system. -# Only the allowed ranges and values of the keys are explained. For their explanations of -# semantic, head over to the github wiki and read there! - - -# ALL SETTINGS ARE OVERWRITTEN BY ENVIRONMENT VALUES! - -#------------------------------------------------------------------------------------------------- -# DIRECTORY SETTINGS -#------------------------------------------------------------------------------------------------- - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_TRANSCODE_TEMP_DIR = tmp - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_PROFILE_DIR = profiles - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_MEDIA_INPUT_DIR = C:\\Users\\Public\\Videos - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_MEDIA_OUTPUT_DIR = \\\\server\\a UNC\\Path - -#------------------------------------------------------------------------------------------------- -# TRANSCODER SETTINGS -#------------------------------------------------------------------------------------------------- - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_TRANSCODE_CLI = C:\\Program Files\\HandBrake\\HandBrakeCLI.exe - -# Bool. true | false. -CC_TRANSCODE_IO_REDIRECTED = false - -# String. File name. Provide leading '.' -CC_TRANSCODE_DEFAULT_FORMAT = .mkv - -# Enum -# Values: HANDBRAKE, FFMPEG -CC_TRANSCODE_TYPE = HANDBRAKE - -#------------------------------------------------------------------------------------------------- -# SCAN SETTINGS -#------------------------------------------------------------------------------------------------- - -# Array[String]. Unordered. Separator: ,(comma). Do not provide leading '.'. -CC_MEDIA_EXTENSIONS = mkv,mp4,avi - -# String. File name. Do not provide leading or trailing '.' -CC_PROFILE_FILE_NAME = profile - -# String. File name. Provide leading '.' -CC_PROFILE_FILE_EXTENSION = .handbrake - -# String. File name. Provide leading '.' -CC_MEDIA_SKIP_NAME = .done - -# String. File name. Do not provide leading or trailing '.' -CC_PROFILE_FILE_DEFAULT = default - -# Enum[String]. Ordered. Separator: (space). -# Values: COMPANION, DIRECTORY_STRUCTURE, DEFAULT -CC_PROFILE_STRATEGY = COMPANION DIRECTORY_STRUCTURE DEFAULT - -# Integer. 1 <= x. Unit: Minutes -CC_MEDIA_SCAN_INTERVAL = 30 - -#------------------------------------------------------------------------------------------------- -# CONSTRAINT SETTINGS -#------------------------------------------------------------------------------------------------- - -# Enum[String]. Unordered. Separator: (space). -# Values: ALL, FILE_SIZE, TIME, FILE_NAME, NONE -CC_CONSTRAINTS_ACTIVE = FILE_SIZE - -# Integer. 0 <= x. Unit: MB -CC_CONSTRAINT_FILE_MIN_SIZE = 150 - -# Integer. 0 <= x. Unit: MB -CC_CONSTRAINT_FILE_MAX_SIZE = 0 - -# String. Format: HH:mm. -CC_CONSTRAINT_TIME_BEGIN = 08:00 - -# String. Format: HH:mm. -CC_CONSTRAINT_TIME_STOP = 16:00 - -# String. -CC_CONSTRAINT_FILE_REGEX = - - -#------------------------------------------------------------------------------------------------- -# CLEANUP SETTINGS -#------------------------------------------------------------------------------------------------- - -# Enum[String]. Ordered. Separator: (space). -# Values: UNIFIED_OUTPUT, STRUCTURED_OUTPUT, DELETE_SOURCE, MARK_SOURCE, CHOWN, MARK_SOURCE_DIR -CC_CLEANUP_STRATEGY = STRUCTURED_OUTPUT MARK_SOURCE - -# Bool. true | false. -CC_CLEANUP_OVERWRITE = true - -# Integer. 0 <= x. -CC_CLEANUP_CHOWN_GROUPID = 0 - -# Integer. 0 <= x. -CC_CLEANUP_CHOWN_USERID = 0 - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_CLEANUP_MARK_SOURCE_DIR = done - -#------------------------------------------------------------------------------------------------- -# API SETTINGS -#------------------------------------------------------------------------------------------------- - -# Bool. true | false. -CC_REST_API_ENABLED = true - -# Integer. 1024 <= x. -CC_REST_API_PORT = 7700 - -#------------------------------------------------------------------------------------------------- -# NETWORK and CLUSTER SETTINGS -#------------------------------------------------------------------------------------------------- - -# String. -CC_CLUSTER_NAME = clustercode - -# Path. Relative | Absolute path. Windows: use "\\" as path separators. -CC_CLUSTER_JGROUPS_CONFIG = config/tcp.xml - -# Bool. true | false. -CC_CLUSTER_JGROUPS_PREFER_IPV4 = true - -# Integer. 1024 <= x. -CC_CLUSTER_JGROUPS_BIND_PORT = 7600 - -# String. Format: IPv4 | IPv6 | Jgroups-match | Enum -CC_CLUSTER_JGROUPS_BIND_ADDR = SITE_LOCAL - -# String. Format: IPv4 | IPv6. Set to empty or uncomment if not used -CC_CLUSTER_JGROUPS_EXT_ADDR = - -# Array[String]. Format: IPv4[port] | IPv6[port] | DNS hostname[port] -CC_CLUSTER_JGROUPS_TCP_INITIAL_HOSTS = localhost[7600] - -# String. Can be empty. -CC_CLUSTER_JGROUPS_HOSTNAME = - -# Integer. 0 <= x. Unit: Hours -CC_CLUSTER_TASK_TIMEOUT = 6 - -# Bool. true | false. -CC_ARBITER_NODE = false diff --git a/src/assembly/clustercode/windows/config/fork.xml b/src/assembly/clustercode/windows/config/fork.xml deleted file mode 100644 index f5e53ba1..00000000 --- a/src/assembly/clustercode/windows/config/fork.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/src/assembly/clustercode/windows/config/tcp.xml b/src/assembly/clustercode/windows/config/tcp.xml deleted file mode 100644 index 757e1976..00000000 --- a/src/assembly/clustercode/windows/config/tcp.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - bind_addr="${jgroups.bind_addr:SITE_LOCAL}" - bind_port="${jgroups.bind_port:7600}" - recv_buf_size="${tcp.recv_buf_size:130k}" - send_buf_size="${tcp.send_buf_size:130k}" - max_bundle_size="64K" - sock_conn_timeout="300" - - thread_pool.min_threads="0" - thread_pool.max_threads="20" - thread_pool.keep_alive_time="30000"/> - - - - - - - - - - - - - - - - - - diff --git a/src/assembly/clustercode/windows/done/README.md b/src/assembly/clustercode/windows/done/README.md deleted file mode 100644 index 97222b55..00000000 --- a/src/assembly/clustercode/windows/done/README.md +++ /dev/null @@ -1 +0,0 @@ -This directory is used to mark input files as "done" diff --git a/src/assembly/clustercode/windows/log4j2.xml b/src/assembly/clustercode/windows/log4j2.xml deleted file mode 100644 index 1b08d6f1..00000000 --- a/src/assembly/clustercode/windows/log4j2.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/assembly/clustercode/windows/profiles/default.handbrake b/src/assembly/clustercode/windows/profiles/default.handbrake deleted file mode 100644 index 0e889bbc..00000000 --- a/src/assembly/clustercode/windows/profiles/default.handbrake +++ /dev/null @@ -1,45 +0,0 @@ -# This is a handbrake template file which encodes in x264 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. - -# See https://trac.handbrake.fr/wiki/CLIGuide for a list of options - -# Destination options------------------------------------------------------ ---input ${INPUT} ---output ${OUTPUT} - -# To force mp4 videos uncomment this line: -#--format av_mp4 -#--optimize - -# Use Mkv container ---format av_mkv - -# Video Options------------------------------------------------------------ - ---encoder x264 ---quality 24 -# To use variable bitrate, comment "--quality" and uncomment following: -#--vb 3000 - -# Audio Options------------------------------------------------------------ - -# this will copy all audio tracks as they are in the source ---aencoder copy - - -# Picture Settings--------------------------------------------------------- - -#--width 1920 -#--height 1080 - -# Filters------------------------------------------------------------------ - -#--deinterlace - -# Subtitle Options--------------------------------------------------------- - -#--subtitle "1" -#--srt-file /input/0/movie.srt - -# Specify format (clustercode specific) -%{FORMAT=.mkv} \ No newline at end of file diff --git a/src/assembly/clustercode/windows/profiles/x265.handbrake b/src/assembly/clustercode/windows/profiles/x265.handbrake deleted file mode 100644 index c6455275..00000000 --- a/src/assembly/clustercode/windows/profiles/x265.handbrake +++ /dev/null @@ -1,48 +0,0 @@ -# This is a handbrake template file which encodes in x265 in High Quality. -# Note: Lines starting with # are comments. Comments within a line are NOT supported. - -# See https://handbrake.fr/docs/en/1.0.0/cli/cli-guide.html for a list of options -# This template is based on http://unix.stackexchange.com/questions/230800/re-encoding-video-library-in-x265-hevc-with-no-quality-loss - -# Destination options------------------------------------------------------ ---input ${INPUT} ---output ${OUTPUT} - -# To force mp4 videos uncomment this line: -#--format av_mp4 -#--optimize - -# Use Mkv container ---format av_mkv - -# Video Options------------------------------------------------------------ - ---encoder x265 ---quality 23 -# To use variable bitrate, comment "--quality" and uncomment following: -#--vb 3000 - ---encopts ctu=32:max-tu-size=16:qcomp=0.8:aq-mode=1:aq_strength=1.0:qg-size=16:psy-rd=0.7:psy-rdoq=5.0:rdoq-level=1:merange=44 - -# Audio Options------------------------------------------------------------ - -# this will copy all audio tracks as they are in the source ---aencoder copy - - -# Picture Settings--------------------------------------------------------- - -#--width 1920 -#--height 1080 - -# Filters------------------------------------------------------------------ - -#--deinterlace - -# Subtitle Options--------------------------------------------------------- - -#--subtitle "1" -#--srt-file /input/0/movie.srt - -# Specify format (clustercode specific) -%{FORMAT=.mkv} \ No newline at end of file diff --git a/src/assembly/clustercode/windows/tmp/README.md b/src/assembly/clustercode/windows/tmp/README.md deleted file mode 100644 index f32076ac..00000000 --- a/src/assembly/clustercode/windows/tmp/README.md +++ /dev/null @@ -1 +0,0 @@ -temporary files go here. \ No newline at end of file diff --git a/src/integration-test/java/net/chrigel/clustercode/cluster/impl/CancelTaskIT.java b/src/integration-test/java/net/chrigel/clustercode/cluster/impl/CancelTaskIT.java deleted file mode 100644 index d89bce5f..00000000 --- a/src/integration-test/java/net/chrigel/clustercode/cluster/impl/CancelTaskIT.java +++ /dev/null @@ -1,83 +0,0 @@ -package net.chrigel.clustercode.cluster.impl; - -import com.google.inject.AbstractModule; -import com.google.inject.Guice; -import com.google.inject.Injector; -import com.google.inject.name.Names; -import net.chrigel.clustercode.GlobalModule; -import net.chrigel.clustercode.api.RestApiServices; -import net.chrigel.clustercode.api.impl.ApiModule; -import net.chrigel.clustercode.api.rest.TasksApi; -import net.chrigel.clustercode.cleanup.impl.CleanupModule; -import net.chrigel.clustercode.cluster.ClusterService; -import net.chrigel.clustercode.process.impl.ProcessModule; -import net.chrigel.clustercode.scan.MediaScanSettings; -import net.chrigel.clustercode.scan.impl.MediaScanSettingsImpl; -import net.chrigel.clustercode.scan.impl.ScanModule; -import net.chrigel.clustercode.transcode.impl.TranscodeModule; -import org.junit.After; -import org.junit.Before; -import org.junit.Ignore; -import org.junit.Test; - -import java.util.Arrays; -import java.util.Properties; - -import static org.assertj.core.api.Assertions.assertThat; - -public class CancelTaskIT { - - private Injector injector; - - @Before - public void setUp() throws Exception { - Properties properties = getMinimalConfiguration(); - injector = Guice.createInjector(Arrays.asList( - new ApiModule(properties), - new ClusterModule(), - new TranscodeModule(properties), - new ProcessModule(), - new GlobalModule(), - new AbstractModule() { - @Override - protected void configure() { - bind(MediaScanSettings.class).to(MediaScanSettingsImpl.class); - Names.bindProperties(binder(), properties); - } - } - )); - - injector.getInstance(ClusterService.class).joinCluster(); - } - - @After - public void tearDown() throws Exception { - injector.getInstance(ClusterService.class).leaveCluster(); - } - - @Test - public void cancelTask_InCluster_ShouldReturn409_IfHostnameDoesNotExist() throws Exception { - - TasksApi api = injector.getInstance(TasksApi.class); - - assertThat(api.stopTask("test").getStatus()).isEqualTo(409); - } - - @Test - public void cancelTask_Locally_ShouldReturn409_IfHostnameDoesNotExist() throws Exception { - - TasksApi api = injector.getInstance(TasksApi.class); - - assertThat(api.stopTask("localhost").getStatus()).isEqualTo(200); - } - - private Properties getMinimalConfiguration() { - Properties config = new Properties(); - config.setProperty(CleanupModule.CLEANUP_STRATEGY_KEY, "CHOWN"); - config.setProperty(TranscodeModule.TRANSCODE_TYPE_KEY, "HANDBRAKE"); - config.setProperty(ApiModule.REST_PORT_KEY, "8080"); - config.setProperty(ScanModule.MEDIA_INPUT_DIR_KEY, "input"); - config.setProperty(ClusterModule.CLUSTER_JGROUPS_HOSTNAME_KEY, "localhost"); - return config; - } -} diff --git a/src/integration-test/java/net/chrigel/clustercode/cluster/impl/JgroupsClusterImplIT.java b/src/integration-test/java/net/chrigel/clustercode/cluster/impl/JgroupsClusterImplIT.java deleted file mode 100644 index 317ecf2c..00000000 --- a/src/integration-test/java/net/chrigel/clustercode/cluster/impl/JgroupsClusterImplIT.java +++ /dev/null @@ -1,63 +0,0 @@ -package net.chrigel.clustercode.cluster.impl; - -import net.chrigel.clustercode.cluster.JGroupsMessageDispatcher; -import net.chrigel.clustercode.cluster.JGroupsTaskState; -import net.chrigel.clustercode.scan.Media; -import net.chrigel.clustercode.test.MockedFileBasedUnitTest; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -public class JgroupsClusterImplIT implements MockedFileBasedUnitTest { - - private JgroupsClusterImpl subject; - - @Mock - private JgroupsClusterSettings settings; - @Mock - private Media candidate; - @Mock - private JGroupsMessageDispatcher dispatcher; - @Mock - private JGroupsTaskState taskState; - - @Before - public void setUp() throws Exception { - MockitoAnnotations.initMocks(this); - subject = new JgroupsClusterImpl(settings, dispatcher, taskState); - when(settings.getJgroupsConfigFile()).thenReturn("docker/default/config/tcp.xml"); - when(settings.getClusterName()).thenReturn("clustercode"); - when(settings.getBindingPort()).thenReturn(5000); - when(settings.isIPv4Preferred()).thenReturn(true); - when(settings.getBindingAddress()).thenReturn(""); - when(settings.getHostname()).thenReturn(""); - } - - @After - public void tearDown() throws Exception { - subject.leaveCluster(); - } - - @Test - public void setTask_ShouldAcceptTask() throws Exception { - Path source = createPath("0", "movie.mp4"); - when(candidate.getSourcePath()).thenReturn(source); - - when(taskState.isQueuedInCluster(candidate)).thenReturn(true); - subject.joinCluster(); - subject.setTask(candidate); - assertThat(subject.isQueuedInCluster(candidate)).isTrue(); - verify(taskState).setTask(candidate); - } - -} diff --git a/src/integration-test/java/net/chrigel/clustercode/process/impl/ExternalProcessServiceImplIT.java b/src/integration-test/java/net/chrigel/clustercode/process/impl/ExternalProcessServiceImplIT.java deleted file mode 100644 index fe2c79b8..00000000 --- a/src/integration-test/java/net/chrigel/clustercode/process/impl/ExternalProcessServiceImplIT.java +++ /dev/null @@ -1,129 +0,0 @@ -package net.chrigel.clustercode.process.impl; - -import net.chrigel.clustercode.process.ProcessConfiguration; -import net.chrigel.clustercode.test.CompletableUnitTest; -import net.chrigel.clustercode.test.TestUtility; -import net.chrigel.clustercode.util.Platform; -import org.junit.Before; -import org.junit.Test; - -import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -public class ExternalProcessServiceImplIT implements CompletableUnitTest { - - private ExternalProcessServiceImpl subject; - - @Before - public void setUp() { - subject = new ExternalProcessServiceImpl(); - } - - @Test(timeout = 2000) - public void start_ShouldCallScriptWithArguments() { - setExpectedCountForCompletion(3); - Path executable = getArgumentsScript(); - List expected = Arrays.asList("arg1", "arg2 with spaces"); - - ProcessConfiguration c = ProcessConfiguration - .builder() - .arguments(expected) - .executable(executable) - .stdoutObserver(observable -> - observable.subscribe(s -> { - if (s.contains(" ")) { - assertThat(s).isEqualTo("arg1 \"arg2 with spaces\""); - } else { - assertThat(s).isEqualTo("Arguments:"); - } - assertThat(Thread.currentThread().getName()).startsWith("RxCachedThreadScheduler"); - completeOne(); - })) - .build(); - - subject.start(c, runningExternalProcess -> {}) - .subscribe( - exitCode -> { - assertThat(exitCode).isEqualTo(0); - assertThat(Thread.currentThread().getName()).startsWith("RxCachedThreadScheduler"); - completeOne(); - }); - waitForCompletion(); - } - - @Test(timeout = 2000) - public void destroy_ShouldStopProcess() { - setExpectedCountForCompletion(4); - Path executable = getSleepScript(); - List expected = Collections.singletonList("5000"); - - ProcessConfiguration c = ProcessConfiguration - .builder() - .arguments(expected) - .executable(executable) - .stdoutObserver(observable -> - observable.subscribe(s -> { - assertThat(Thread.currentThread().getName()).startsWith("RxCachedThreadScheduler"); - completeOne(); - })) - .build(); - - subject - .start(c, process -> { - process.sleep(500) - .awaitDestruction(); - completeOne(); - }) - .subscribe(exitCode -> { - assertThat(exitCode).isEqualTo(1); - completeOne(); - }); - waitForCompletion(); - } - - - @Test(timeout = 2000) - public void start_ShouldThrowException_IfFileDoesNotExist() { - Path executable = Paths.get("inexistent.file"); - - ProcessConfiguration c = ProcessConfiguration - .builder() - .executable(executable) - .build(); - - subject - .start(c) - .subscribe( - exitCode -> fail("This should not get called."), - ex -> { - assertThat(ex).isInstanceOf(IOException.class); - completeOne(); - }); - waitForCompletion(); - } - - private Path getArgumentsScript() { - switch (Platform.currentPlatform()) { - case WINDOWS: - return TestUtility.getIntegrationTestResourcesDir().resolve("Echo Arguments.cmd").toAbsolutePath(); - default: - return TestUtility.getIntegrationTestResourcesDir().resolve("Echo Arguments.sh").toAbsolutePath(); - } - } - - private Path getSleepScript() { - switch (Platform.currentPlatform()) { - case WINDOWS: - return TestUtility.getIntegrationTestResourcesDir().resolve("Sleep.cmd").toAbsolutePath(); - default: - return TestUtility.getIntegrationTestResourcesDir().resolve("Sleep.sh").toAbsolutePath(); - } - } -} diff --git a/src/integration-test/resources/Echo Arguments.cmd b/src/integration-test/resources/Echo Arguments.cmd deleted file mode 100644 index 0e881733..00000000 --- a/src/integration-test/resources/Echo Arguments.cmd +++ /dev/null @@ -1,6 +0,0 @@ -@echo off -:: This script returns the arguments and exits with exit code 0 -echo Arguments: -echo %* - -exit 0 \ No newline at end of file diff --git a/src/integration-test/resources/Echo Arguments.sh b/src/integration-test/resources/Echo Arguments.sh deleted file mode 100644 index fdc86cc8..00000000 --- a/src/integration-test/resources/Echo Arguments.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -echo "Arguments:" -echo $@ - -exit 0 diff --git a/src/integration-test/resources/Sleep.cmd b/src/integration-test/resources/Sleep.cmd deleted file mode 100644 index 87d69d91..00000000 --- a/src/integration-test/resources/Sleep.cmd +++ /dev/null @@ -1,8 +0,0 @@ -@echo off -:: This script sleeps for the given time in millis -echo %time% -echo Sleeping for %1 milliseconds -ping 1.1.1.1 -n 1 -w %1 >NUL -echo %time% - -exit 0 \ No newline at end of file diff --git a/src/integration-test/resources/Sleep.sh b/src/integration-test/resources/Sleep.sh deleted file mode 100644 index ca42da57..00000000 --- a/src/integration-test/resources/Sleep.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -echo $(date) -echo "Sleeping for $1 milliseconds" -sleep $(($1 / 1000)) -echo $(date) - -exit 0 diff --git a/src/integration-test/resources/log4j2.xml b/src/integration-test/resources/log4j2.xml deleted file mode 100644 index a86b095a..00000000 --- a/src/integration-test/resources/log4j2.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/swagger/README.md b/src/swagger/README.md deleted file mode 100644 index 9f6e9cca..00000000 --- a/src/swagger/README.md +++ /dev/null @@ -1,3 +0,0 @@ -The template files in this folder follow Swagger spec 2.0. - -https://github.com/kongchen/api-doc-template diff --git a/src/swagger/markdown.hbs b/src/swagger/markdown.hbs deleted file mode 100644 index 546f673c..00000000 --- a/src/swagger/markdown.hbs +++ /dev/null @@ -1,108 +0,0 @@ -#{{#info}}{{title}} - - -## {{join schemes " | "}}://{{host}}{{basePath}} - - -{{description}} - -{{#contact}} -[**Contact the developer**](mailto:{{email}}) -{{/contact}} - -**Version** {{version}} - -[**Terms of Service**]({{termsOfService}}) - -{{#license}}[**{{name}}**]({{url}}){{/license}} - -{{/info}} - -{{#if consumes}}**Consumes:** {{join consumes ", "}}{{/if}} - -{{#if produces}}**Produces:** {{join produces ", "}}{{/if}} - -{{#if securityDefinitions}} -# Security Definitions -{{/if}} -{{> security}} - -# APIs - -{{#each paths}} -## {{@key}} -{{#this}} -{{#get}} -### GET -{{> operation}} -{{/get}} - -{{#put}} -### PUT -{{> operation}} -{{/put}} - -{{#post}} -### POST - -{{> operation}} - -{{/post}} - -{{#delete}} -### DELETE -{{> operation}} -{{/delete}} - -{{#option}} -### OPTION -{{> operation}} -{{/option}} - -{{#patch}} -### PATCH -{{> operation}} -{{/patch}} - -{{#head}} -### HEAD -{{> operation}} -{{/head}} - -{{/this}} -{{/each}} - -# Definitions -{{#each definitions}} -## {{@key}} - - - - - - - - - - {{#each this.properties}} - - - - - - - - {{/each}} -
    nametyperequireddescriptionexample
    {{@key}} - {{#ifeq type "array"}} - {{#items.$ref}} - {{type}}[{{basename items.$ref}}] - {{/items.$ref}} - {{^items.$ref}}{{type}}[{{items.type}}]{{/items.$ref}} - {{else}} - {{#$ref}}{{basename $ref}}{{/$ref}} - {{^$ref}}{{type}}{{#format}} ({{format}}){{/format}}{{/$ref}} - {{/ifeq}} - {{#required}}required{{/required}}{{^required}}optional{{/required}}{{#description}}{{{description}}}{{/description}}{{^description}}-{{/description}}{{example}}
    -{{/each}} - diff --git a/src/swagger/operation.hbs b/src/swagger/operation.hbs deleted file mode 100644 index 3d7d88e5..00000000 --- a/src/swagger/operation.hbs +++ /dev/null @@ -1,80 +0,0 @@ -{{#deprecated}}-deprecated-{{/deprecated}} -{{summary}} - -{{description}} - -{{#if externalDocs.url}}{{externalDocs.description}}. [See external documents for more details]({{externalDocs.url}}) -{{/if}} - -{{#if security}} -#### Security -{{/if}} - -{{#security}} -{{#each this}} -* {{@key}} -{{#this}} * {{this}} -{{/this}} -{{/each}} -{{/security}} - -#### Request - -{{#if consumes}} -**Content-Type: ** {{join consumes ", "}}{{/if}} - -##### Parameters -{{#if parameters}} - - - - - - - - - -{{/if}} - -{{#parameters}} - - - - - - -{{#ifeq in "body"}} - -{{else}} - {{#ifeq type "array"}} - - {{else}} - - {{/ifeq}} -{{/ifeq}} - -{{/parameters}} -{{#if parameters}} -
    NameLocated inRequiredDescriptionDefaultSchema
    {{name}}{{in}}{{#if required}}yes{{else}}no{{/if}} - {{description}}{{#if pattern}} (**Pattern**: `{{pattern}}`){{/if}} - {{#if enum}} -

    - Allowable values: {{#join enum ", "}} {{this}}{{/join}} -

    - {{/if}} -
    {{#if defaultValue}}{{defaultValue}}{{else}} - {{/if}} - {{#ifeq schema.type "array"}}Array[{{basename schema.items.$ref}}]{{/ifeq}} - {{#schema.$ref}}{{basename schema.$ref}} {{/schema.$ref}} - Array[{{items.type}}] ({{collectionFormat}}){{type}} {{#format}}({{format}}){{/format}}
    -{{/if}} - - -#### Response - -{{#if produces}}**Content-Type: ** {{join produces ", "}}{{/if}} - - -| Status Code | Reason | Response Model | -|-------------|-------------|----------------| -{{#each responses}}| {{@key}} | {{description}} | {{#schema.$ref}}{{basename schema.$ref}}{{/schema.$ref}}{{#ifeq schema.type "array"}}Array[{{basename schema.items.$ref}}]{{/ifeq}}{{^schema}} - {{/schema}}| -{{/each}} diff --git a/src/swagger/security.hbs b/src/swagger/security.hbs deleted file mode 100644 index 04f86e83..00000000 --- a/src/swagger/security.hbs +++ /dev/null @@ -1,88 +0,0 @@ -{{#each securityDefinitions}} -### {{@key}} -{{#this}} -{{#ifeq type "oauth2"}} - - - - - -{{#if description}} - - - - -{{/if}} -{{#if authorizationUrl}} - - - - -{{/if}} -{{#if flow}} - - - - -{{/if}} -{{#if tokenUrl}} - - - - -{{/if}} -{{#if scopes}} - - -{{#each scopes}} - - - - -{{/each}} - -{{/if}} -
    type{{type}}
    description{{description}}
    authorizationUrl{{authorizationUrl}}
    flow{{flow}}
    tokenUrl{{tokenUrl}}
    scopes{{@key}}{{this}}
    -{{/ifeq}} -{{#ifeq type "apiKey"}} - - - - - -{{#if description}} - - - - -{{/if}} -{{#if name}} - - - - -{{/if}} -{{#if in}} - - - - -{{/if}} -
    type{{type}}
    description{{description}}
    name{{name}}
    in{{in}}
    -{{/ifeq}} -{{#ifeq type "basic"}} - - - - - -{{#if description}} - - - - -{{/if}} -
    type{{type}}
    description{{description}}
    -{{/ifeq}} -{{/this}} -{{/each}} \ No newline at end of file diff --git a/src/swagger/strapdown.html.hbs b/src/swagger/strapdown.html.hbs deleted file mode 100644 index ec02669d..00000000 --- a/src/swagger/strapdown.html.hbs +++ /dev/null @@ -1,10 +0,0 @@ - - -API Document - - -{{>markdown}} - - - - \ No newline at end of file From 558573f7caa5deb6733b54f7ddaf690a91c9affa Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 20 Dec 2020 13:11:13 +0100 Subject: [PATCH 02/13] Start setting up Kubernetes Operator Lets use Kubernetes as database asdf --- .github/ISSUE_TEMPLATE/bug_report.md | 44 + .github/ISSUE_TEMPLATE/feature_request.md | 47 + .github/PULL_REQUEST_TEMPLATE.md | 15 + .github/workflows/build.yml | 73 ++ .github/workflows/lint.yml | 30 + .github/workflows/push.yml | 31 + .github/workflows/release.yml | 37 + .gitignore | 86 +- .goreleaser.yml | 41 + Dockerfile | 9 + Makefile | 175 +++ api/v1alpha1/clustercodeplan_types.go | 42 + api/v1alpha1/clustercodetask_types.go | 37 + api/v1alpha1/groupversion_info.go | 20 + api/v1alpha1/zz_generated.deepcopy.go | 203 ++++ cfg/config.go | 24 + config/boilerplate.go.txt | 0 ...lustercode.github.io_clustercodeplans.yaml | 998 ++++++++++++++++++ ...lustercode.github.io_clustercodetasks.yaml | 91 ++ config/crd/v1alpha1/kustomization.yaml | 36 + config/crd/v1alpha1/kustomizeconfig.yaml | 17 + config/default/kustomization.yaml | 19 + config/manager/kustomization.yaml | 2 + config/manager/manager.yaml | 32 + config/rbac/kustomization.yaml | 5 + config/rbac/leader_election_role.yaml | 33 + config/rbac/leader_election_role_binding.yaml | 12 + config/rbac/role.yaml | 28 + config/rbac/role_binding.yaml | 12 + controllers/clustercodeplan_controller.go | 44 + e2e/Makefile | 20 + e2e/kind-config.yaml | 6 + e2e/lib/custom.bash | 12 + e2e/lib/detik.bash | 286 +++++ e2e/lib/linter.bash | 244 +++++ e2e/lib/utils.bash | 75 ++ e2e/lint.bats | 11 + e2e/package.json | 6 + e2e/test1.bats | 22 + e2e/test1/deployment.yaml | 12 + e2e/test1/kustomization.yaml | 11 + generate.go | 11 + go.mod | 14 + go.sum | 934 ++++++++++++++++ main.go | 93 ++ 45 files changed, 3960 insertions(+), 40 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/push.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 api/v1alpha1/clustercodeplan_types.go create mode 100644 api/v1alpha1/clustercodetask_types.go create mode 100644 api/v1alpha1/groupversion_info.go create mode 100644 api/v1alpha1/zz_generated.deepcopy.go create mode 100644 cfg/config.go create mode 100644 config/boilerplate.go.txt create mode 100644 config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml create mode 100644 config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml create mode 100644 config/crd/v1alpha1/kustomization.yaml create mode 100644 config/crd/v1alpha1/kustomizeconfig.yaml create mode 100644 config/default/kustomization.yaml create mode 100644 config/manager/kustomization.yaml create mode 100644 config/manager/manager.yaml create mode 100644 config/rbac/kustomization.yaml create mode 100644 config/rbac/leader_election_role.yaml create mode 100644 config/rbac/leader_election_role_binding.yaml create mode 100644 config/rbac/role.yaml create mode 100644 config/rbac/role_binding.yaml create mode 100644 controllers/clustercodeplan_controller.go create mode 100644 e2e/Makefile create mode 100644 e2e/kind-config.yaml create mode 100644 e2e/lib/custom.bash create mode 100755 e2e/lib/detik.bash create mode 100755 e2e/lib/linter.bash create mode 100755 e2e/lib/utils.bash create mode 100644 e2e/lint.bats create mode 100644 e2e/package.json create mode 100644 e2e/test1.bats create mode 100644 e2e/test1/deployment.yaml create mode 100644 e2e/test1/kustomization.yaml create mode 100644 generate.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..50f3d7ad --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,44 @@ +--- +name: 🐛 Bug report +about: Create a report to help improve 🎉 +title: '[Bug] ' +labels: 'bug' + +--- + +## Describe the bug + +A clear and concise description of what the bug is. + +## Additional context + +Add any other context about the problem here. + +## Logs + +If applicable, add logs to help explain your problem. +```console + +``` + +## Expected behavior + +A clear and concise description of what you expected to happen. + +## To Reproduce + +Steps to reproduce the behavior: +1. Specs +```yaml + +``` +2. Commands +```bash + +``` + +## Environment (please complete the following information): + +- Image Version: e.g. v1.0 +- K8s Version: e.g. v1.18 +- K8s Distribution: e.g. OpenShift, Rancher, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..7efd27bf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,47 @@ +--- +name: 🚀 Feature request +about: Suggest an idea for this project 💡 +title: '[Feature] ' +labels: 'enhancement' + +--- + + +## Summary + +**As** "role name"\ +**I want** "a feature or functionality"\ +**So that** "business value(s)" + +## Context + +Add more information here. You are completely free regarding form and length + +## Out of Scope + +* List aspects that are explicitly not part of this feature + +## Further links + +* URLs of relevant Git repositories, PRs, Issues, etc. + +## Acceptance criteria + + + +*Given* "a precondition"\ +*When* "an action happens"\ +*Then* "a result is expected" + +## Implementation Ideas + +* If applicable, shortly list possible implementation ideas diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..ac5bb156 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Summary + + + +## Checklist + + +- [ ] Update the documentation. +- [ ] Update tests. +- [ ] Link this PR to related issues. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..9ef822fc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,73 @@ +name: Build + +on: + push: + branches: + - master + - development + tags-ignore: + - "*" + pull_request: + branches: + - master + - development + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run tests + run: make test + e2e-test: + runs-on: ubuntu-latest + strategy: + matrix: + include: + - kind-node-version: v1.18.8 + crd-spec-version: v1 + steps: + - uses: actions/checkout@v2 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run e2e tests + run: make crd install_bats setup_e2e_test e2e_test -e CRD_SPEC_VERSION=${{ matrix.crd-spec-version }} -e KIND_NODE_VERSION=${{ matrix.kind-node-version }} -e KIND_KUBECTL_ARGS=--validate=false + - name: Show e2e debug logs + run: cat e2e/debug/detik/* + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Build image + run: make docker-build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..717c1fc5 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,30 @@ +name: Lint + +on: + push: + branches: + - master + tags-ignore: + - "*" + pull_request: + branches: + - master + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run linters + run: make lint diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 00000000..762f0897 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,31 @@ +name: Push + +on: + push: + branches: + - master + +jobs: + dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Build docker images + run: make docker-build -e IMG_TAG=${GITHUB_REF#refs/heads/} + - name: Login to Docker hub + run: docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}" + - name: Login to quay.io + run: docker login -u "${{ secrets.QUAY_IO_USERNAME }}" -p "${{ secrets.QUAY_IO_PASSWORD }}" quay.io + - name: Push docker images + run: make docker-push -e IMG_TAG=${GITHUB_REF#refs/heads/} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..0cbbeb6a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + push: + tags: + - "*" + +jobs: + dist: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Determine Go version from go.mod + run: echo "GO_VERSION=$(grep "go 1." go.mod | cut -d " " -f 2)" >> $GITHUB_ENV + - uses: actions/setup-go@v2 + with: + go-version: ${{ env.GO_VERSION }} + - uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Login to Docker hub + run: docker login -u "${{ secrets.DOCKER_USERNAME }}" -p "${{ secrets.DOCKER_PASSWORD }}" + - name: Login to quay.io + run: docker login -u "${{ secrets.QUAY_IO_USERNAME }}" -p "${{ secrets.QUAY_IO_PASSWORD }}" quay.io + - name: Generate artifacts + run: make crd + - name: Publish releases + uses: goreleaser/goreleaser-action@v2 + with: + args: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 35356add..295dfb48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,46 +1,52 @@ -*.class - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* - -# maven -target/ - -# gradle -build/ -.gradle/ -out/ - -# Microsoft opened files. -~$$*.~* - - -# intellij -*.iws -.idea/dataSources.ids -.idea/dataSources.xml -.idea/sqlDataSources.xml -.idea/dynamic.xml -desktop.ini -.idea/*.iml - -.idea/* -!.idea/codeStyleSettings.xml -!.idea/codeStyles -!.idea/runConfigurations +# Created by .ignore support plugin (hsz.mobi) +### Example user template template +### Example user template + +# VSCode configs +.vscode/ + +# IntelliJ project files +.idea +*.iml +out +gen### Go template +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 +.glide/ + +# Antora related +.asciidoctor +.cache +_public/ +_archive/ + +*.DS_Store +cmd/*/debug +cmd/operator/operator +cmd/restic/restic + +bin/ +dist/ +clustercode-crd*.yaml +clustercode +testbin/ +node_modules/ +e2e/debug +__debug_bin # project -/config/ /tmp/ /input/ /output/ -/nginx -# NPM, Webpack -node_modules/ -dist/ -/src/frontend/test/unit/coverage/ -package-lock.json diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..e4654708 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,41 @@ +# This is an example goreleaser.yaml file with some sane defaults. +# Make sure to check the documentation at http://goreleaser.com + +builds: +- env: + - CGO_ENABLED=0 # this is needed otherwise the Docker image build is faulty + goarch: + - amd64 + goos: + - linux + +checksum: + name_template: 'checksums.txt' + +snapshot: + name_template: "{{ .Tag }}-snapshot" + +dockers: +- image_templates: + - "docker.io/ccremer/clustercode:latest" + - "docker.io/ccremer/clustercode:v{{ .Version }}" + - "docker.io/ccremer/clustercode:v{{ .Major }}" + - "quay.io/ccremer/clustercode:latest" + - "quay.io/ccremer/clustercode:v{{ .Version }}" + - "quay.io/ccremer/clustercode:v{{ .Major }}" + +changelog: + sort: asc + filters: + exclude: + - '^(D|d)oc(s|umentation):' + - '^(T|t)ests?:' + - '^(R|r)efactor:' + - '^Merge pull request' + +release: + github: + owner: ccremer + name: clustercode + extra_files: + - glob: ./clustercode-crd*.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8bd932b4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM docker.io/library/alpine:3.12 as runtime + +ENTRYPOINT ["clustercode"] + +RUN \ + apk add --no-cache curl bash tzdata + +COPY clustercode /usr/bin/ +USER 1001:0 diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..eaf327b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,175 @@ +# Current Operator version +VERSION ?= 0.0.1 +# Default bundle image tag +BUNDLE_IMG ?= controller-bundle:$(VERSION) +# Options for 'bundle-build' +ifneq ($(origin CHANNELS), undefined) +BUNDLE_CHANNELS := --channels=$(CHANNELS) +endif +ifneq ($(origin DEFAULT_CHANNEL), undefined) +BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) +endif +BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) + +IMG_TAG ?= latest + +BIN_FILENAME ?= clustercode + +CRD_SPEC_VERSION ?= v1 + +CRD_ROOT_DIR ?= config/crd/v1alpha1 +CRD_FILE ?= clustercode-crd.yaml + +TESTBIN_DIR ?= ./testbin/bin +KIND_BIN ?= $(TESTBIN_DIR)/kind +KIND_VERSION ?= 0.9.0 +KIND_KUBECONFIG ?= ./testbin/kind-kubeconfig +KIND_NODE_VERSION ?= v1.19.4 +KIND_CLUSTER ?= clustercode-$(KIND_NODE_VERSION) +KIND_KUBECTL_ARGS ?= --validate=true +KIND_REGISTRY_NAME ?= kind-registry +KIND_REGISTRY_PORT ?= 5000 + +SETUP_E2E_TEST := testbin/.setup_e2e_test + +ENABLE_LEADER_ELECTION ?= false + +# Image URL to use all building/pushing image targets +DOCKER_IMG ?= docker.io/ccremer/clustercode:$(IMG_TAG) +QUAY_IMG ?= quay.io/ccremer/clustercode:$(IMG_TAG) +E2E_IMG ?= localhost:$(KIND_REGISTRY_PORT)/ccremer/clustercode:e2e + +build_cmd ?= CGO_ENABLED=0 go build -o $(BIN_FILENAME) main.go + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +# Set Shell to bash, otherwise some targets fail with dash/zsh etc. +SHELL := /bin/bash + +KUSTOMIZE ?= go run sigs.k8s.io/kustomize/kustomize/v3 +KUSTOMIZE_BUILD_CRD ?= $(KUSTOMIZE) build $(CRD_ROOT_DIR) + +all: build ## Invokes the build target + +test: fmt vet ## Run tests + go test ./... -coverprofile cover.out + +# Run tests (see https://sdk.operatorframework.io/docs/building-operators/golang/references/envtest-setup) +ENVTEST_ASSETS_DIR=$(shell pwd)/testbin + +$(TESTBIN_DIR): + mkdir -p $(TESTBIN_DIR) + +# See https://storage.googleapis.com/kubebuilder-tools/ for list of supported K8s versions +# No, there's no 1.18 support, so we're going for 1.19 +integration_test: export ENVTEST_K8S_VERSION = 1.19.2 +integration_test: generate fmt vet $(TESTBIN_DIR) ## Run integration tests with envtest + test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/master/hack/setup-envtest.sh + source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test -tags=integration -v ./... -coverprofile cover.out + +build: generate fmt vet ## Build manager binary + $(build_cmd) + +dist: generate fmt vet ## Generates a release + goreleaser release --snapshot --rm-dist --skip-sign + +run: export BACKUP_ENABLE_LEADER_ELECTION = $(ENABLE_LEADER_ELECTION) +run: fmt vet ## Run against the configured Kubernetes cluster in ~/.kube/config + go run ./main.go + +install: generate ## Install CRDs into a cluster + $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1 | kubectl apply -f - + +uninstall: generate ## Uninstall CRDs from a cluster + $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1 | kubectl delete -f - + +deploy: generate ## Deploy controller in the configured Kubernetes cluster in ~/.kube/config + cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} + $(KUSTOMIZE) build config/default | kubectl apply -f - + +generate: ## Generate manifests e.g. CRD, RBAC etc. + @CRD_ROOT_DIR="$(CRD_ROOT_DIR)" go generate -tags=generate generate.go + @rm config/*.yaml + +crd: generate ## Generate CRD to file + $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1 > $(CRD_FILE) + $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1beta1 > $(CRD_FILE_LEGACY) + +fmt: ## Run go fmt against code + go fmt ./... + +vet: ## Run go vet against code + go vet ./... + +lint: fmt vet ## Invokes the fmt and vet targets + @echo 'Check for uncommitted changes ...' + git diff --exit-code + +# Build the binary without running generators +$(BIN_FILENAME): + $(build_cmd) + +docker-build: $(BIN_FILENAME) $(KIND_KUBECONFIG) ## Build the docker image + docker build . -t $(DOCKER_IMG) -t $(QUAY_IMG) -t $(E2E_IMG) + +docker-push: ## Push the docker image + docker push $(DOCKER_IMG) + docker push $(QUAY_IMG) + +install_bats: ## Installs the bats util via NPM + $(MAKE) -C e2e install_bats + +e2e_test: install_bats $(SETUP_E2E_TEST) docker-build ## Runs the e2e test suite + docker push $(E2E_IMG) + $(MAKE) -C e2e run_bats -e KUBECONFIG=../$(KIND_KUBECONFIG) + +run_kind: export KUBECONFIG = $(KIND_KUBECONFIG) +run_kind: export BACKUP_ENABLE_LEADER_ELECTION = $(ENABLE_LEADER_ELECTION) +run_kind: $(SETUP_E2E_TEST) ## Runs the operator in kind + go run ./main.go + +.PHONY: setup_e2e_test +setup_e2e_test: $(SETUP_E2E_TEST) ## Run the e2e setup + +.PHONY: clean_e2e_setup +clean_e2e_setup: export KUBECONFIG = $(KIND_KUBECONFIG) +clean_e2e_setup: ## Clean the e2e setup (e.g. to rerun the setup_e2e_test) + kubectl delete ns clustercode-system --ignore-not-found --force --grace-period=0 || true + @$(KUSTOMIZE_BUILD_CRD) | kubectl delete -f - || true + @rm $(SETUP_E2E_TEST) || true + +clean: export KUBECONFIG = $(KIND_KUBECONFIG) +clean: ## Cleans up the generated resources + $(KIND_BIN) delete cluster --name $(KIND_CLUSTER) || true + docker stop "$(KIND_REGISTRY_NAME)" || true + docker rm "$(KIND_REGISTRY_NAME)" || true + docker rmi "$(E2E_IMG)" || true + rm -r testbin/ dist/ bin/ cover.out $(BIN_FILENAME) || true + $(MAKE) -C e2e clean + +.PHONY: help +help: ## Show this help + @grep -E -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +$(KIND_BIN): export KUBECONFIG = $(KIND_KUBECONFIG) +$(KIND_BIN): $(TESTBIN_DIR) + curl -Lo $(KIND_BIN) "https://kind.sigs.k8s.io/dl/v$(KIND_VERSION)/kind-$$(uname)-amd64" + @chmod +x $(KIND_BIN) + docker run -d --restart=always -p "$(KIND_REGISTRY_PORT):5000" --name "$(KIND_REGISTRY_NAME)" docker.io/library/registry:2 + $(KIND_BIN) create cluster --name $(KIND_CLUSTER) --image kindest/node:$(KIND_NODE_VERSION) --config=e2e/kind-config.yaml + @docker network connect "kind" "$(KIND_REGISTRY_NAME)" || true + @kubectl version + @kubectl cluster-info + +$(KIND_KUBECONFIG): $(KIND_BIN) + +$(SETUP_E2E_TEST): export KUBECONFIG = $(KIND_KUBECONFIG) +$(SETUP_E2E_TEST): $(KIND_BIN) + @kubectl config use-context kind-$(KIND_CLUSTER) + @$(KUSTOMIZE_BUILD_CRD) | kubectl apply $(KIND_KUBECTL_ARGS) -f - + @touch $(SETUP_E2E_TEST) diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go new file mode 100644 index 00000000..1ab86e4c --- /dev/null +++ b/api/v1alpha1/clustercodeplan_types.go @@ -0,0 +1,42 @@ +package v1alpha1 + +import ( + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ( + // ClustercodePlanSpec defines the desired state of Archive. + ClustercodePlanSpec struct { + // +kubebuilder:default=1 + ScanIntervalSeconds int64 `json:"scanIntervalMinutes,omitempty"` + // +kubebuilder:validation:Required + SourceVolume corev1.Volume `json:"sourceVolume,omitempty"` + } + + // ClustercodePlan is the Schema for the archives API + // +kubebuilder:object:root=true + // +kubebuilder:subresource:status + ClustercodePlan struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ClustercodePlanSpec `json:"spec,omitempty"` + Status ClustercodePlanStatus `json:"status,omitempty"` + } + + // ClustercodePlanList contains a list of Archive + // +kubebuilder:object:root=true + ClustercodePlanList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClustercodePlan `json:"items"` + } + ClustercodePlanStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` + } +) + +func init() { + SchemeBuilder.Register(&ClustercodePlan{}, &ClustercodePlanList{}) +} diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go new file mode 100644 index 00000000..3d7576c3 --- /dev/null +++ b/api/v1alpha1/clustercodetask_types.go @@ -0,0 +1,37 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type ( + + // EncodingTaskSpec defines the desired state of Archive. + ClustercodeTaskSpec struct { + } + + // ClustercodePlan is the Schema for the archives API + // +kubebuilder:object:root=true + // +kubebuilder:subresource:status + ClustercodeTask struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ClustercodeTaskSpec `json:"spec,omitempty"` + Status ClustercodeTaskStatus `json:"status,omitempty"` + } + // ClustercodeTaskList contains a list of Archive + // +kubebuilder:object:root=true + ClustercodeTaskList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClustercodeTask `json:"items"` + } + ClustercodeTaskStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` + } +) + +func init() { + SchemeBuilder.Register(&ClustercodeTask{}, &ClustercodeTaskList{}) +} diff --git a/api/v1alpha1/groupversion_info.go b/api/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..642fdae7 --- /dev/null +++ b/api/v1alpha1/groupversion_info.go @@ -0,0 +1,20 @@ +// Package v1alpha1 contains API Schema definitions for the sync v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=clustercode.github.io +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "clustercode.github.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..ee3e1368 --- /dev/null +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,203 @@ +// +build !ignore_autogenerated + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodePlan) DeepCopyInto(out *ClustercodePlan) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlan. +func (in *ClustercodePlan) DeepCopy() *ClustercodePlan { + if in == nil { + return nil + } + out := new(ClustercodePlan) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClustercodePlan) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodePlanList) DeepCopyInto(out *ClustercodePlanList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClustercodePlan, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlanList. +func (in *ClustercodePlanList) DeepCopy() *ClustercodePlanList { + if in == nil { + return nil + } + out := new(ClustercodePlanList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClustercodePlanList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodePlanSpec) DeepCopyInto(out *ClustercodePlanSpec) { + *out = *in + in.SourceVolume.DeepCopyInto(&out.SourceVolume) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlanSpec. +func (in *ClustercodePlanSpec) DeepCopy() *ClustercodePlanSpec { + if in == nil { + return nil + } + out := new(ClustercodePlanSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodePlanStatus) DeepCopyInto(out *ClustercodePlanStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlanStatus. +func (in *ClustercodePlanStatus) DeepCopy() *ClustercodePlanStatus { + if in == nil { + return nil + } + out := new(ClustercodePlanStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeTask) DeepCopyInto(out *ClustercodeTask) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTask. +func (in *ClustercodeTask) DeepCopy() *ClustercodeTask { + if in == nil { + return nil + } + out := new(ClustercodeTask) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClustercodeTask) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeTaskList) DeepCopyInto(out *ClustercodeTaskList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClustercodeTask, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskList. +func (in *ClustercodeTaskList) DeepCopy() *ClustercodeTaskList { + if in == nil { + return nil + } + out := new(ClustercodeTaskList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClustercodeTaskList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeTaskSpec) DeepCopyInto(out *ClustercodeTaskSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskSpec. +func (in *ClustercodeTaskSpec) DeepCopy() *ClustercodeTaskSpec { + if in == nil { + return nil + } + out := new(ClustercodeTaskSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeTaskStatus) DeepCopyInto(out *ClustercodeTaskStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskStatus. +func (in *ClustercodeTaskStatus) DeepCopy() *ClustercodeTaskStatus { + if in == nil { + return nil + } + out := new(ClustercodeTaskStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cfg/config.go b/cfg/config.go new file mode 100644 index 00000000..7ffc52ac --- /dev/null +++ b/cfg/config.go @@ -0,0 +1,24 @@ +package cfg + +// Configuration holds a strongly-typed tree of the configuration +type Configuration struct { + MetricsBindAddress string `koanf:"metrics-bindaddress"` + + // Enabling this will ensure there is only one active controller manager. + EnableLeaderElection bool `koanf:"enable-leader-election"` +} + +var ( + Config = NewDefaultConfig() +) + +// NewDefaultConfig retrieves the config with sane defaults +func NewDefaultConfig() *Configuration { + return &Configuration{ + EnableLeaderElection: true, + } +} + +func (c Configuration) ValidateSyntax() error { + return nil +} diff --git a/config/boilerplate.go.txt b/config/boilerplate.go.txt new file mode 100644 index 00000000..e69de29b diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml new file mode 100644 index 00000000..15b3a3e7 --- /dev/null +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -0,0 +1,998 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.1 + creationTimestamp: null + name: clustercodeplans.clustercode.github.io +spec: + group: clustercode.github.io + names: + kind: ClustercodePlan + listKind: ClustercodePlanList + plural: clustercodeplans + singular: clustercodeplan + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: ClustercodePlan is the Schema for the archives API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: ClustercodePlanSpec defines the desired state of Archive. + properties: + scanIntervalMinutes: + default: 1 + format: int64 + type: integer + sourceVolume: + description: Volume represents a named volume in a pod that may be accessed by any container in the pod. + properties: + awsElasticBlockStore: + description: 'AWSElasticBlockStore represents an AWS Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + properties: + fsType: + description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore TODO: how do we prevent errors in the filesystem from compromising the machine' + type: string + partition: + description: 'The partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty).' + format: int32 + type: integer + readOnly: + description: 'Specify "true" to force and set the ReadOnly property in VolumeMounts to "true". If omitted, the default is "false". More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: boolean + volumeID: + description: 'Unique ID of the persistent disk resource in AWS (Amazon EBS volume). More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' + type: string + required: + - volumeID + type: object + azureDisk: + description: AzureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. + properties: + cachingMode: + description: 'Host Caching mode: None, Read Only, Read Write.' + type: string + diskName: + description: The Name of the data disk in the blob storage + type: string + diskURI: + description: The URI the data disk in the blob storage + type: string + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + kind: + description: 'Expected values Shared: multiple blob disks per storage account Dedicated: single blob disk per storage account Managed: azure managed data disk (only in managed availability set). defaults to shared' + type: string + readOnly: + description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + required: + - diskName + - diskURI + type: object + azureFile: + description: AzureFile represents an Azure File Service mount on the host and bind mount to the pod. + properties: + readOnly: + description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretName: + description: the name of secret that contains Azure Storage Account Name and Key + type: string + shareName: + description: Share Name + type: string + required: + - secretName + - shareName + type: object + cephfs: + description: CephFS represents a Ceph FS mount on the host that shares a pod's lifetime + properties: + monitors: + description: 'Required: Monitors is a collection of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + items: + type: string + type: array + path: + description: 'Optional: Used as the mounted root, rather than the full Ceph tree, default is /' + type: string + readOnly: + description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: boolean + secretFile: + description: 'Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + secretRef: + description: 'Optional: SecretRef is reference to the authentication secret for User, default is empty. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + user: + description: 'Optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' + type: string + required: + - monitors + type: object + cinder: + description: 'Cinder represents a cinder volume attached and mounted on kubelets host machine. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + properties: + fsType: + description: 'Filesystem type to mount. Must be a filesystem type supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: string + readOnly: + description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: boolean + secretRef: + description: 'Optional: points to a secret object containing parameters used to connect to OpenStack.' + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + volumeID: + description: 'volume id used to identify the volume in cinder. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' + type: string + required: + - volumeID + type: object + configMap: + description: ConfigMap represents a configMap that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: If unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: The key to project. + type: string + mode: + description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: Specify whether the ConfigMap or its keys must be defined + type: boolean + type: object + csi: + description: CSI (Container Storage Interface) represents ephemeral storage that is handled by certain external CSI drivers (Beta feature). + properties: + driver: + description: Driver is the name of the CSI driver that handles this volume. Consult with your admin for the correct name as registered in the cluster. + type: string + fsType: + description: Filesystem type to mount. Ex. "ext4", "xfs", "ntfs". If not provided, the empty value is passed to the associated CSI driver which will determine the default filesystem to apply. + type: string + nodePublishSecretRef: + description: NodePublishSecretRef is a reference to the secret object containing sensitive information to pass to the CSI driver to complete the CSI NodePublishVolume and NodeUnpublishVolume calls. This field is optional, and may be empty if no secret is required. If the secret object contains more than one secret, all secret references are passed. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + readOnly: + description: Specifies a read-only configuration for the volume. Defaults to false (read/write). + type: boolean + volumeAttributes: + additionalProperties: + type: string + description: VolumeAttributes stores driver-specific properties that are passed to the CSI driver. Consult your driver's documentation for supported values. + type: object + required: + - driver + type: object + downwardAPI: + description: DownwardAPI represents downward API about the pod that should populate this volume + properties: + defaultMode: + description: 'Optional: mode bits to use on created files by default. Must be a Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: Items is a list of downward API volume file + items: + description: DownwardAPIVolumeFile represents information to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the pod: only annotations, labels, name and namespace are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + mode: + description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + emptyDir: + description: 'EmptyDir represents a temporary directory that shares a pod''s lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + properties: + medium: + description: 'What type of storage medium should back this directory. The default is "" which means to use the node''s default medium. Must be an empty string (default) or Memory. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' + type: string + sizeLimit: + anyOf: + - type: integer + - type: string + description: 'Total amount of local storage required for this EmptyDir volume. The size limit is also applicable for memory medium. The maximum usage on memory medium EmptyDir would be the minimum value between the SizeLimit specified here and the sum of memory limits of all containers in a pod. The default is nil which means that the limit is undefined. More info: http://kubernetes.io/docs/user-guide/volumes#emptydir' + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + type: object + ephemeral: + description: "Ephemeral represents a volume that is handled by a cluster storage driver (Alpha feature). The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, and deleted when the pod is removed. \n Use this if: a) the volume is only needed while the pod runs, b) features of normal volumes like restoring from snapshot or capacity tracking are needed, c) the storage driver is specified through a storage class, and d) the storage driver supports dynamic volume provisioning through a PersistentVolumeClaim (see EphemeralVolumeSource for more information on the connection between this volume type and PersistentVolumeClaim). \n Use PersistentVolumeClaim or one of the vendor-specific APIs for volumes that persist for longer than the lifecycle of an individual pod. \n Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to be used that way - see the documentation of the driver for more information. \n A pod can use both types of ephemeral volumes and persistent volumes at the same time." + properties: + readOnly: + description: Specifies a read-only configuration for the volume. Defaults to false (read/write). + type: boolean + volumeClaimTemplate: + description: "Will be used to create a stand-alone PVC to provision the volume. The pod in which this EphemeralVolumeSource is embedded will be the owner of the PVC, i.e. the PVC will be deleted together with the pod. The name of the PVC will be `-` where `` is the name from the `PodSpec.Volumes` array entry. Pod validation will reject the pod if the concatenated name is not valid for a PVC (for example, too long). \n An existing PVC with that name that is not owned by the pod will *not* be used for the pod to avoid using an unrelated volume by mistake. Starting the pod is then blocked until the unrelated PVC is removed. If such a pre-created PVC is meant to be used by the pod, the PVC has to updated with an owner reference to the pod once the pod exists. Normally this should not be necessary, but it may be useful when manually reconstructing a broken cluster. \n This field is read-only and no changes will be made by Kubernetes to the PVC after it has been created. \n Required, must not be nil." + properties: + metadata: + description: May contain labels and annotations that will be copied into the PVC when creating it. No other fields are allowed and will be rejected during validation. + type: object + spec: + description: The specification for the PersistentVolumeClaim. The entire content is copied unchanged into the PVC that gets created from this template. The same fields as in a PersistentVolumeClaim are also valid here. + properties: + accessModes: + description: 'AccessModes contains the desired access modes the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' + items: + type: string + type: array + dataSource: + description: 'This field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot - Beta) * An existing PVC (PersistentVolumeClaim) * An existing custom resource/object that implements data population (Alpha) In order to use VolumeSnapshot object types, the appropriate feature gate must be enabled (VolumeSnapshotDataSource or AnyVolumeDataSource) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. If the specified data source is not supported, the volume will not be created and the failure will be reported as an event. In the future, we plan to support more data source types and the behavior of the provisioner may change.' + properties: + apiGroup: + description: APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + resources: + description: 'Resources represents the minimum resources the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/' + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. More info: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/' + type: object + type: object + selector: + description: A label query over volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector requirements. The requirements are ANDed. + items: + description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. + properties: + key: + description: key is the label key that the selector applies to. + type: string + operator: + description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. + items: + type: string + type: array + required: + - key + - operator + type: object + type: array + matchLabels: + additionalProperties: + type: string + description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + storageClassName: + description: 'Name of the StorageClass required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' + type: string + volumeMode: + description: volumeMode defines what type of volume is required by the claim. Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: VolumeName is the binding reference to the PersistentVolume backing this claim. + type: string + type: object + required: + - spec + type: object + type: object + fc: + description: FC represents a Fibre Channel resource that is attached to a kubelet's host machine and then exposed to the pod. + properties: + fsType: + description: 'Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. TODO: how do we prevent errors in the filesystem from compromising the machine' + type: string + lun: + description: 'Optional: FC target lun number' + format: int32 + type: integer + readOnly: + description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' + type: boolean + targetWWNs: + description: 'Optional: FC target worldwide names (WWNs)' + items: + type: string + type: array + wwids: + description: 'Optional: FC volume world wide identifiers (wwids) Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously.' + items: + type: string + type: array + type: object + flexVolume: + description: FlexVolume represents a generic volume resource that is provisioned/attached using an exec based plugin. + properties: + driver: + description: Driver is the name of the driver to use for this volume. + type: string + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. + type: string + options: + additionalProperties: + type: string + description: 'Optional: Extra command options if any.' + type: object + readOnly: + description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' + type: boolean + secretRef: + description: 'Optional: SecretRef is reference to the secret object containing sensitive information to pass to the plugin scripts. This may be empty if no secret object is specified. If the secret object contains more than one secret, all secrets are passed to the plugin scripts.' + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + required: + - driver + type: object + flocker: + description: Flocker represents a Flocker volume attached to a kubelet's host machine. This depends on the Flocker control service being running + properties: + datasetName: + description: Name of the dataset stored as metadata -> name on the dataset for Flocker should be considered as deprecated + type: string + datasetUUID: + description: UUID of the dataset. This is unique identifier of a Flocker dataset + type: string + type: object + gcePersistentDisk: + description: 'GCEPersistentDisk represents a GCE Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + properties: + fsType: + description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk TODO: how do we prevent errors in the filesystem from compromising the machine' + type: string + partition: + description: 'The partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + format: int32 + type: integer + pdName: + description: 'Unique name of the PD resource in GCE. Used to identify the disk in GCE. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: string + readOnly: + description: 'ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' + type: boolean + required: + - pdName + type: object + gitRepo: + description: 'GitRepo represents a git repository at a particular revision. DEPRECATED: GitRepo is deprecated. To provision a container with a git repo, mount an EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir into the Pod''s container.' + properties: + directory: + description: Target directory name. Must not contain or start with '..'. If '.' is supplied, the volume directory will be the git repository. Otherwise, if specified, the volume will contain the git repository in the subdirectory with the given name. + type: string + repository: + description: Repository URL + type: string + revision: + description: Commit hash for the specified revision. + type: string + required: + - repository + type: object + glusterfs: + description: 'Glusterfs represents a Glusterfs mount on the host that shares a pod''s lifetime. More info: https://examples.k8s.io/volumes/glusterfs/README.md' + properties: + endpoints: + description: 'EndpointsName is the endpoint name that details Glusterfs topology. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' + type: string + path: + description: 'Path is the Glusterfs volume path. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' + type: string + readOnly: + description: 'ReadOnly here will force the Glusterfs volume to be mounted with read-only permissions. Defaults to false. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' + type: boolean + required: + - endpoints + - path + type: object + hostPath: + description: 'HostPath represents a pre-existing file or directory on the host machine that is directly exposed to the container. This is generally used for system agents or other privileged things that are allowed to see the host machine. Most containers will NOT need this. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath --- TODO(jonesdl) We need to restrict who can use host directory mounts and who can/can not mount host directories as read/write.' + properties: + path: + description: 'Path of the directory on the host. If the path is a symlink, it will follow the link to the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + type: string + type: + description: 'Type for HostPath Volume Defaults to "" More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' + type: string + required: + - path + type: object + iscsi: + description: 'ISCSI represents an ISCSI Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://examples.k8s.io/volumes/iscsi/README.md' + properties: + chapAuthDiscovery: + description: whether support iSCSI Discovery CHAP authentication + type: boolean + chapAuthSession: + description: whether support iSCSI Session CHAP authentication + type: boolean + fsType: + description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi TODO: how do we prevent errors in the filesystem from compromising the machine' + type: string + initiatorName: + description: Custom iSCSI Initiator Name. If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface : will be created for the connection. + type: string + iqn: + description: Target iSCSI Qualified Name. + type: string + iscsiInterface: + description: iSCSI Interface Name that uses an iSCSI transport. Defaults to 'default' (tcp). + type: string + lun: + description: iSCSI Target Lun number. + format: int32 + type: integer + portals: + description: iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). + items: + type: string + type: array + readOnly: + description: ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. + type: boolean + secretRef: + description: CHAP Secret for iSCSI target and initiator authentication + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + targetPortal: + description: iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). + type: string + required: + - iqn + - lun + - targetPortal + type: object + name: + description: 'Volume''s name. Must be a DNS_LABEL and unique within the pod. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' + type: string + nfs: + description: 'NFS represents an NFS mount on the host that shares a pod''s lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + properties: + path: + description: 'Path that is exported by the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + readOnly: + description: 'ReadOnly here will force the NFS export to be mounted with read-only permissions. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: boolean + server: + description: 'Server is the hostname or IP address of the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' + type: string + required: + - path + - server + type: object + persistentVolumeClaim: + description: 'PersistentVolumeClaimVolumeSource represents a reference to a PersistentVolumeClaim in the same namespace. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + properties: + claimName: + description: 'ClaimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' + type: string + readOnly: + description: Will force the ReadOnly setting in VolumeMounts. Default false. + type: boolean + required: + - claimName + type: object + photonPersistentDisk: + description: PhotonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine + properties: + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + pdID: + description: ID that identifies Photon Controller persistent disk + type: string + required: + - pdID + type: object + portworxVolume: + description: PortworxVolume represents a portworx volume attached and mounted on kubelets host machine + properties: + fsType: + description: FSType represents the filesystem type to mount Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + volumeID: + description: VolumeID uniquely identifies a Portworx volume + type: string + required: + - volumeID + type: object + projected: + description: Items for all in one resources secrets, configmaps, and downward API + properties: + defaultMode: + description: Mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + sources: + description: list of volume projections + items: + description: Projection that may be projected along with other supported volume types + properties: + configMap: + description: information about the configMap data to project + properties: + items: + description: If unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: The key to project. + type: string + mode: + description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: Specify whether the ConfigMap or its keys must be defined + type: boolean + type: object + downwardAPI: + description: information about the downwardAPI data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the pod: only annotations, labels, name and namespace are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified API version. + type: string + required: + - fieldPath + type: object + mode: + description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' + properties: + containerName: + description: 'Container name: required for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + required: + - path + type: object + type: array + type: object + secret: + description: information about the secret data to project + properties: + items: + description: If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: The key to project. + type: string + mode: + description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + optional: + description: Specify whether the Secret or its key must be defined + type: boolean + type: object + serviceAccountToken: + description: information about the serviceAccountToken data to project + properties: + audience: + description: Audience is the intended audience of the token. A recipient of a token must identify itself with an identifier specified in the audience of the token, and otherwise should reject the token. The audience defaults to the identifier of the apiserver. + type: string + expirationSeconds: + description: ExpirationSeconds is the requested duration of validity of the service account token. As the token approaches expiration, the kubelet volume plugin will proactively rotate the service account token. The kubelet will start trying to rotate the token if the token is older than 80 percent of its time to live or if the token is older than 24 hours.Defaults to 1 hour and must be at least 10 minutes. + format: int64 + type: integer + path: + description: Path is the path relative to the mount point of the file to project the token into. + type: string + required: + - path + type: object + type: object + type: array + required: + - sources + type: object + quobyte: + description: Quobyte represents a Quobyte mount on the host that shares a pod's lifetime + properties: + group: + description: Group to map volume access to Default is no group + type: string + readOnly: + description: ReadOnly here will force the Quobyte volume to be mounted with read-only permissions. Defaults to false. + type: boolean + registry: + description: Registry represents a single or multiple Quobyte Registry services specified as a string as host:port pair (multiple entries are separated with commas) which acts as the central registry for volumes + type: string + tenant: + description: Tenant owning the given Quobyte volume in the Backend Used with dynamically provisioned Quobyte volumes, value is set by the plugin + type: string + user: + description: User to map volume access to Defaults to serivceaccount user + type: string + volume: + description: Volume is a string that references an already created Quobyte volume by name. + type: string + required: + - registry + - volume + type: object + rbd: + description: 'RBD represents a Rados Block Device mount on the host that shares a pod''s lifetime. More info: https://examples.k8s.io/volumes/rbd/README.md' + properties: + fsType: + description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd TODO: how do we prevent errors in the filesystem from compromising the machine' + type: string + image: + description: 'The rados image name. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + keyring: + description: 'Keyring is the path to key ring for RBDUser. Default is /etc/ceph/keyring. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + monitors: + description: 'A collection of Ceph monitors. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + items: + type: string + type: array + pool: + description: 'The rados pool name. Default is rbd. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + readOnly: + description: 'ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: boolean + secretRef: + description: 'SecretRef is name of the authentication secret for RBDUser. If provided overrides keyring. Default is nil. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + user: + description: 'The rados user name. Default is admin. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' + type: string + required: + - image + - monitors + type: object + scaleIO: + description: ScaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. + properties: + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Default is "xfs". + type: string + gateway: + description: The host address of the ScaleIO API Gateway. + type: string + protectionDomain: + description: The name of the ScaleIO Protection Domain for the configured storage. + type: string + readOnly: + description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: SecretRef references to the secret for ScaleIO user and other sensitive information. If this is not provided, Login operation will fail. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + sslEnabled: + description: Flag to enable/disable SSL communication with Gateway, default false + type: boolean + storageMode: + description: Indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. Default is ThinProvisioned. + type: string + storagePool: + description: The ScaleIO Storage Pool associated with the protection domain. + type: string + system: + description: The name of the storage system as configured in ScaleIO. + type: string + volumeName: + description: The name of a volume already created in the ScaleIO system that is associated with this volume source. + type: string + required: + - gateway + - secretRef + - system + type: object + secret: + description: 'Secret represents a secret that should populate this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + properties: + defaultMode: + description: 'Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + items: + description: If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: The key to project. + type: string + mode: + description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' + format: int32 + type: integer + path: + description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + optional: + description: Specify whether the Secret or its keys must be defined + type: boolean + secretName: + description: 'Name of the secret in the pod''s namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' + type: string + type: object + storageos: + description: StorageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. + properties: + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + readOnly: + description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. + type: boolean + secretRef: + description: SecretRef specifies the secret to use for obtaining the StorageOS API credentials. If not specified, default values will be attempted. + properties: + name: + description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' + type: string + type: object + volumeName: + description: VolumeName is the human-readable name of the StorageOS volume. Volume names are only unique within a namespace. + type: string + volumeNamespace: + description: VolumeNamespace specifies the scope of the volume within StorageOS. If no namespace is specified then the Pod's namespace will be used. This allows the Kubernetes name scoping to be mirrored within StorageOS for tighter integration. Set VolumeName to any name to override the default behaviour. Set to "default" if you are not using namespaces within StorageOS. Namespaces that do not pre-exist within StorageOS will be created. + type: string + type: object + vsphereVolume: + description: VsphereVolume represents a vSphere volume attached and mounted on kubelets host machine + properties: + fsType: + description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. + type: string + storagePolicyID: + description: Storage Policy Based Management (SPBM) profile ID associated with the StoragePolicyName. + type: string + storagePolicyName: + description: Storage Policy Based Management (SPBM) profile name. + type: string + volumePath: + description: Path that identifies vSphere volume vmdk + type: string + required: + - volumePath + type: object + required: + - name + type: object + type: object + status: + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml new file mode 100644 index 00000000..e59a9817 --- /dev/null +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -0,0 +1,91 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.4.1 + creationTimestamp: null + name: clustercodetasks.clustercode.github.io +spec: + group: clustercode.github.io + names: + kind: ClustercodeTask + listKind: ClustercodeTaskList + plural: clustercodetasks + singular: clustercodetask + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: ClustercodePlan is the Schema for the archives API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: EncodingTaskSpec defines the desired state of Archive. + type: object + status: + properties: + conditions: + items: + description: "Condition contains details for one aspect of the current state of this API Resource. --- This struct is intended for direct use as an array at the field path .status.conditions. For example, type FooStatus struct{ // Represents the observations of a foo's current state. // Known .status.conditions.type are: \"Available\", \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition transitioned from one status to another. This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation that the condition was set based upon. For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating the reason for the condition's last transition. Producers of specific condition types may define expected values and meanings for this field, and whether the values are considered a guaranteed API. The value should be a CamelCase string. This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. --- Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be useful (see .node.status.conditions), the ability to deconflict is important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/config/crd/v1alpha1/kustomization.yaml b/config/crd/v1alpha1/kustomization.yaml new file mode 100644 index 00000000..76b41acd --- /dev/null +++ b/config/crd/v1alpha1/kustomization.yaml @@ -0,0 +1,36 @@ +# This kustomization.yaml is not intended to be run by itself, +# since it depends on service name and namespace that are out of this kustomize package. +# It should be run by config/default +resources: +- clustercode.github.io_clustercodeplans.yaml +- clustercode.github.io_clustercodetasks.yaml +# +kubebuilder:scaffold:crdkustomizeresource + +patchesStrategicMerge: +# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. +# patches here are for enabling the conversion webhook for each CRD +#- patches/webhook_in_schedules.yaml +#- patches/webhook_in_backups.yaml +#- patches/webhook_in_restores.yaml +#- patches/webhook_in_archives.yaml +#- patches/webhook_in_checks.yaml +#- patches/webhook_in_prunes.yaml +#- patches/webhook_in_snapshots.yaml +#- patches/webhook_in_prebackuppods.yaml +# +kubebuilder:scaffold:crdkustomizewebhookpatch + +# [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix. +# patches here are for enabling the CA injection for each CRD +#- patches/cainjection_in_schedules.yaml +#- patches/cainjection_in_backups.yaml +#- patches/cainjection_in_restores.yaml +#- patches/cainjection_in_archives.yaml +#- patches/cainjection_in_checks.yaml +#- patches/cainjection_in_prunes.yaml +#- patches/cainjection_in_snapshots.yaml +#- patches/cainjection_in_prebackuppods.yaml +# +kubebuilder:scaffold:crdkustomizecainjectionpatch + +# the following config is for teaching kustomize how to do kustomization for CRDs. +configurations: +- kustomizeconfig.yaml diff --git a/config/crd/v1alpha1/kustomizeconfig.yaml b/config/crd/v1alpha1/kustomizeconfig.yaml new file mode 100644 index 00000000..6f83d9a9 --- /dev/null +++ b/config/crd/v1alpha1/kustomizeconfig.yaml @@ -0,0 +1,17 @@ +# This file is for teaching kustomize how to substitute name and namespace reference in CRD +nameReference: +- kind: Service + version: v1 + fieldSpecs: + - kind: CustomResourceDefinition + group: apiextensions.k8s.io + path: spec/conversion/webhookClientConfig/service/name + +namespace: +- kind: CustomResourceDefinition + group: apiextensions.k8s.io + path: spec/conversion/webhookClientConfig/service/namespace + create: false + +varReference: +- path: metadata/annotations diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml new file mode 100644 index 00000000..3f10d0a1 --- /dev/null +++ b/config/default/kustomization.yaml @@ -0,0 +1,19 @@ +# Adds namespace to all resources. +namespace: clustercode-system + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: clustercode- + +# Labels to add to all resources and selectors. +commonLabels: + app.kubernetes.io/name: clustercode + app.kubernetes.io/managed-by: kustomize + +resources: +- ../crd/v1alpha1 +- ../rbac +- ../manager diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml new file mode 100644 index 00000000..5c5f0b84 --- /dev/null +++ b/config/manager/kustomization.yaml @@ -0,0 +1,2 @@ +resources: +- manager.yaml diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml new file mode 100644 index 00000000..4d448437 --- /dev/null +++ b/config/manager/manager.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: Namespace +metadata: + labels: + control-plane: controller-manager + name: system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: operator + namespace: system +spec: + selector: + matchLabels: + control-plane: controller-manager + replicas: 1 + template: + metadata: + labels: + control-plane: controller-manager + spec: + containers: + - name: clustercode + image: quay.io/ccremer/clustercode:latest + resources: + limits: + cpu: 300m + memory: 100Mi + requests: + cpu: 100m + memory: 20Mi diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml new file mode 100644 index 00000000..c887f9f6 --- /dev/null +++ b/config/rbac/kustomization.yaml @@ -0,0 +1,5 @@ +resources: +- role.yaml +- role_binding.yaml +- leader_election_role.yaml +- leader_election_role_binding.yaml diff --git a/config/rbac/leader_election_role.yaml b/config/rbac/leader_election_role.yaml new file mode 100644 index 00000000..7dc16c42 --- /dev/null +++ b/config/rbac/leader_election_role.yaml @@ -0,0 +1,33 @@ +# permissions to do leader election. +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: leader-election-role +rules: +- apiGroups: + - "" + resources: + - configmaps + verbs: + - get + - list + - watch + - create + - update + - patch + - delete +- apiGroups: + - "" + resources: + - configmaps/status + verbs: + - get + - update + - patch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch diff --git a/config/rbac/leader_election_role_binding.yaml b/config/rbac/leader_election_role_binding.yaml new file mode 100644 index 00000000..eed16906 --- /dev/null +++ b/config/rbac/leader_election_role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: leader-election-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: leader-election-role +subjects: +- kind: ServiceAccount + name: default + namespace: system diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml new file mode 100644 index 00000000..12751da0 --- /dev/null +++ b/config/rbac/role.yaml @@ -0,0 +1,28 @@ + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: manager-role +rules: +- apiGroups: + - clustercode.github.io + resources: + - encodingplans + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - clustercode.github.io + resources: + - encodingplans/status + verbs: + - get + - patch + - update diff --git a/config/rbac/role_binding.yaml b/config/rbac/role_binding.yaml new file mode 100644 index 00000000..8f265870 --- /dev/null +++ b/config/rbac/role_binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: +- kind: ServiceAccount + name: default + namespace: system diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go new file mode 100644 index 00000000..d7d85797 --- /dev/null +++ b/controllers/clustercodeplan_controller.go @@ -0,0 +1,44 @@ +package controllers + +import ( + "context" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/ccremer/clustercode/api/v1alpha1" +) + +type ( + // ClustercodePlanReconciler reconciles ClustercodePlan objects + ClustercodePlanReconciler struct { + Client client.Client + Log logr.Logger + Scheme *runtime.Scheme + } + // ReconciliationContext holds the parameters of a single reconciliation + ReconciliationContext struct { + ctx context.Context + plan *v1alpha1.ClustercodePlan + } +) + +func (r *ClustercodePlanReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.ClustercodePlan{}). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(r) +} + +// +kubebuilder:rbac:groups=clustercode.github.io,resources=encodingplans,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=clustercode.github.io,resources=encodingplans/status,verbs=get;update;patch +func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, returnErr error) { + rc := ReconciliationContext{ + ctx: ctx, + } + + return ctrl.Result{}, nil +} diff --git a/e2e/Makefile b/e2e/Makefile new file mode 100644 index 00000000..85bfe10e --- /dev/null +++ b/e2e/Makefile @@ -0,0 +1,20 @@ + +# Set Shell to bash, otherwise some targets fail with dash/zsh etc. +SHELL := /bin/bash + +.PHONY: install_bats +install_bats: node_modules/.bin/bats ## Installs the bats util via NPM + +.PHONY: run_bats +run_bats: export KUBECONFIG = $(KIND_KUBECONFIG) +run_bats: + @mkdir -p debug || true + @node_modules/.bin/bats . + +clean: + rm -r debug node_modules || true + +node_modules/.bin/bats: node_modules + +node_modules: + @npm install diff --git a/e2e/kind-config.yaml b/e2e/kind-config.yaml new file mode 100644 index 00000000..3351464b --- /dev/null +++ b/e2e/kind-config.yaml @@ -0,0 +1,6 @@ +apiVersion: kind.x-k8s.io/v1alpha4 +kind: Cluster +containerdConfigPatches: +- |- + [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5000"] + endpoint = ["http://kind-registry:5000"] diff --git a/e2e/lib/custom.bash b/e2e/lib/custom.bash new file mode 100644 index 00000000..9fd8a00b --- /dev/null +++ b/e2e/lib/custom.bash @@ -0,0 +1,12 @@ +#!/bin/bash + +setup() { + debug "-- $BATS_TEST_DESCRIPTION" + debug "-- $(date)" + debug "" + debug "" +} + +teardown() { + cp -r /tmp/detik debug || true +} diff --git a/e2e/lib/detik.bash b/e2e/lib/detik.bash new file mode 100755 index 00000000..6e6b305a --- /dev/null +++ b/e2e/lib/detik.bash @@ -0,0 +1,286 @@ +#!/bin/bash + +directory=$(dirname "${BASH_SOURCE[0]}") +source "$directory/utils.bash" + + +# Retrieves values and attempts to compare values to an expected result (with retries). +# @param {string} A text query that respect the appropriate syntax +# @return +# 1 Empty query +# 2 Invalid syntax +# 3 The assertion could not be verified after all the attempts +# (may also indicate an error with the K8s client) +# 0 Everything is fine +try() { + + # Concatenate all the arguments into a single string + IFS=' ' + exp="$*" + + # Trim the expression + exp=$(trim "$exp") + + # Make the regular expression case-insensitive + shopt -s nocasematch; + + # Verify the expression and use it to build a request + if [[ "$exp" == "" ]]; then + echo "An empty expression was not expected." + return 1 + fi + + # Let's verify the syntax + times="" + delay="" + resource="" + name="" + property="" + expected_value="" + expected_count="" + + if [[ "$exp" =~ $try_regex_verify ]]; then + + # Extract parameters + times="${BASH_REMATCH[1]}" + delay="${BASH_REMATCH[2]}" + resource=$(to_lower_case "${BASH_REMATCH[3]}") + name="${BASH_REMATCH[4]}" + property="${BASH_REMATCH[5]}" + expected_value=$(to_lower_case "${BASH_REMATCH[6]}") + + elif [[ "$exp" =~ $try_regex_find ]]; then + + # Extract parameters + times="${BASH_REMATCH[1]}" + delay="${BASH_REMATCH[2]}" + expected_count="${BASH_REMATCH[3]}" + resource=$(to_lower_case "${BASH_REMATCH[4]}") + name="${BASH_REMATCH[5]}" + property="${BASH_REMATCH[6]}" + expected_value=$(to_lower_case "${BASH_REMATCH[7]}") + fi + + # Do we have something? + if [[ "$times" != "" ]]; then + + # Prevent line breaks from being removed in command results + IFS="" + + # Start the loop + echo "Valid expression. Verification in progress..." + code=0 + for ((i=1; i<=$times; i++)); do + + # Verify the value + verify_value $property $expected_value $resource $name "$expected_count" && code=$? || code=$? + + # Break the loop prematurely? + if [[ "$code" == "0" ]]; then + break + elif [[ "$code" == "101" ]]; then + sleep $delay + elif [[ "$i" != "1" ]]; then + code=3 + sleep $delay + else + code=3 + fi + done + + ## Error code + return $code + fi + + # Default behavior + echo "Invalid expression: it does not respect the expected syntax." + return 2 +} + + +# Retrieves values and attempts to compare values to an expected result (without any retry). +# @param {string} A text query that respect one of the supported syntaxes +# @return +# 1 Empty query +# 2 Invalid syntax +# 3 The elements count is incorrect +# (may also indicate an error with the K8s client) +# 0 Everything is fine +verify() { + + # Concatenate all the arguments into a single string + IFS=' ' + exp="$*" + + # Trim the expression + exp=$(trim "$exp") + + # Make the regular expression case-insensitive + shopt -s nocasematch; + + # Verify the expression and use it to build a request + if [[ "$exp" == "" ]]; then + echo "An empty expression was not expected." + return 1 + + elif [[ "$exp" =~ $verify_regex_count_is ]] || [[ "$exp" =~ $verify_regex_count_are ]]; then + card="${BASH_REMATCH[1]}" + resource=$(to_lower_case "${BASH_REMATCH[2]}") + name="${BASH_REMATCH[3]}" + + echo "Valid expression. Verification in progress..." + query=$(build_k8s_request "") + client_with_options=$(build_k8s_client_with_options) + result=$(eval $client_with_options get $resource $query | grep $name | tail -n +1 | wc -l | tr -d '[:space:]') + + # Debug? + detik_debug "-----DETIK:begin-----" + detik_debug "$BATS_TEST_FILENAME" + detik_debug "$BATS_TEST_DESCRIPTION" + detik_debug "" + detik_debug "Client query:" + detik_debug "$client_with_options get $resource $query" + detik_debug "" + detik_debug "Result:" + detik_debug "$result" + detik_debug "-----DETIK:end-----" + detik_debug "" + + if [[ "$result" == "$card" ]]; then + echo "Found $result $resource named $name (as expected)." + else + echo "Found $result $resource named $name (instead of $card expected)." + return 3 + fi + + elif [[ "$exp" =~ $verify_regex_property_is ]]; then + property="${BASH_REMATCH[1]}" + expected_value="${BASH_REMATCH[2]}" + resource=$(to_lower_case "${BASH_REMATCH[3]}") + name="${BASH_REMATCH[4]}" + + echo "Valid expression. Verification in progress..." + verify_value $property $expected_value $resource $name + + if [[ "$?" != "0" ]]; then + return 3 + fi + + else + echo "Invalid expression: it does not respect the expected syntax." + return 2 + fi +} + + +# Verifies the value of a column for a set of elements. +# @param {string} A K8s column or one of the supported aliases. +# @param {string} The expected value. +# @param {string} The resouce type (e.g. pod). +# @param {string} The resource name or regex. +# @param {integer} a.k.a. "expected_count": the expected number of elements having this property (optional) +# @return +# If "expected_count" was NOT set: the number of elements with the wrong value. +# If "expected_count" was set: 101 if the elements count is not right, 0 otherwise. +verify_value() { + + # Make the parameters readable + property="$1" + expected_value=$(to_lower_case "$2") + resource="$3" + name="$4" + expected_count="$5" + + # List the items and remove the first line (the one that contains the column names) + query=$(build_k8s_request $property) + client_with_options=$(build_k8s_client_with_options) + result=$(eval $client_with_options get $resource $query | grep $name | tail -n +1) + + # Debug? + detik_debug "-----DETIK:begin-----" + detik_debug "$BATS_TEST_FILENAME" + detik_debug "$BATS_TEST_DESCRIPTION" + detik_debug "" + detik_debug "Client query:" + detik_debug "$client_with_options get $resource $query" + detik_debug "" + detik_debug "Result:" + detik_debug "$result" + if [[ "$expected_count" != "" ]]; then + detik_debug "" + detik_debug "Expected count: $expected_count" + fi + detik_debug "-----DETIK:end-----" + detik_debug "" + + # Is the result empty? + empty=0 + if [[ "$result" == "" ]]; then + echo "No resource of type '$resource' was found with the name '$name'." + fi + + # Verify the result + IFS=$'\n' + invalid=0 + valid=0 + for line in $result; do + + # Keep the second column (property to verify) + # and put it in lower case + value=$(to_lower_case "$line" | awk '{ print $2 }') + element=$(echo "$line" | awk '{ print $1 }') + if [[ "$value" != "$expected_value" ]]; then + echo "Current value for $element is $value..." + invalid=$((invalid + 1)) + else + echo "$element has the right value ($value)." + valid=$((valid + 1)) + fi + done + + # Do we have the right number of elements? + if [[ "$expected_count" != "" ]]; then + if [[ "$valid" != "$expected_count" ]]; then + echo "Expected $expected_count $resource named $name to have this value ($expected_value). Found $valid." + invalid=101 + else + invalid=0 + fi + fi + + return $invalid +} + + +# Builds the request for the get operation of the K8s client. +# @param {string} A K8s column or one of the supported aliases. +# @return 0 +build_k8s_request() { + + req="-o custom-columns=NAME:.metadata.name" + if [[ "$1" == "status" ]]; then + req="$req,PROP:.status.phase" + elif [[ "$1" == "port" ]]; then + req="$req,PROP:.spec.ports[*].port" + elif [[ "$1" == "targetPort" ]]; then + req="$req,PROP:.spec.ports[*].targetPort" + elif [[ "$1" != "" ]]; then + req="$req,PROP:$1" + fi + + echo $req +} + + +# Builds the client command, with the option for the K8s namespace, if any. +# @return 0 +build_k8s_client_with_options() { + + client_with_options="$DETIK_CLIENT_NAME" + if [[ ! -z "$DETIK_CLIENT_NAMESPACE" ]]; then + # eval does not like '-n' + client_with_options="$DETIK_CLIENT_NAME --namespace=$DETIK_CLIENT_NAMESPACE" + fi + + echo $client_with_options +} diff --git a/e2e/lib/linter.bash b/e2e/lib/linter.bash new file mode 100755 index 00000000..ac163eb6 --- /dev/null +++ b/e2e/lib/linter.bash @@ -0,0 +1,244 @@ +#!/bin/bash + +directory=$(dirname "${BASH_SOURCE[0]}") +source "$directory/utils.bash" + + +# Constants +lint_try_regex="^(run[[:space:]]+)?[[:space:]]*try[[:space:]]+(.*)$" +lint_verify_regex="^(run[[:space:]]+)?[[:space:]]*verify[[:space:]]+(.*)$" + +# Global variables +errors_count=0 +verified_entries_count=0 + + +# Verifies the syntax of DETIK queries. +# @param {string} A file path +# @return +# Any integer above 0: the number of found errors +# 0 Everything is fine +lint() { + + # Verify the file exists + if [ ! -f "$1" ]; then + handle_error "'$1' does not exist or is not a regular file." + return 1 + fi + + # Make the regular expression case-insensitive + shopt -s nocasematch; + + current_line="" + current_line_number=0 + user_line_number=0 + multi_line=1 + was_multi_line=1 + while IFS='' read -r line || [[ -n "$line" ]]; do + + # Increase the line number + current_line_number=$((current_line_number + 1)) + + # Debug + detik_debug "Read line $current_line_number: $line" + + # Skip empty lines and comments + if [[ ! -n "$line" ]] || [[ "$line" =~ ^[[:space:]]*#.* ]]; then + if [[ "$multi_line" == "0" ]]; then + handle_error "Incomplete multi-line statement at $current_line_number." + $current_line="" + fi + continue + fi + + # Is this line a part of a multi-line statement? + was_multi_line="$multi_line" + [[ "$line" =~ ^.*\\$ ]] + multi_line="$?" + + # Do we need to update the user line number? + if [[ "$was_multi_line" != "0" ]]; then + user_line_number="$current_line_number" + fi + + # Is this the continuation of a previous line? + if [[ "$multi_line" == "0" ]]; then + current_line="$current_line ${line::-1}" + elif [[ "$was_multi_line" == "0" ]]; then + current_line="$current_line $line" + else + current_line="$line" + fi + + # When we have a complete line... + if [[ "$multi_line" != "0" ]]; then + check_line "$current_line" "$user_line_number" + current_line="" + fi + line="" + done < "$1" + + # Output + if [[ "$verified_entries_count" == "1" ]]; then + echo "1 DETIK query was verified." + else + echo "$verified_entries_count DETIK queries were verified." + fi + + if [[ "$errors_count" == "1" ]]; then + echo "1 DETIK query was found to be invalid or malformed." + else + echo "$errors_count DETIK queries were found to be invalid or malformed." + fi + + # Prepare the result + res="$errors_count" + + # Reset global variables + errors_count=0 + verified_entries_count=0 + + return "$res" +} + + +# Verifies the correctness of a read line. +# @param {string} The line to verify +# @param {integer} The line number +# @return 0 +check_line() { + + # Make the regular expression case-insensitive + shopt -s nocasematch; + + # Get parameters and prepare the line + line="$1" + line_number="$2" + context="Current line: $line" + + line=$(echo "$line" | sed -e 's/"[[:space:]]*"//g') + line=$(trim "$line") + context="$context\nPurged line: $line" + + # Basic case: "run try", "run verify", "try", "verify" alone + if [[ "$line" =~ ^(run[[:space:]]+)?try$ ]] || [[ "$line" =~ ^(run[[:space:]]+)?verify$ ]]; then + verified_entries_count=$((verified_entries_count + 1)) + handle_error "Empty statement at line $line_number." "$context" + + # We have "try" or "run try" followed by something + elif [[ "$line" =~ $lint_try_regex ]]; then + verified_entries_count=$((verified_entries_count + 1)) + + part=$(clean_regex_part "${BASH_REMATCH[2]}") + context="$context\nRegex part: $part" + + verify_against_pattern "$part" "$try_regex_verify" + p_verify="$?" + + verify_against_pattern "$part" "$try_regex_find" + p_find="$?" + + # detik_debug "p_verify=$p_verify, p_find=$p_find, part=$part" + if [[ "$p_verify" != "0" ]] && [[ "$p_find" != "0" ]]; then + handle_error "Invalid TRY statement at line $line_number." "$context" + fi + + # We have "verify" or "run verify" followed by something + elif [[ "$line" =~ $lint_verify_regex ]]; then + verified_entries_count=$((verified_entries_count + 1)) + + part=$(clean_regex_part "${BASH_REMATCH[2]}") + context="$context\nRegex part: $part" + + verify_against_pattern "$part" "$verify_regex_count_is" + p_is="$?" + + verify_against_pattern "$part" "$verify_regex_count_are" + p_are="$?" + + verify_against_pattern "$part" "$verify_regex_property_is" + p_prop="$?" + + # detik_debug "p_is=$p_is, p_are=$p_are, p_prop=$p_prop, part=$part" + if [[ "$p_is" != "0" ]] && [[ "$p_are" != "0" ]] && [[ "$p_prop" != "0" ]] ; then + handle_error "Invalid VERIFY statement at line $line_number." "$context" + fi + fi +} + + +# Cleans a string before being checked by a regexp. +# @param {string} The string to clean +# @return 0 +clean_regex_part() { + + part=$(trim "$1") + part=$(remove_surrounding_quotes "$part") + part=$(trim "$part") + echo "$part" +} + + +# Removes surrounding quotes. +# @param {string} The string to clean +# @return 0 +remove_surrounding_quotes() { + + # Starting and ending with a quote? Remove them. + if [[ "$1" =~ ^\"(.*)\"$ ]]; then + echo "${BASH_REMATCH[1]}" + + # Otherwise, ignore it + else + echo "$1" + fi + + return 0 +} + + +# Verifies an assertion part against a regular expression. +# Given that assertions can skip double quotes around the whole +# assertion, we try the given regular expression and an altered one. +# +# Example: run try at most 5 times ... "'nginx'" ... +# +# Here, "'nginx'" is not part of the default regular expression. +# So, we update it to allow this kind of assertions. +# +# @param {string} The line to verify +# @param {string} The pattern +# @return +# 0 if everyhing went fine +# not-zero in case of error +verify_against_pattern() { + + # Make the regular expression case-insensitive + shopt -s nocasematch; + + line="$1" + pattern="$2" + code=0 + if ! [[ "$line" =~ $pattern ]]; then + line=${line//\"\'/\'} + line=${line//\'\"/\'} + [[ "$line" =~ $pattern ]] + code="$?" + fi + + return "$code" +} + + +# Handles an error by printing it and updating the error count. +# @param {string} The error message +# @param2 {string} The error context +# @return 0 +handle_error() { + + detik_debug "$2" + detik_debug "Error: $1" + + echo "$1" + errors_count=$((errors_count + 1)) +} diff --git a/e2e/lib/utils.bash b/e2e/lib/utils.bash new file mode 100755 index 00000000..7740215e --- /dev/null +++ b/e2e/lib/utils.bash @@ -0,0 +1,75 @@ +#!/bin/bash + + +# The regex for the "try" key word +try_regex_verify="^at +most +([0-9]+) +times +every +([0-9]+)s +to +get +([a-z]+) +named +'([^']+)' +and +verify +that +'([^']+)' +is +'([^']+)'$" +try_regex_find="^at +most +([0-9]+) +times +every +([0-9]+)s +to +find +([0-9]+) +([a-z]+) +named +'([^']+)' +with +'([^']+)' +being +'([^']+)'$" + +# The regex for the "verify" key word +verify_regex_count_is="^there +is +(0|1) +([a-z]+) +named +'([^']+)'$" +verify_regex_count_are="^there +are +([0-9]+) +([a-z]+) +named +'([^']+)'$" +verify_regex_property_is="^'([^']+)' +is +'([^']+)' +for +([a-z]+) +named +'([^']+)'$" + + + +# Prints a string in lower case. +# @param {string} The string. +# @return 0 +to_lower_case() { + echo "$1" | tr '[:upper:]' '[:lower:]' +} + + +# Trims a text. +# @param {string} The string. +# @return 0 +trim() { + echo $1 | sed -e 's/^[[:space:]]*([^[[:space:]]].*[^[[:space:]]])[[:space:]]*$/$1/' +} + + +# Trims ANSI codes (used to format strings in consoles). +# @param {string} The string. +# @return 0 +trim_ansi_codes() { + echo $1 | sed -e 's/[[:cntrl:]]\[[0-9;]*[a-zA-Z]//g' +} + + +# Adds a debug message for a given test. +# @param {string} The debug message. +# @return 0 +debug() { + debug_filename=$(basename -- $BATS_TEST_FILENAME) + mkdir -p /tmp/detik + echo -e "$1" >> "/tmp/detik/$debug_filename.debug" +} + + +# Deletes the file that contains debug messages for a given test. +# @return 0 +reset_debug() { + debug_filename=$(basename -- $BATS_TEST_FILENAME) + rm -f "/tmp/detik/$debug_filename.debug" +} + + +# Adds a debug message for a given test about DETIK. +# @param {string} The debug message. +# @return 0 +detik_debug() { + + if [[ "$DEBUG_DETIK" == "true" ]]; then + debug "$1" + fi +} + + +# Deletes the file that contains debug messages for a given test about DETIK. +# @return 0 +reset_detik_debug() { + + if [[ "$DEBUG_DETIK" == "true" ]]; then + reset_debug + fi +} diff --git a/e2e/lint.bats b/e2e/lint.bats new file mode 100644 index 00000000..f39677da --- /dev/null +++ b/e2e/lint.bats @@ -0,0 +1,11 @@ +#!/usr/bin/env bats +load "lib/utils" +load "lib/linter" + +@test "lint assertions" { + + run lint "test1.bats" + # echo -e "$output" > /tmp/errors.txt + [ "$status" -eq 0 ] + +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..d4ca30af --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,6 @@ +{ + "name": "clustercode", + "devDependencies": { + "bats": "1.2.1" + } +} diff --git a/e2e/test1.bats b/e2e/test1.bats new file mode 100644 index 00000000..44fbb17e --- /dev/null +++ b/e2e/test1.bats @@ -0,0 +1,22 @@ +#!/usr/bin/env bats + +load "lib/utils" +load "lib/detik" +load "lib/custom" + +DETIK_CLIENT_NAME="kubectl" +DETIK_CLIENT_NAMESPACE="clustercode-system" +DEBUG_DETIK="true" + +@test "reset the debug file" { + reset_debug +} + +@test "verify the deployment" { + go run sigs.k8s.io/kustomize/kustomize/v3 build test1 > debug/test1.yaml + run kubectl apply -f debug/test1.yaml + echo "$output" + + try "at most 20 times every 2s to find 1 pod named 'clustercode-operator' with 'status' being 'running'" + +} diff --git a/e2e/test1/deployment.yaml b/e2e/test1/deployment.yaml new file mode 100644 index 00000000..7c2b5185 --- /dev/null +++ b/e2e/test1/deployment.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: operator + namespace: system +spec: + template: + spec: + containers: + - name: clustercode + image: localhost:5000/ccremer/clustercode:e2e diff --git a/e2e/test1/kustomization.yaml b/e2e/test1/kustomization.yaml new file mode 100644 index 00000000..bf1c070b --- /dev/null +++ b/e2e/test1/kustomization.yaml @@ -0,0 +1,11 @@ +resources: +- ../../config/manager +- ../../config/rbac +patchesStrategicMerge: +- deployment.yaml +namespace: clustercode-system +namePrefix: clustercode- + +commonLabels: + app.kubernetes.io/name: e2e + app.kubernetes.io/managed-by: kustomize diff --git a/generate.go b/generate.go new file mode 100644 index 00000000..5e54a3fa --- /dev/null +++ b/generate.go @@ -0,0 +1,11 @@ +// +build generate + +package main + +//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile="config/boilerplate.go.txt" paths="./..." +//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:trivialVersions=true rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=${CRD_ROOT_DIR} crd:crdVersions=v1 + +import ( + _ "sigs.k8s.io/controller-tools" + _ "sigs.k8s.io/kustomize/kustomize/v3" +) diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..c51419c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/ccremer/clustercode + +go 1.15 + +require ( + github.com/go-logr/logr v0.3.0 + github.com/knadh/koanf v0.14.0 + k8s.io/api v0.19.6 + k8s.io/apimachinery v0.19.6 + k8s.io/client-go v0.19.6 + sigs.k8s.io/controller-runtime v0.7.0 + sigs.k8s.io/controller-tools v0.4.1 + sigs.k8s.io/kustomize/kustomize/v3 v3.9.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..49adba92 --- /dev/null +++ b/go.sum @@ -0,0 +1,934 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0 h1:ROfEUZz+Gh5pa62DJWXSaonyu3StP6EA6lPEXPI6mCo= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.51.0 h1:PvKAVQWCtlGUSlZkGW3QLelKaWq7KYv/MW1EboG8bfM= +cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/360EntSecGroup-Skylar/excelize v1.4.1/go.mod h1:vnax29X2usfl7HHkBrX5EvSCJcmH3dT9luvxzu8iGAE= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.9.6 h1:5YWtOnckcudzIw8lPPBcWOnmIFWMtHci1ZWAZulMSx0= +github.com/Azure/go-autorest/autorest v0.9.6/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.2 h1:O1X4oexUxnZCaEUGsvMnr8ZGj8HI37tNezwY4npRqA0= +github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0 h1:yW+Zlqf26583pE43KhfnhFcdmSWlm5Ew6bxipnr/tbM= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/logger v0.1.0 h1:ruG4BSDXONFRrZZJ2GUXDiUyVpayPmb1GnWeHDdaNKY= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VYyQflFE619k= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/OpenPeeDeeP/depguard v1.0.1/go.mod h1:xsIw86fROiiwelg+jB2uM9PiKihMMmUx/1V+TNhjQvM= +github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= +github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bombsimon/wsl v1.2.5/go.mod h1:43lEF/i0kpXbLCeDXL9LMT8c92HyBywXb0AsgMHYngM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustmop/soup v1.1.2-0.20190516214245-38228baa104e/go.mod h1:CgNC6SGbT+Xb8wGGvzilttZL1mc5sQ/5KkcxsZttMIk= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.5+incompatible h1:spTtZBk5DYEvbxMVutUuTyh1Ao2r4iyvLdACqsl/Ljk= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.9.0+incompatible h1:kLcOMZeuLAJvL2BPWLMIj5oaZQobrkAqrL+WFZwQses= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-critic/go-critic v0.3.5-0.20190904082202-d79a9f0c64db/go.mod h1:+sE8vrLDS2M0pZkBk0wy6+nLdKexVDrl/jBqQOTDThA= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-lintpack/lintpack v0.5.2/go.mod h1:NwZuYi2nUHho8XEIZ6SIxihrnPoqBTDqfpXvXAN0sXM= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0 h1:M1Tv3VzNlEHg6uyACnRdtrploV2P7wZqH8BoQMtz0cg= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0 h1:QvGt2nLcHH0WK9orKa+ppBPAxREcH364nPUedEpK0TY= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/logr v0.3.0 h1:q4c+kbcR0d5rSurhBR8dIgieOaYpXtsdTYfx22Cu6rs= +github.com/go-logr/logr v0.3.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-logr/zapr v0.2.0 h1:v6Ji8yBW77pva6NkJKQdHLAJKrIJKRHz0RXwPqCHSR4= +github.com/go-logr/zapr v0.2.0/go.mod h1:qhKdvif7YF5GI9NWEpyxTSSBdGmzkNguibrdCNVPunU= +github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/analysis v0.19.5 h1:8b2ZgKfKIUTVQpTb77MoRDIMEIwvDVw40o3aOXdfYzI= +github.com/go-openapi/analysis v0.19.5/go.mod h1:hkEAkxagaIvIP7VTn8ygJNkd4kAYON2rCu0v0ObL0AU= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2 h1:a2kIyV3w+OS3S97zxUndRVD46+FhGOUBDFY7nmu4CsY= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+jeTWn7tUa8o= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/loads v0.19.4 h1:5I4CCSqoWzT+82bBkNIvmLc0UOsoKKQ4Fz+3VxOB7SY= +github.com/go-openapi/loads v0.19.4/go.mod h1:zZVHonKd8DXyxyw4yfnVjPzBjIQcLt0CCsn0N0ZrQsk= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/runtime v0.19.4 h1:csnOgcgAiuGoM/Po7PEpKDoNulCcF3FGbSnbHfxgjMI= +github.com/go-openapi/runtime v0.19.4/go.mod h1:X277bwSUBxVlCYR3r7xgZZGKVvBd/29gLDlFGtJ8NL4= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/spec v0.19.5 h1:Xm0Ao53uqnk9QE/LlYV5DEU09UAgpliA85QoT9LzqPw= +github.com/go-openapi/spec v0.19.5/go.mod h1:Hm2Jr4jv8G1ciIAo+frC/Ft+rR2kQDh8JHKHb3gWUSk= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/strfmt v0.19.3/go.mod h1:0yX7dbo8mKIvc3XSKp7MNfxw4JytCfCD6+bY1AVL9LU= +github.com/go-openapi/strfmt v0.19.5 h1:0utjKrw+BAh8s57XE9Xz8DUBsVvPmRUB6styvl9wWIM= +github.com/go-openapi/strfmt v0.19.5/go.mod h1:eftuHTlB/dI8Uq8JJOyRlieZf+WkkxUuk0dgdHXr2Qk= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= +github.com/go-openapi/validate v0.19.8 h1:YFzsdWIDfVuLvIOF+ZmKjVg1MbPJ1QgY9PihMwei1ys= +github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4= +github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ= +github.com/go-toolsmith/astequal v0.0.0-20180903214952-dcb477bfacd6/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY= +github.com/go-toolsmith/astequal v1.0.0/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY= +github.com/go-toolsmith/astfmt v0.0.0-20180903215011-8f8ee99c3086/go.mod h1:mP93XdblcopXwlyN4X4uodxXQhldPGZbcEJIimQHrkg= +github.com/go-toolsmith/astfmt v1.0.0/go.mod h1:cnWmsOAuq4jJY6Ct5YWlVLmcmLMn1JUPuQIHCY7CJDw= +github.com/go-toolsmith/astinfo v0.0.0-20180906194353-9809ff7efb21/go.mod h1:dDStQCHtmZpYOmjRP/8gHHnCCch3Zz3oEgCdZVdtweU= +github.com/go-toolsmith/astp v0.0.0-20180903215135-0af7e3c24f30/go.mod h1:SV2ur98SGypH1UjcPpCatrV5hPazG6+IfNHbkDXBRrk= +github.com/go-toolsmith/astp v1.0.0/go.mod h1:RSyrtpVlfTFGDYRbrjyWP1pYu//tSFcvdYrA8meBmLI= +github.com/go-toolsmith/pkgload v0.0.0-20181119091011-e9e65178eee8/go.mod h1:WoMrjiy4zvdS+Bg6z9jZH82QXwkcgCBX6nOfnmdaHks= +github.com/go-toolsmith/pkgload v1.0.0/go.mod h1:5eFArkbO80v7Z0kdngIxsRXRMTaX4Ilcwuh3clNrQJc= +github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= +github.com/go-toolsmith/typep v1.0.0/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU= +github.com/gobuffalo/flect v0.2.0 h1:EWCvMGGxOjsgwlWaP+f4+Hh6yrrte7JeFL2S6b+0hdM= +github.com/gobuffalo/flect v0.2.0/go.mod h1:W3K3X9ksuZfir8f/LrfVtWmCDQFfayuylOJ7sz/Fj80= +github.com/gobuffalo/here v0.6.0/go.mod h1:wAG085dHOYqUpf+Ap+WOdrPTp5IYcDAs/x7PLa8Y5fM= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.0.0-20190320160742-5135e617513b/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7 h1:5ZkaAPbicIKTF2I64qf5Fh8Aa83Q/dnOafMYV0OMwjA= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golangci/check v0.0.0-20180506172741-cfe4005ccda2/go.mod h1:k9Qvh+8juN+UKMCS/3jFtGICgW8O96FVaZsaxdzDkR4= +github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a/go.mod h1:ryS0uhF+x9jgbj/N71xsEqODy9BN81/GonCZiOzirOk= +github.com/golangci/errcheck v0.0.0-20181223084120-ef45e06d44b6/go.mod h1:DbHgvLiFKX1Sh2T1w8Q/h4NAI8MHIpzCdnBUDTXU3I0= +github.com/golangci/go-misc v0.0.0-20180628070357-927a3d87b613/go.mod h1:SyvUF2NxV+sN8upjjeVYr5W7tyxaT1JVtvhKhOn2ii8= +github.com/golangci/goconst v0.0.0-20180610141641-041c5f2b40f3/go.mod h1:JXrF4TWy4tXYn62/9x8Wm/K/dm06p8tCKwFRDPZG/1o= +github.com/golangci/gocyclo v0.0.0-20180528134321-2becd97e67ee/go.mod h1:ozx7R9SIwqmqf5pRP90DhR2Oay2UIjGuKheCBCNwAYU= +github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a/go.mod h1:9qCChq59u/eW8im404Q2WWTrnBUQKjpNYKMbU4M7EFU= +github.com/golangci/golangci-lint v1.21.0/go.mod h1:phxpHK52q7SE+5KpPnti4oZTdFCEsn/tKN+nFvCKXfk= +github.com/golangci/ineffassign v0.0.0-20190609212857-42439a7714cc/go.mod h1:e5tpTHCfVze+7EpLEozzMB3eafxo2KT5veNg1k6byQU= +github.com/golangci/lint-1 v0.0.0-20191013205115-297bf364a8e0/go.mod h1:66R6K6P6VWk9I95jvqGxkqJxVWGFy9XlDwLwVz1RCFg= +github.com/golangci/maligned v0.0.0-20180506175553-b1d89398deca/go.mod h1:tvlJhZqDe4LMs4ZHD0oMUlt9G2LWuDGoisJTBzLMV9o= +github.com/golangci/misspell v0.0.0-20180809174111-950f5d19e770/go.mod h1:dEbvlSfYbMQDtrpRMQU675gSDLDNa8sCPPChZ7PhiVA= +github.com/golangci/prealloc v0.0.0-20180630174525-215b22d4de21/go.mod h1:tf5+bzsHdTM0bsB7+8mt0GUMvjCgwLpTapNZHU8AajI= +github.com/golangci/revgrep v0.0.0-20180526074752-d9c87f5ffaf0/go.mod h1:qOQCunEYvmd/TLamH+7LlVccLvUH5kZNhbCgTHoBbp4= +github.com/golangci/unconvert v0.0.0-20180507085042-28b1c447d1f4/go.mod h1:Izgrg8RkN3rCIMLGE9CyYmU9pY2Jer6DgANEnZ/L/cQ= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.4.1 h1:DLJCy1n/vrD4HPjOvYcT8aYQXpPIzoRZONaYwyycI+I= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/googleapis/gnostic v0.5.1 h1:A8Yhf6EtqTv9RMsU6MQTyrtV1TjWlR6xU9BsZIwuTCM= +github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6KdvN3Gig= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= +github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0= +github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= +github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/knadh/koanf v0.14.0 h1:h9XeG4wEiEuxdxqv/SbY7TEK+7vzrg/dOaGB+S6+mPo= +github.com/knadh/koanf v0.14.0/go.mod h1:H5mEFsTeWizwFXHKtsITL5ipsLTuAMQoGuQpp+1JL9U= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/markbates/pkger v0.17.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI= +github.com/matoous/godox v0.0.0-20190911065817-5d6d842e92eb/go.mod h1:1BELzlh859Sh1c6+90blK8lbYy0kwQf1bYlBhBysy1s= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-ps v0.0.0-20190716172923-621e5597135b/go.mod h1:r1VsdOzOPt1ZSrGZWFoNhsAedKnEd6r9Np1+5blZCWk= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= +github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/mozilla/tls-observatory v0.0.0-20190404164649-a3c1b6cfecfd/go.mod h1:SrKMQvPiws7F7iqYp8/TX+IhxCYhzr6N/1yb8cwHsGk= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.0-20170122224234-a0225b3f23b5/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= +github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1 h1:mFwc4LvZ0xpSvDZ3E+k8Yte0hLOMxXUlP+yXtJqkYfQ= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.1 h1:jMU0WaQrP0a/YAEq8eJmJKjBoMs+pClEr1vDMlM/Do4= +github.com/onsi/ginkgo v1.14.1/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= +github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= +github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/paulmach/orb v0.1.3/go.mod h1:VFlX/8C+IQ1p6FTRRKzKoOPJnvEtA5G0Veuqwbu//Vk= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= +github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_golang v1.0.0 h1:vrDKnkGzuGvhNAL56c7DBz29ZL+KxnoR0x7enabFceM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1 h1:NTGy1Ja9pByO+xAeH/qiWnLrKtr3hJPNjaVUwnjpdpA= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0 h1:uq5h0d+GuxiXLJLNABMgp2qUWDPiLvgCzz2dUR+/W/M= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1 h1:K0MGApIoQvMw27RTdJkPbr3JZ7DNbtxQNyi5STVM6Kw= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0 h1:RyRA7RzGXQZiW+tGMr7sxa85G1z0yOpM1qq5c8lNawc= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFBS8= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/qri-io/starlib v0.4.2-0.20200213133954-ff2e8cd5ef8d h1:K6eOUihrFLdZjZnA4XlRp864fmWXv9YTIk7VPLhRacA= +github.com/qri-io/starlib v0.4.2-0.20200213133954-ff2e8cd5ef8d/go.mod h1:7DPO4domFU579Ga6E61sB9VFNaniPVwJP5C4bBCu3wA= +github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI= +github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc= +github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= +github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= +github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/sourcegraph/go-diff v0.5.1/go.mod h1:j2dHj3m8aZgQO8lMTcTnBcXkRRRqi34cd2MNlA9u1mE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.3-0.20181224173747-660f15d67dbb/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ulikunitz/xz v0.5.8 h1:ERv8V6GKqVi23rgu5cj9pVfVzJbOqAY2Ntl88O6c2nQ= +github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ultraware/funlen v0.0.2/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= +github.com/ultraware/whitespace v0.0.4/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA= +github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= +github.com/uudashr/gocognit v0.0.0-20190926065955-1655d0de0517/go.mod h1:j44Ayx2KW4+oB6SWMv8KsmHzZrOInQav7D3cQMJ5JUM= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.2.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s= +github.com/valyala/quicktemplate v1.2.0/go.mod h1:EH+4AkTd43SvgIbQHYu59/cJyxDoOVRUAfrukLPuGJ4= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca h1:1CFlNzQhALwjS9mBAUkycX616GzgsuYUOCHA5+HSlXI= +github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca/go.mod h1:ce1O1j6UtZfjr22oyGxGLbauSBp2YVXpARAosm7dHBg= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yujunz/go-getter v1.5.1-lite.0.20201201013212-6d9c071adddf h1:gvEmqF83GB8R5XtrMseJb6A6R0OCtNAS8f4TmZg2dGc= +github.com/yujunz/go-getter v1.5.1-lite.0.20201201013212-6d9c071adddf/go.mod h1:bL0Pr07HEdsMZ1WBqZIxXj96r5LnFsY4LgPaPEGkw1k= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= +go.etcd.io/etcd v0.5.0-alpha.5.0.20200819165624-17cef6e3e9d5/go.mod h1:skWido08r9w6Lq/w70DO5XYIKMu4QFu1+4VsqLQuJy8= +go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.mongodb.org/mongo-driver v1.1.2 h1:jxcFYjlkl8xaERsgLo+RNquI0epW6zuy/ZRQs6jnrFA= +go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.starlark.net v0.0.0-20190528202925-30ae18b8564f/go.mod h1:c1/X6cHgvdXj6pUlmWKMkuqRnW4K8x2vwt6JAaaircg= +go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 h1:+FNtrFTmVw0YZGpBGX56XDee331t6JAXeK2bcyhLOOc= +go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/goleak v1.1.10 h1:z+mqJhf6ss6BSfSM671tgKyZBFPTTJM+HLxnhPC3wu0= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.8.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190617133340-57b3e21c3d56/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f h1:J5lckAjkw6qYlOZNj90mLYNTEKDvWeuc1yieZ8qUzUE= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 h1:pE8b58s1HRDMi8RDc79m0HISf9D4TzseP40cEA6IGfs= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191002063906-3421d5a6bb1c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200622214017-ed371f2e16b4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd h1:5CtCZbICpIOFdgO940moixOPjc0178IU44m4EjOO5IY= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181117154741-2ddaf7f79a09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190110163146-51295c7ec13a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190311215038-5c2858a9cfe5/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190322203728-c1a832b0ad89/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190521203540-521d6ed310dd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190617190820-da514acc4774/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190719005602-e377ae9d6386/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190910044552-dd2b5c81c578/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190930201159-7c411dea38b0/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191010075000-0337d82405ff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200616133436-c1934b75d054 h1:HHeAlu5H9b71C+Fx0K+1dGgVFN1DM1/wz4aoGOA5qS8= +golang.org/x/tools v0.0.0-20200616133436-c1934b75d054/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200616195046-dc31b401abb5 h1:UaoXseXAWUJUcuJ2E2oczJdLxAJXL0lOmVaBl7kuk+I= +golang.org/x/tools v0.0.0-20200616195046-dc31b401abb5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.1.0 h1:Phva6wqu+xR//Njw6iorylFFgn/z547tw5Ne3HZPQ+k= +gomodules.xyz/jsonpatch/v2 v2.1.0/go.mod h1:IhYNNY4jnS53ZnfE4PAmpKtDpTCj1JFXc+3mwe7XcUU= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0 h1:UhZDfRO8JRQru4/+LlLE0BRKGF8L+PICnvYZmx/fEGA= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +k8s.io/api v0.17.0/go.mod h1:npsyOePkeP0CPwyGfXDHxvypiYMJxBWAMpQxCaJ4ZxI= +k8s.io/api v0.18.2/go.mod h1:SJCWI7OLzhZSvbY7U8zwNl9UA4o1fizoug34OV/2r78= +k8s.io/api v0.18.10/go.mod h1:xWtwPX1v47j5RTncmlMFGCx8b0avh+nP8OgZZ9hjo3M= +k8s.io/api v0.19.2/go.mod h1:IQpK0zFQ1xc5iNIQPqzgoOwuFugaYHK4iCknlAQP9nI= +k8s.io/api v0.19.6 h1:F3lfwgpKcKms6F1mMqkQXFzXmme8QqHTJBtBkev3TOg= +k8s.io/api v0.19.6/go.mod h1:Plxx44Nh4zVblkJrIgxVPgPre1mvng6tXf1Sj3bs0fU= +k8s.io/apiextensions-apiserver v0.18.2/go.mod h1:q3faSnRGmYimiocj6cHQ1I3WpLqmDgJFlKL37fC4ZvY= +k8s.io/apiextensions-apiserver v0.19.2 h1:oG84UwiDsVDu7dlsGQs5GySmQHCzMhknfhFExJMz9tA= +k8s.io/apiextensions-apiserver v0.19.2/go.mod h1:EYNjpqIAvNZe+svXVx9j4uBaVhTB4C94HkY3w058qcg= +k8s.io/apimachinery v0.17.0/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/apimachinery v0.18.2/go.mod h1:9SnR/e11v5IbyPCGbvJViimtJ0SwHG4nfZFjU77ftcA= +k8s.io/apimachinery v0.18.10/go.mod h1:PF5taHbXgTEJLU+xMypMmYTXTWPJ5LaW8bfsisxnEXk= +k8s.io/apimachinery v0.19.2/go.mod h1:DnPGDnARWFvYa3pMHgSxtbZb7gpzzAZ1pTfaUNDVlmA= +k8s.io/apimachinery v0.19.6 h1:kBLzSGuDdY1NdSV2uFzI+FwZ9wtkmG+X3ZVcWXSqNgA= +k8s.io/apimachinery v0.19.6/go.mod h1:6sRbGRAVY5DOCuZwB5XkqguBqpqLU6q/kOaOdk29z6Q= +k8s.io/apiserver v0.18.2/go.mod h1:Xbh066NqrZO8cbsoenCwyDJ1OSi8Ag8I2lezeHxzwzw= +k8s.io/apiserver v0.19.2/go.mod h1:FreAq0bJ2vtZFj9Ago/X0oNGC51GfubKK/ViOKfVAOA= +k8s.io/client-go v0.17.0/go.mod h1:TYgR6EUHs6k45hb6KWjVD6jFZvJV4gHDikv/It0xz+k= +k8s.io/client-go v0.18.2/go.mod h1:Xcm5wVGXX9HAA2JJ2sSBUn3tCJ+4SVlCbl2MNNv+CIU= +k8s.io/client-go v0.18.10/go.mod h1:XBkFAqPrzqfwmGkV5ac+mlgBpWcz5TkhLw2808q8C3c= +k8s.io/client-go v0.19.2/go.mod h1:S5wPhCqyDNAlzM9CnEdgTGV4OqhsW3jGO1UM1epwfJA= +k8s.io/client-go v0.19.6 h1:vtPb33nP8DBMW+/CyuJ8fiie36c3CM1Ts6L4Tsr+PtU= +k8s.io/client-go v0.19.6/go.mod h1:gEiS+efRlXYUEQ9Oz4lmNXlxAl5JZ8y2zbTDGhvXXnk= +k8s.io/code-generator v0.18.2/go.mod h1:+UHX5rSbxmR8kzS+FAv7um6dtYrZokQvjHpDSYRVkTc= +k8s.io/code-generator v0.19.2/go.mod h1:moqLn7w0t9cMs4+5CQyxnfA/HV8MF6aAVENF+WZZhgk= +k8s.io/component-base v0.18.2/go.mod h1:kqLlMuhJNHQ9lz8Z7V5bxUUtjFZnrypArGl58gmDfUM= +k8s.io/component-base v0.19.2 h1:jW5Y9RcZTb79liEhW3XDVTW7MuvEGP0tQZnfSX6/+gs= +k8s.io/component-base v0.19.2/go.mod h1:g5LrsiTiabMLZ40AR6Hl45f088DevyGY+cCE2agEIVo= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20200428234225-8167cfdcfc14/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog/v2 v2.0.0 h1:Foj74zO6RbjjP4hBEKjnYtjjAhGg4jNynUdYF6fJrok= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.2.0 h1:XRvcwJozkgZ1UQJmfMGpvRthQHOvihEhYtDfAaxMz/A= +k8s.io/klog/v2 v2.2.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20200121204235-bf4fb3bd569c/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= +k8s.io/kube-openapi v0.0.0-20200410145947-61e04a5be9a6/go.mod h1:GRQhZsXIAJ1xR0C9bd8UpWHZ5plfAS9fzPjJuQ6JL3E= +k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6 h1:+WnxoVtG8TMiudHBSEtrVL1egv36TkkJm+bA8AxicmQ= +k8s.io/kube-openapi v0.0.0-20200805222855-6aeccd4b50c6/go.mod h1:UuqjUnNftUyPE5H64/qeyjQoUZhGpeFDVdxjTeEVN2o= +k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200324210504-a9aa75ae1b89/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20200729134348-d5654de09c73/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +k8s.io/utils v0.0.0-20200912215256-4140de9c8800 h1:9ZNvfPvVIEsp/T1ez4GQuzCcCTEQWhovSofhqR73A6g= +k8s.io/utils v0.0.0-20200912215256-4140de9c8800/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +mvdan.cc/interfacer v0.0.0-20180901003855-c20040233aed/go.mod h1:Xkxe497xwlCKkIaQYRfC7CSLworTXY9RMqwhhCm+8Nc= +mvdan.cc/lint v0.0.0-20170908181259-adc824a0674b/go.mod h1:2odslEg/xrtNQqCYg2/jCoyKnw3vv5biOc3JnIcYfL4= +mvdan.cc/unparam v0.0.0-20190720180237-d51796306d8f/go.mod h1:4G1h5nDURzA3bwVMZIVpwbkw+04kSxk3rAtzlimaUJw= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.7/go.mod h1:PHgbrJT7lCHcxMU+mDHEm+nx46H4zuuHZkDP6icnhu0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.0.9/go.mod h1:dzAXnQbTRyDlZPJX2SUPEqvnB+j7AJjtlox7PEwigU0= +sigs.k8s.io/controller-runtime v0.7.0 h1:bU20IBBEPccWz5+zXpLnpVsgBYxqclaHu1pVDl/gEt8= +sigs.k8s.io/controller-runtime v0.7.0/go.mod h1:pJ3YBrJiAqMAZKi6UVGuE98ZrroV1p+pIhoHsMm9wdU= +sigs.k8s.io/controller-tools v0.4.1 h1:VkuV0MxlRPmRu5iTgBZU4UxUX2LiR99n3sdQGRxZF4w= +sigs.k8s.io/controller-tools v0.4.1/go.mod h1:G9rHdZMVlBDocIxGkK3jHLWqcTMNvveypYJwrvYKjWU= +sigs.k8s.io/kustomize v1.0.11 h1:Yb+6DDt9+aR2AvQApvUaKS/ugteeG4MPyoFeUHiPOjk= +sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0= +sigs.k8s.io/kustomize/api v0.7.0 h1:djxH9k1izeU1BvdP1i23qqKwhmWu2BuKNEKr/Da7Dpw= +sigs.k8s.io/kustomize/api v0.7.0/go.mod h1:3TxKEyaxwOIfHmRbQF14hDUSRmVQI0iSn8qDA5zaO/0= +sigs.k8s.io/kustomize/cmd/config v0.8.6 h1:Rr7eyD+h32OfruN6V+cgUqHRpC2Y5ZnjjAPbjhKFLGE= +sigs.k8s.io/kustomize/cmd/config v0.8.6/go.mod h1:e4PgdLUNnkf+Iapvjyb6gTG9DZQkDZIR6uS1Bv4YA6s= +sigs.k8s.io/kustomize/kustomize v0.0.0-20191024000301-ce7ebe3299dd h1:naYNcVnc1wjElh8I9o/NP2oqSlYCJJERusQrC3WGcBQ= +sigs.k8s.io/kustomize/kustomize/v3 v3.9.0 h1:U2B1nfr4Dvz4aMiO9DJCi1dODSiB46vtHT0a0BaG5/0= +sigs.k8s.io/kustomize/kustomize/v3 v3.9.0/go.mod h1:IY5mZn2ehVHpWr5vpSo1MkZ7oZVQGZdN1sH2GMU5bfQ= +sigs.k8s.io/kustomize/kyaml v0.10.3 h1:ARSJUMN/c3k31DYxRfZ+vp/UepUQjg9zCwny7Oj908I= +sigs.k8s.io/kustomize/kyaml v0.10.3/go.mod h1:RA+iCHA2wPCOfv6uG6TfXXWhYsHpgErq/AljxWKuxtg= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e h1:4Z09Hglb792X0kfOBBJUPFEyvVfQWrYT/l8h5EKA6JQ= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0-20200116222232-67a7b8c61874/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v3 v3.0.0/go.mod h1:PlARxl6Hbt/+BC80dRLi1qAmnMqwqDg62YvvVkZjemw= +sigs.k8s.io/structured-merge-diff/v4 v4.0.1 h1:YXTMot5Qz/X1iBRJhAt+vI+HVttY0WkSqqhKxQ0xVbA= +sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/main.go b/main.go new file mode 100644 index 00000000..fbb07344 --- /dev/null +++ b/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "os" + "strings" + + "github.com/knadh/koanf" + "github.com/knadh/koanf/providers/env" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/ccremer/clustercode/cfg" + "github.com/ccremer/clustercode/controllers" +) + +var ( + // These will be populated by Goreleaser + version string + commit string + date string + + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") + // Global koanfInstance instance. Use . as the key path delimiter. + koanfInstance = koanf.New(".") +) + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(batchv1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme +} + +func main() { + + ctrl.SetLogger(zap.New(zap.UseDevMode(true))) + + loadEnvironmentVariables() + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + MetricsBindAddress: cfg.Config.MetricsBindAddress, + Port: 9443, + LeaderElection: cfg.Config.EnableLeaderElection, + LeaderElectionID: "clustercode.github.io", + }) + if err != nil { + setupLog.Error(err, "unable to start operator") + os.Exit(1) + } + + if err = (&controllers.ClustercodePlanReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("clustercodeplan"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "clustercodeplan") + os.Exit(1) + } + // +kubebuilder:scaffold:builder + + setupLog.WithValues("version", version, "date", date, "commit", commit).Info("Starting operator") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running operator") + os.Exit(1) + } +} + +func loadEnvironmentVariables() { + prefix := "CC_" + // Load environment variables + err := koanfInstance.Load(env.Provider(prefix, ".", func(s string) string { + s = strings.TrimLeft(s, prefix) + s = strings.Replace(strings.ToLower(s), "_", "-", -1) + return s + }), nil) + if err != nil { + setupLog.Error(err, "could not load environment variables") + } + + if err := koanfInstance.UnmarshalWithConf("", &cfg.Config, koanf.UnmarshalConf{Tag: "koanf", FlatPaths: true}); err != nil { + setupLog.Error(err, "could not merge defaults with settings from environment variables") + } + if err := cfg.Config.ValidateSyntax(); err != nil { + setupLog.Error(err, "settings invalid") + os.Exit(2) + } +} From 2fe46f2afce62424c4b558cba003c0169ee577e0 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 20 Dec 2020 14:47:01 +0100 Subject: [PATCH 03/13] Start defining API --- api/v1alpha1/clustercodeplan_types.go | 33 +++++++++- api/v1alpha1/clustercodetask_types.go | 9 ++- api/v1alpha1/zz_generated.deepcopy.go | 55 +++++++++++++++- cfg/config.go | 1 + ...lustercode.github.io_clustercodeplans.yaml | 63 ++++++++++++++++++- ...lustercode.github.io_clustercodetasks.yaml | 58 +++++++++++++++++ controllers/clustercodeplan_controller.go | 13 +++- 7 files changed, 225 insertions(+), 7 deletions(-) diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 1ab86e4c..919f8444 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -6,12 +6,39 @@ import ( ) type ( - // ClustercodePlanSpec defines the desired state of Archive. + // ClustercodePlanSpec specifies a Clustercode ClustercodePlanSpec struct { - // +kubebuilder:default=1 - ScanIntervalSeconds int64 `json:"scanIntervalMinutes,omitempty"` + ScanSchedule string `json:"scanSchedule"` // +kubebuilder:validation:Required SourceVolume corev1.Volume `json:"sourceVolume,omitempty"` + // +kubebuilder:validation:Required + SourceVolumeSubdir string `json:"sourceVolumeSubdir,omitempty"` + + // +kubebuilder:default=1 + MaxParallelTasks int `json:"maxParallelTasks,omitempty"` + + Suspend bool `json:"suspend,omitempty"` + + ScanSpec ScanSpec `json:"scanSpec,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec,omitempty"` + } + + ScanSpec struct { + // +kubebuilder:default=mkv;mp4;avi + MediaFileExtensions []string `json:"mediaFileExtensions,omitempty"` + } + + EncodeSpec struct { + // +kubebuilder:default=-y;-hide_banner;-nostats + DefaultFfmpegArgs []string `json:"defaultFfmpegArgs"` + // +kubebuilder:default=-i;"\"${INPUT}\"";-c;copy;-map;0;-segment_time;"\"${SLICE_SIZE}\"";-f;segment;"\"${OUTPUT}\"" + SplitFfmpegArgs []string `json:"splitFfmpegArgs"` + // +kubebuilder:default=-i;"\"${INPUT}\"";"-c:v";copy;"-c:a";copy;"\"${OUTPUT}\"" + TranscodeFfmpegArgs []string `json:"transcodeArgs"` + // +kubebuilder:default=-f;concat;-i;concat.txt;-c;copy;media_out.mkv + MergeFfmpegArgs []string `json:"mergeFfmpegArgs"` + + SliceSize int `json:"sliceSize,omitempty"` } // ClustercodePlan is the Schema for the archives API diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index 3d7576c3..b5551ab3 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -8,6 +8,10 @@ type ( // EncodingTaskSpec defines the desired state of Archive. ClustercodeTaskSpec struct { + SourceUrl string `json:"sourceUrl,omitempty"` + TargetUrl string `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec `json:"encodeSpec"` } // ClustercodePlan is the Schema for the archives API @@ -28,7 +32,10 @@ type ( Items []ClustercodeTask `json:"items"` } ClustercodeTaskStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + SourceMediaFileName string `json:"sourceMediaFileName,omitempty"` + TargetMediaFileName string `json:"targetMediaFileName,omitempty"` + SliceCount int `json:"sliceCount,omitempty"` } ) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index ee3e1368..2635e00e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -72,6 +72,8 @@ func (in *ClustercodePlanList) DeepCopyObject() runtime.Object { func (in *ClustercodePlanSpec) DeepCopyInto(out *ClustercodePlanSpec) { *out = *in in.SourceVolume.DeepCopyInto(&out.SourceVolume) + in.ScanSpec.DeepCopyInto(&out.ScanSpec) + in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlanSpec. @@ -111,7 +113,7 @@ func (in *ClustercodeTask) DeepCopyInto(out *ClustercodeTask) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -168,6 +170,7 @@ func (in *ClustercodeTaskList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodeTaskSpec) DeepCopyInto(out *ClustercodeTaskSpec) { *out = *in + in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskSpec. @@ -201,3 +204,53 @@ func (in *ClustercodeTaskStatus) DeepCopy() *ClustercodeTaskStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EncodeSpec) DeepCopyInto(out *EncodeSpec) { + *out = *in + if in.DefaultFfmpegArgs != nil { + in, out := &in.DefaultFfmpegArgs, &out.DefaultFfmpegArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SplitFfmpegArgs != nil { + in, out := &in.SplitFfmpegArgs, &out.SplitFfmpegArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.MergeFfmpegArgs != nil { + in, out := &in.MergeFfmpegArgs, &out.MergeFfmpegArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EncodeSpec. +func (in *EncodeSpec) DeepCopy() *EncodeSpec { + if in == nil { + return nil + } + out := new(EncodeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ScanSpec) DeepCopyInto(out *ScanSpec) { + *out = *in + if in.MediaFileExtensions != nil { + in, out := &in.MediaFileExtensions, &out.MediaFileExtensions + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ScanSpec. +func (in *ScanSpec) DeepCopy() *ScanSpec { + if in == nil { + return nil + } + out := new(ScanSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cfg/config.go b/cfg/config.go index 7ffc52ac..f212fd55 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -16,6 +16,7 @@ var ( func NewDefaultConfig() *Configuration { return &Configuration{ EnableLeaderElection: true, + MetricsBindAddress: ":9090", } } diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index 15b3a3e7..012567a4 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -30,12 +30,69 @@ spec: metadata: type: object spec: - description: ClustercodePlanSpec defines the desired state of Archive. + description: ClustercodePlanSpec specifies a Clustercode properties: + encodeSpec: + properties: + defaultFfmpegArgs: + default: + - -y + - -hide_banner + - -nostats + items: + type: string + type: array + mergeFfmpegArgs: + default: + - -f + - concat + - -i + - concat.txt + - -c + - copy + - media_out.mkv + items: + type: string + type: array + splitFfmpegArgs: + default: + - -i + - '"${INPUT}"' + - -c + - copy + - -map + - "0" + - -segment_time + - '"${SLICE_SIZE}"' + - -f + - segment + - '"${OUTPUT}"' + items: + type: string + type: array + required: + - defaultFfmpegArgs + - mergeFfmpegArgs + - splitFfmpegArgs + type: object + maxParallelTasks: + default: 1 + type: integer scanIntervalMinutes: default: 1 format: int64 type: integer + scanSpec: + properties: + mediaFileExtensions: + default: + - mkv + - mp4 + - avi + items: + type: string + type: array + type: object sourceVolume: description: Volume represents a named volume in a pod that may be accessed by any container in the pod. properties: @@ -938,6 +995,10 @@ spec: required: - name type: object + sourceVolumeSubdir: + type: string + suspend: + type: boolean type: object status: properties: diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index e59a9817..6ce23b40 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -31,6 +31,58 @@ spec: type: object spec: description: EncodingTaskSpec defines the desired state of Archive. + properties: + encodeSpec: + properties: + defaultFfmpegArgs: + default: + - -y + - -hide_banner + - -nostats + items: + type: string + type: array + mergeFfmpegArgs: + default: + - -f + - concat + - -i + - concat.txt + - -c + - copy + - media_out.mkv + items: + type: string + type: array + splitFfmpegArgs: + default: + - -i + - '"${INPUT}"' + - -c + - copy + - -map + - "0" + - -segment_time + - '"${SLICE_SIZE}"' + - -f + - segment + - '"${OUTPUT}"' + items: + type: string + type: array + required: + - defaultFfmpegArgs + - mergeFfmpegArgs + - splitFfmpegArgs + type: object + sourceUrl: + type: string + suspend: + type: boolean + targetUrl: + type: string + required: + - encodeSpec type: object status: properties: @@ -77,6 +129,12 @@ spec: - type type: object type: array + sliceCount: + type: integer + sourceMediaFileName: + type: string + targetMediaFileName: + type: string type: object type: object served: true diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index d7d85797..a5336635 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -2,8 +2,10 @@ package controllers import ( "context" + "time" "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -38,7 +40,16 @@ func (r *ClustercodePlanReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, returnErr error) { rc := ReconciliationContext{ ctx: ctx, + plan: &v1alpha1.ClustercodePlan{}, + } + err := r.Client.Get(ctx, req.NamespacedName, rc.plan) + if err != nil { + if errors.IsNotFound(err) { + r.Log.Info("object not found, ignoring reconcile", "object", req.NamespacedName) + return ctrl.Result{}, nil + } + r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) + return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } - return ctrl.Result{}, nil } From 5c807d34f0f7f051707514b45339af6a3e30c537 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 20 Dec 2020 16:10:46 +0100 Subject: [PATCH 04/13] Introduce CLI library for different modes of operation --- api/v1alpha1/clustercodetask_types.go | 6 +- api/v1alpha1/zz_generated.deepcopy.go | 5 + cfg/config.go | 28 +++-- cmd/operate.go | 64 +++++++++++ cmd/root.go | 105 ++++++++++++++++++ cmd/scan.go | 36 ++++++ ...lustercode.github.io_clustercodeplans.yaml | 23 +++- ...lustercode.github.io_clustercodetasks.yaml | 15 +++ controllers/clustercodeplan_controller.go | 7 +- generate.go | 2 +- go.mod | 3 + go.sum | 73 +++++++++++- main.go | 80 +------------ 13 files changed, 350 insertions(+), 97 deletions(-) create mode 100644 cmd/operate.go create mode 100644 cmd/root.go create mode 100644 cmd/scan.go diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index b5551ab3..136dd61d 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -8,9 +8,9 @@ type ( // EncodingTaskSpec defines the desired state of Archive. ClustercodeTaskSpec struct { - SourceUrl string `json:"sourceUrl,omitempty"` - TargetUrl string `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` + SourceUrl string `json:"sourceUrl,omitempty"` + TargetUrl string `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` EncodeSpec `json:"encodeSpec"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2635e00e..fa13e8d8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -218,6 +218,11 @@ func (in *EncodeSpec) DeepCopyInto(out *EncodeSpec) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.TranscodeFfmpegArgs != nil { + in, out := &in.TranscodeFfmpegArgs, &out.TranscodeFfmpegArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } if in.MergeFfmpegArgs != nil { in, out := &in.MergeFfmpegArgs, &out.MergeFfmpegArgs *out = make([]string, len(*in)) diff --git a/cfg/config.go b/cfg/config.go index f212fd55..64f79fcc 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -1,12 +1,24 @@ package cfg // Configuration holds a strongly-typed tree of the configuration -type Configuration struct { - MetricsBindAddress string `koanf:"metrics-bindaddress"` +type ( + Configuration struct { + Operator OperatorConfig + Log LogConfig + } + OperatorConfig struct { + MetricsBindAddress string `koanf:"metrics-bind-address"` - // Enabling this will ensure there is only one active controller manager. - EnableLeaderElection bool `koanf:"enable-leader-election"` -} + // Enabling this will ensure there is only one active controller manager. + EnableLeaderElection bool `koanf:"enable-leader-election"` + } + LogConfig struct { + Debug bool `koanf:"debug"` + } + ScanConfig struct { + ClustercodePlanName string `koanf:"clustercode-plan-name"` + } +) var ( Config = NewDefaultConfig() @@ -15,8 +27,10 @@ var ( // NewDefaultConfig retrieves the config with sane defaults func NewDefaultConfig() *Configuration { return &Configuration{ - EnableLeaderElection: true, - MetricsBindAddress: ":9090", + Operator: OperatorConfig{ + MetricsBindAddress: ":9090", + EnableLeaderElection: false, + }, } } diff --git a/cmd/operate.go b/cmd/operate.go new file mode 100644 index 00000000..253ba7ca --- /dev/null +++ b/cmd/operate.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/ccremer/clustercode/cfg" + "github.com/ccremer/clustercode/controllers" +) + +// operateCmd represents the operate command +var ( + scheme = runtime.NewScheme() + operateCmd = &cobra.Command{ + Use: "operate", + Short: "Starts Clustercode in Operator mode", + RunE: startOperator, + } +) + +func init() { + rootCmd.AddCommand(operateCmd) + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + utilruntime.Must(batchv1.AddToScheme(scheme)) + // +kubebuilder:scaffold:scheme + + operateCmd.PersistentFlags().String("operator.metrics-bind-address", cfg.Config.Operator.MetricsBindAddress, "Prometheus metrics bind address") +} + +func startOperator(cmd *cobra.Command, args []string) error { + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + MetricsBindAddress: cfg.Config.Operator.MetricsBindAddress, + Port: 9443, + LeaderElection: cfg.Config.Operator.EnableLeaderElection, + LeaderElectionID: "clustercode.github.io", + }) + if err != nil { + return fmt.Errorf("unable to start operator: %w", err) + } + + if err = (&controllers.ClustercodePlanReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("clustercodeplan"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create controller '%s': %w", "clustercodeplan", err) + } + // +kubebuilder:scaffold:builder + + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running operator") + return err + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..999e7e85 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/knadh/koanf" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/posflag" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "go.uber.org/zap/zapcore" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/ccremer/clustercode/cfg" +) + +// rootCmd represents the base command when called without any subcommands +var ( + rootCmd = &cobra.Command{ + Use: "clustercode", + Short: "clustercode brings media encoding into Kubernetes", + Long: `clustercode scans media volumes for media files, splits them up into multiple segment and encodes them in parallel.`, + PersistentPreRunE: parseConfig, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, + } + + setupLog = ctrl.Log.WithName("setup") + // Global koanfInstance instance. Use . as the key path delimiter. + koanfInstance = koanf.New(".") + version = "undefined" +) + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func initRootConfig() { + if err := bindFlags(rootCmd.Flags()); err !=nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().BoolP("log.debug", "v", cfg.Config.Log.Debug, "Enable debug log") + cobra.OnInitialize(initRootConfig) +} + +// parseConfig reads the flags and ENV vars +func parseConfig(cmd *cobra.Command, args []string) error { + if err := loadEnvironmentVariables(); err != nil { + return err + } + if err := bindFlags(cmd.PersistentFlags()); err != nil { + return err + } + if err := koanfInstance.Unmarshal("", &cfg.Config); err != nil { + return fmt.Errorf("could not read config: %w", err) + } + + level := zapcore.InfoLevel + if cfg.Config.Log.Debug { + level = zapcore.DebugLevel + } + ctrl.SetLogger(zap.New(zap.UseDevMode(true), zap.Level(level))) + setupLog.Info("Starting Clustercode", "version", version, "command", cmd.Name()) + setupLog.V(1).Info("using config", "config", cfg.Config) + return cfg.Config.ValidateSyntax() +} + +func loadEnvironmentVariables() error { + prefix := "CC_" + return koanfInstance.Load(env.Provider(prefix, ".", func(s string) string { + /* + Configuration can contain hierarchies (YAML, etc.) and CLI flags dashes. To read environment variables with + hierarchies and dashes we replace the hierarchy delimiter with double underscore and dashes with single underscore, + so that parent.child-with-dash becomes PARENT__CHILD_WITH_DASH + */ + s = strings.TrimPrefix(s, prefix) + s = strings.Replace(strings.ToLower(s), "__", ".", -1) + s = strings.Replace(strings.ToLower(s), "_", "-", -1) + return s + }), nil) +} + +func bindFlags(flagSet *pflag.FlagSet) error { + return koanfInstance.Load(posflag.Provider(flagSet, ".", koanfInstance), nil) +} + +// SetVersion sets the version string in the help messages +func SetVersion(v string) { + // We need to set both properties in order to break an initialization loop + rootCmd.Version = v + version = v +} diff --git a/cmd/scan.go b/cmd/scan.go new file mode 100644 index 00000000..36d41547 --- /dev/null +++ b/cmd/scan.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// scanCmd represents the scan command +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "A brief description of your command", + Long: `A longer description that spans multiple lines and likely contains examples +and usage of using your command. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("scan called") + }, +} + +func init() { + rootCmd.AddCommand(scanCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // scanCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // scanCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index 012567a4..dddaeb5f 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -54,6 +54,8 @@ spec: items: type: string type: array + sliceSize: + type: integer splitFfmpegArgs: default: - -i @@ -70,18 +72,29 @@ spec: items: type: string type: array + transcodeArgs: + default: + - -i + - '"${INPUT}"' + - -c:v + - copy + - -c:a + - copy + - '"${OUTPUT}"' + items: + type: string + type: array required: - defaultFfmpegArgs - mergeFfmpegArgs - splitFfmpegArgs + - transcodeArgs type: object maxParallelTasks: default: 1 type: integer - scanIntervalMinutes: - default: 1 - format: int64 - type: integer + scanSchedule: + type: string scanSpec: properties: mediaFileExtensions: @@ -999,6 +1012,8 @@ spec: type: string suspend: type: boolean + required: + - scanSchedule type: object status: properties: diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 6ce23b40..e365dfea 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -54,6 +54,8 @@ spec: items: type: string type: array + sliceSize: + type: integer splitFfmpegArgs: default: - -i @@ -70,10 +72,23 @@ spec: items: type: string type: array + transcodeArgs: + default: + - -i + - '"${INPUT}"' + - -c:v + - copy + - -c:a + - copy + - '"${OUTPUT}"' + items: + type: string + type: array required: - defaultFfmpegArgs - mergeFfmpegArgs - splitFfmpegArgs + - transcodeArgs type: object sourceUrl: type: string diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index a5336635..c98a339b 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -35,11 +35,11 @@ func (r *ClustercodePlanReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -// +kubebuilder:rbac:groups=clustercode.github.io,resources=encodingplans,verbs=get;list;watch;create;update;patch;delete -// +kubebuilder:rbac:groups=clustercode.github.io,resources=encodingplans/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodeplans,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodeplans/status,verbs=get;update;patch func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, returnErr error) { rc := ReconciliationContext{ - ctx: ctx, + ctx: ctx, plan: &v1alpha1.ClustercodePlan{}, } err := r.Client.Get(ctx, req.NamespacedName, rc.plan) @@ -51,5 +51,6 @@ func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Requ r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } + return ctrl.Result{}, nil } diff --git a/generate.go b/generate.go index 5e54a3fa..fca6ffff 100644 --- a/generate.go +++ b/generate.go @@ -6,6 +6,6 @@ package main //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:trivialVersions=true rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=${CRD_ROOT_DIR} crd:crdVersions=v1 import ( - _ "sigs.k8s.io/controller-tools" + _ "sigs.k8s.io/controller-tools/cmd/controller-gen" _ "sigs.k8s.io/kustomize/kustomize/v3" ) diff --git a/go.mod b/go.mod index c51419c8..8f2b0708 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.15 require ( github.com/go-logr/logr v0.3.0 github.com/knadh/koanf v0.14.0 + github.com/spf13/cobra v1.1.1 + github.com/spf13/pflag v1.0.5 + go.uber.org/zap v1.15.0 k8s.io/api v0.19.6 k8s.io/apimachinery v0.19.6 k8s.io/client-go v0.19.6 diff --git a/go.sum b/go.sum index 49adba92..8b43e4e5 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ cloud.google.com/go v0.51.0 h1:PvKAVQWCtlGUSlZkGW3QLelKaWq7KYv/MW1EboG8bfM= cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -26,6 +27,7 @@ github.com/Azure/go-autorest/autorest/date v0.2.0 h1:yW+Zlqf26583pE43KhfnhFcdmSW github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0 h1:qJumjCaCudz+OcqE9/XtEPfvtOjOmKaui4EOpFI6zZc= github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= github.com/Azure/go-autorest/logger v0.1.0 h1:ruG4BSDXONFRrZZJ2GUXDiUyVpayPmb1GnWeHDdaNKY= github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= @@ -53,7 +55,10 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= @@ -65,6 +70,7 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bombsimon/wsl v1.2.5/go.mod h1:43lEF/i0kpXbLCeDXL9LMT8c92HyBywXb0AsgMHYngM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -79,6 +85,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -131,6 +138,7 @@ github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0 github.com/go-critic/go-critic v0.3.5-0.20190904082202-d79a9f0c64db/go.mod h1:+sE8vrLDS2M0pZkBk0wy6+nLdKexVDrl/jBqQOTDThA= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -296,8 +304,10 @@ github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3i github.com/googleapis/gnostic v0.5.1 h1:A8Yhf6EtqTv9RMsU6MQTyrtV1TjWlR6xU9BsZIwuTCM= github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -305,27 +315,45 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0 h1:wvCrVc9TjDls6+YGAF2hAifE1E5U1+b4tH6KdvN3Gig= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.1.0 h1:bPIoEKD27tNdebFGGxxYwcL4nepeY4j1QP23PFRGzg0= github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/imdario/mergo v0.3.10 h1:6q5mVkdH/vYmqngx7kZQTjJ5HRsx+ImorDIEQ+beJgc= github.com/imdario/mergo v0.3.10/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= @@ -338,6 +366,7 @@ github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= @@ -359,10 +388,13 @@ github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -377,6 +409,7 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -388,12 +421,17 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-ps v0.0.0-20190716172923-621e5597135b/go.mod h1:r1VsdOzOPt1ZSrGZWFoNhsAedKnEd6r9Np1+5blZCWk= github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -414,6 +452,7 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -437,6 +476,7 @@ github.com/onsi/gomega v1.10.1 h1:o0+MgICZLuZ7xjH7Vx6zS/zcu93/BEp1VwkIW1mEXCE= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.2 h1:aY/nuoWlKJud2J6U0E3NWsjlg+0GtwXxgEqthRdzlcs= github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/paulmach/orb v0.1.3/go.mod h1:VFlX/8C+IQ1p6FTRRKzKoOPJnvEtA5G0Veuqwbu//Vk= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -450,6 +490,7 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= @@ -482,8 +523,11 @@ github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6So github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/securego/gosec v0.0.0-20191002120514-e680875ea14d/go.mod h1:w5+eXa0mYznDkHaMCXA4XYffjlH+cy1oyKbfzJXa2Do= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v0.0.0-20190901111213-e4ec7b275ada/go.mod h1:WWnYX4lzhCH5h/3YBfyVA3VbLYjlMZZAQcW9ojMexNc= github.com/shirou/w32 v0.0.0-20160930032740-bb4de0191aa4/go.mod h1:qsXQc7+bwAM3Q1u/4XEfrquwF8Lw7D7y5cD8CuHnfIc= @@ -493,16 +537,23 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sourcegraph/go-diff v0.5.1/go.mod h1:j2dHj3m8aZgQO8lMTcTnBcXkRRRqi34cd2MNlA9u1mE= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= +github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= @@ -511,9 +562,12 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= @@ -524,6 +578,9 @@ github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/timakin/bodyclose v0.0.0-20190930140734-f7f2e9bca95e/go.mod h1:Qimiffbc6q9tBWlVV6x0P9sat/ao1xEkREYPPj9hphk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -582,6 +639,7 @@ go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -601,6 +659,7 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -630,7 +689,9 @@ golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -668,9 +729,11 @@ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -734,6 +797,7 @@ golang.org/x/tools v0.0.0-20190311215038-5c2858a9cfe5/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190322203728-c1a832b0ad89/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190521203540-521d6ed310dd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -755,6 +819,7 @@ golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= @@ -774,6 +839,7 @@ google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEt google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -792,6 +858,7 @@ google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRn google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= @@ -818,12 +885,15 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= @@ -911,13 +981,10 @@ sigs.k8s.io/controller-runtime v0.7.0 h1:bU20IBBEPccWz5+zXpLnpVsgBYxqclaHu1pVDl/ sigs.k8s.io/controller-runtime v0.7.0/go.mod h1:pJ3YBrJiAqMAZKi6UVGuE98ZrroV1p+pIhoHsMm9wdU= sigs.k8s.io/controller-tools v0.4.1 h1:VkuV0MxlRPmRu5iTgBZU4UxUX2LiR99n3sdQGRxZF4w= sigs.k8s.io/controller-tools v0.4.1/go.mod h1:G9rHdZMVlBDocIxGkK3jHLWqcTMNvveypYJwrvYKjWU= -sigs.k8s.io/kustomize v1.0.11 h1:Yb+6DDt9+aR2AvQApvUaKS/ugteeG4MPyoFeUHiPOjk= -sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0= sigs.k8s.io/kustomize/api v0.7.0 h1:djxH9k1izeU1BvdP1i23qqKwhmWu2BuKNEKr/Da7Dpw= sigs.k8s.io/kustomize/api v0.7.0/go.mod h1:3TxKEyaxwOIfHmRbQF14hDUSRmVQI0iSn8qDA5zaO/0= sigs.k8s.io/kustomize/cmd/config v0.8.6 h1:Rr7eyD+h32OfruN6V+cgUqHRpC2Y5ZnjjAPbjhKFLGE= sigs.k8s.io/kustomize/cmd/config v0.8.6/go.mod h1:e4PgdLUNnkf+Iapvjyb6gTG9DZQkDZIR6uS1Bv4YA6s= -sigs.k8s.io/kustomize/kustomize v0.0.0-20191024000301-ce7ebe3299dd h1:naYNcVnc1wjElh8I9o/NP2oqSlYCJJERusQrC3WGcBQ= sigs.k8s.io/kustomize/kustomize/v3 v3.9.0 h1:U2B1nfr4Dvz4aMiO9DJCi1dODSiB46vtHT0a0BaG5/0= sigs.k8s.io/kustomize/kustomize/v3 v3.9.0/go.mod h1:IY5mZn2ehVHpWr5vpSo1MkZ7oZVQGZdN1sH2GMU5bfQ= sigs.k8s.io/kustomize/kyaml v0.10.3 h1:ARSJUMN/c3k31DYxRfZ+vp/UepUQjg9zCwny7Oj908I= diff --git a/main.go b/main.go index fbb07344..720756fe 100644 --- a/main.go +++ b/main.go @@ -1,20 +1,9 @@ package main import ( - "os" - "strings" + "fmt" - "github.com/knadh/koanf" - "github.com/knadh/koanf/providers/env" - batchv1 "k8s.io/api/batch/v1" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - "github.com/ccremer/clustercode/cfg" - "github.com/ccremer/clustercode/controllers" + "github.com/ccremer/clustercode/cmd" ) var ( @@ -22,72 +11,11 @@ var ( version string commit string date string - - scheme = runtime.NewScheme() - setupLog = ctrl.Log.WithName("setup") - // Global koanfInstance instance. Use . as the key path delimiter. - koanfInstance = koanf.New(".") ) -func init() { - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - - utilruntime.Must(batchv1.AddToScheme(scheme)) - // +kubebuilder:scaffold:scheme -} - func main() { - ctrl.SetLogger(zap.New(zap.UseDevMode(true))) - - loadEnvironmentVariables() - - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, - MetricsBindAddress: cfg.Config.MetricsBindAddress, - Port: 9443, - LeaderElection: cfg.Config.EnableLeaderElection, - LeaderElectionID: "clustercode.github.io", - }) - if err != nil { - setupLog.Error(err, "unable to start operator") - os.Exit(1) - } - - if err = (&controllers.ClustercodePlanReconciler{ - Client: mgr.GetClient(), - Log: ctrl.Log.WithName("controllers").WithName("clustercodeplan"), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "clustercodeplan") - os.Exit(1) - } - // +kubebuilder:scaffold:builder - - setupLog.WithValues("version", version, "date", date, "commit", commit).Info("Starting operator") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - setupLog.Error(err, "problem running operator") - os.Exit(1) - } -} - -func loadEnvironmentVariables() { - prefix := "CC_" - // Load environment variables - err := koanfInstance.Load(env.Provider(prefix, ".", func(s string) string { - s = strings.TrimLeft(s, prefix) - s = strings.Replace(strings.ToLower(s), "_", "-", -1) - return s - }), nil) - if err != nil { - setupLog.Error(err, "could not load environment variables") - } + cmd.SetVersion(fmt.Sprintf("%s, commit %s, date %s", version, commit, date)) + cmd.Execute() - if err := koanfInstance.UnmarshalWithConf("", &cfg.Config, koanf.UnmarshalConf{Tag: "koanf", FlatPaths: true}); err != nil { - setupLog.Error(err, "could not merge defaults with settings from environment variables") - } - if err := cfg.Config.ValidateSyntax(); err != nil { - setupLog.Error(err, "settings invalid") - os.Exit(2) - } } From 6c28f412b2898196ad2d5578fe29ba0650930938 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 23 Dec 2020 18:46:57 +0100 Subject: [PATCH 05/13] WIP 1 --- Dockerfile | 2 +- Makefile | 3 +- api/v1alpha1/clustercodeplan_types.go | 47 +++--- api/v1alpha1/clustercodetask_types.go | 25 +-- api/v1alpha1/zz_generated.deepcopy.go | 16 +- cfg/config.go | 11 +- cmd/operate.go | 10 +- cmd/root.go | 2 +- cmd/scan.go | 67 +++++--- cmd/util.go | 21 +++ ...lustercode.github.io_clustercodeplans.yaml | 2 +- ...lustercode.github.io_clustercodetasks.yaml | 2 +- config/manager/manager.yaml | 9 +- config/rbac/clustercodeplan_editor_role.yaml | 26 +++ config/rbac/kustomization.yaml | 1 + config/rbac/role.yaml | 55 +++++- controllers/clustercodeplan_controller.go | 157 +++++++++++++++++- e2e/test1.bats | 4 +- e2e/test1/deployment.yaml | 9 +- e2e/test1/plan.yaml | 10 ++ generate.go | 2 +- go.mod | 3 +- go.sum | 8 +- 23 files changed, 395 insertions(+), 97 deletions(-) create mode 100644 cmd/util.go create mode 100644 config/rbac/clustercodeplan_editor_role.yaml create mode 100644 e2e/test1/plan.yaml diff --git a/Dockerfile b/Dockerfile index 8bd932b4..6b5d7bdd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM docker.io/library/alpine:3.12 as runtime ENTRYPOINT ["clustercode"] RUN \ - apk add --no-cache curl bash tzdata + apk add --no-cache curl bash COPY clustercode /usr/bin/ USER 1001:0 diff --git a/Makefile b/Makefile index eaf327b4..0680101e 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ ENABLE_LEADER_ELECTION ?= false # Image URL to use all building/pushing image targets DOCKER_IMG ?= docker.io/ccremer/clustercode:$(IMG_TAG) QUAY_IMG ?= quay.io/ccremer/clustercode:$(IMG_TAG) -E2E_IMG ?= localhost:$(KIND_REGISTRY_PORT)/ccremer/clustercode:e2e +E2E_IMG ?= localhost:$(KIND_REGISTRY_PORT)/clustercode/operator:e2e build_cmd ?= CGO_ENABLED=0 go build -o $(BIN_FILENAME) main.go @@ -111,6 +111,7 @@ lint: fmt vet ## Invokes the fmt and vet targets git diff --exit-code # Build the binary without running generators +.PHONY: $(BIN_FILENAME) $(BIN_FILENAME): $(build_cmd) diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 919f8444..3f802fe4 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -6,6 +6,27 @@ import ( ) type ( + // +kubebuilder:object:root=true + // +kubebuilder:subresource:status + + // ClustercodePlan is the Schema for the ClusterCodePlan API + ClustercodePlan struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ClustercodePlanSpec `json:"spec,omitempty"` + Status ClustercodePlanStatus `json:"status,omitempty"` + } + + // +kubebuilder:object:root=true + + // ClustercodePlanList contains a list of ClustercodePlans. + ClustercodePlanList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ClustercodePlan `json:"items"` + } + // ClustercodePlanSpec specifies a Clustercode ClustercodePlanSpec struct { ScanSchedule string `json:"scanSchedule"` @@ -30,35 +51,17 @@ type ( EncodeSpec struct { // +kubebuilder:default=-y;-hide_banner;-nostats - DefaultFfmpegArgs []string `json:"defaultFfmpegArgs"` + DefaultCommandArgs []string `json:"defaultFfmpegArgs"` // +kubebuilder:default=-i;"\"${INPUT}\"";-c;copy;-map;0;-segment_time;"\"${SLICE_SIZE}\"";-f;segment;"\"${OUTPUT}\"" - SplitFfmpegArgs []string `json:"splitFfmpegArgs"` + SplitCommandArgs []string `json:"splitFfmpegArgs"` // +kubebuilder:default=-i;"\"${INPUT}\"";"-c:v";copy;"-c:a";copy;"\"${OUTPUT}\"" - TranscodeFfmpegArgs []string `json:"transcodeArgs"` + TranscodeCommandArgs []string `json:"transcodeArgs"` // +kubebuilder:default=-f;concat;-i;concat.txt;-c;copy;media_out.mkv - MergeFfmpegArgs []string `json:"mergeFfmpegArgs"` + MergeCommandArgs []string `json:"mergeFfmpegArgs"` SliceSize int `json:"sliceSize,omitempty"` } - // ClustercodePlan is the Schema for the archives API - // +kubebuilder:object:root=true - // +kubebuilder:subresource:status - ClustercodePlan struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ClustercodePlanSpec `json:"spec,omitempty"` - Status ClustercodePlanStatus `json:"status,omitempty"` - } - - // ClustercodePlanList contains a list of Archive - // +kubebuilder:object:root=true - ClustercodePlanList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []ClustercodePlan `json:"items"` - } ClustercodePlanStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` } diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index 136dd61d..d7dfc8d7 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -5,18 +5,10 @@ import ( ) type ( - - // EncodingTaskSpec defines the desired state of Archive. - ClustercodeTaskSpec struct { - SourceUrl string `json:"sourceUrl,omitempty"` - TargetUrl string `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec `json:"encodeSpec"` - } - - // ClustercodePlan is the Schema for the archives API // +kubebuilder:object:root=true // +kubebuilder:subresource:status + + // ClustercodePlan is the Schema for the archives API ClustercodeTask struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` @@ -24,13 +16,24 @@ type ( Spec ClustercodeTaskSpec `json:"spec,omitempty"` Status ClustercodeTaskStatus `json:"status,omitempty"` } - // ClustercodeTaskList contains a list of Archive + // +kubebuilder:object:root=true + + // ClustercodeTaskList contains a list of ClusterCodeTasks ClustercodeTaskList struct { metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata,omitempty"` Items []ClustercodeTask `json:"items"` } + + // EncodingTaskSpec defines the desired state of ClustercodeTask. + ClustercodeTaskSpec struct { + SourceUrl string `json:"sourceUrl,omitempty"` + TargetUrl string `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec `json:"encodeSpec"` + } + ClustercodeTaskStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` SourceMediaFileName string `json:"sourceMediaFileName,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index fa13e8d8..997fbcb7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -208,23 +208,23 @@ func (in *ClustercodeTaskStatus) DeepCopy() *ClustercodeTaskStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EncodeSpec) DeepCopyInto(out *EncodeSpec) { *out = *in - if in.DefaultFfmpegArgs != nil { - in, out := &in.DefaultFfmpegArgs, &out.DefaultFfmpegArgs + if in.DefaultCommandArgs != nil { + in, out := &in.DefaultCommandArgs, &out.DefaultCommandArgs *out = make([]string, len(*in)) copy(*out, *in) } - if in.SplitFfmpegArgs != nil { - in, out := &in.SplitFfmpegArgs, &out.SplitFfmpegArgs + if in.SplitCommandArgs != nil { + in, out := &in.SplitCommandArgs, &out.SplitCommandArgs *out = make([]string, len(*in)) copy(*out, *in) } - if in.TranscodeFfmpegArgs != nil { - in, out := &in.TranscodeFfmpegArgs, &out.TranscodeFfmpegArgs + if in.TranscodeCommandArgs != nil { + in, out := &in.TranscodeCommandArgs, &out.TranscodeCommandArgs *out = make([]string, len(*in)) copy(*out, *in) } - if in.MergeFfmpegArgs != nil { - in, out := &in.MergeFfmpegArgs, &out.MergeFfmpegArgs + if in.MergeCommandArgs != nil { + in, out := &in.MergeCommandArgs, &out.MergeCommandArgs *out = make([]string, len(*in)) copy(*out, *in) } diff --git a/cfg/config.go b/cfg/config.go index 64f79fcc..22c95c77 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -4,6 +4,7 @@ package cfg type ( Configuration struct { Operator OperatorConfig + Scan ScanConfig Log LogConfig } OperatorConfig struct { @@ -16,7 +17,10 @@ type ( Debug bool `koanf:"debug"` } ScanConfig struct { + ClusterRoleName string `koanf:"cluster-role-name"` ClustercodePlanName string `koanf:"clustercode-plan-name"` + Namespace string `koanf:"namespace"` + SourceRoot string `koanf:"source-root"` } ) @@ -28,8 +32,11 @@ var ( func NewDefaultConfig() *Configuration { return &Configuration{ Operator: OperatorConfig{ - MetricsBindAddress: ":9090", - EnableLeaderElection: false, + MetricsBindAddress: ":9090", + }, + Scan: ScanConfig{ + SourceRoot: "/clustercode", + ClusterRoleName: "clustercode-clustercodeplan-editor-role", }, } } diff --git a/cmd/operate.go b/cmd/operate.go index 253ba7ca..bbb674b6 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -4,10 +4,6 @@ import ( "fmt" "github.com/spf13/cobra" - batchv1 "k8s.io/api/batch/v1" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "github.com/ccremer/clustercode/cfg" @@ -16,7 +12,6 @@ import ( // operateCmd represents the operate command var ( - scheme = runtime.NewScheme() operateCmd = &cobra.Command{ Use: "operate", Short: "Starts Clustercode in Operator mode", @@ -26,9 +21,6 @@ var ( func init() { rootCmd.AddCommand(operateCmd) - utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - - utilruntime.Must(batchv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme operateCmd.PersistentFlags().String("operator.metrics-bind-address", cfg.Config.Operator.MetricsBindAddress, "Prometheus metrics bind address") @@ -36,6 +28,7 @@ func init() { func startOperator(cmd *cobra.Command, args []string) error { + registerScheme() mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, MetricsBindAddress: cfg.Config.Operator.MetricsBindAddress, @@ -62,3 +55,4 @@ func startOperator(cmd *cobra.Command, args []string) error { } return nil } + diff --git a/cmd/root.go b/cmd/root.go index 999e7e85..787d421a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -45,7 +45,7 @@ func Execute() { } func initRootConfig() { - if err := bindFlags(rootCmd.Flags()); err !=nil { + if err := bindFlags(rootCmd.Flags()); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/cmd/scan.go b/cmd/scan.go index 36d41547..3d6fad72 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -1,36 +1,63 @@ package cmd import ( + "context" "fmt" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + controllerclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" ) // scanCmd represents the scan command -var scanCmd = &cobra.Command{ - Use: "scan", - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("scan called") - }, +var ( + scanCmd = &cobra.Command{ + Use: "scan", + Short: "A brief description of your command", + PreRunE: validateScanCmd, + RunE: scanMedia, + } + scanLog = ctrl.Log.WithName("scan") +) + +func validateScanCmd(cmd *cobra.Command, args []string) error { + if cfg.Config.Scan.ClustercodePlanName == "" { + return fmt.Errorf("'%s' cannot be empty", "scan.clustercode-plan-name") + } + if cfg.Config.Scan.Namespace == "" { + return fmt.Errorf("'%s' cannot be empty", "scan.namespace") + } + return nil } func init() { rootCmd.AddCommand(scanCmd) - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // scanCmd.PersistentFlags().String("foo", "", "A help for foo") + scanCmd.PersistentFlags().String("scan.clustercode-plan-name", cfg.Config.Scan.ClustercodePlanName, "Clustercode Plan name (namespace/name)") + scanCmd.PersistentFlags().StringP("scan.namespace", "n", cfg.Config.Scan.Namespace, "Namespace") +} - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // scanCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +func scanMedia(cmd *cobra.Command, args []string) error { + + registerScheme() + client, err := controllerclient.New(ctrl.GetConfigOrDie(), controllerclient.Options{Scheme: scheme}) + if err != nil { + return err + } + ctx := context.Background() + plan := v1alpha1.ClustercodePlan{} + name := types.NamespacedName{ + Name: cfg.Config.Scan.ClustercodePlanName, + Namespace: cfg.Config.Scan.Namespace, + } + err = client.Get(ctx, name, &plan) + if err != nil { + return err + } + scanLog.Info("found plan", "plan", plan) + return nil } diff --git a/cmd/util.go b/cmd/util.go new file mode 100644 index 00000000..9ef78f95 --- /dev/null +++ b/cmd/util.go @@ -0,0 +1,21 @@ +package cmd + +import ( + batchv1 "k8s.io/api/batch/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + "github.com/ccremer/clustercode/api/v1alpha1" +) + +var ( + scheme = runtime.NewScheme() +) + +func registerScheme() { + + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(batchv1.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) +} diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index dddaeb5f..dc22f259 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -19,7 +19,7 @@ spec: - name: v1alpha1 schema: openAPIV3Schema: - description: ClustercodePlan is the Schema for the archives API + description: ClustercodePlan is the Schema for the ClusterCodePlan API properties: apiVersion: description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index e365dfea..276b3e86 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -30,7 +30,7 @@ spec: metadata: type: object spec: - description: EncodingTaskSpec defines the desired state of Archive. + description: EncodingTaskSpec defines the desired state of ClustercodeTask. properties: encodeSpec: properties: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 4d448437..c0c93ac4 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -23,10 +23,5 @@ spec: containers: - name: clustercode image: quay.io/ccremer/clustercode:latest - resources: - limits: - cpu: 300m - memory: 100Mi - requests: - cpu: 100m - memory: 20Mi + args: + - operate diff --git a/config/rbac/clustercodeplan_editor_role.yaml b/config/rbac/clustercodeplan_editor_role.yaml new file mode 100644 index 00000000..7e585c9e --- /dev/null +++ b/config/rbac/clustercodeplan_editor_role.yaml @@ -0,0 +1,26 @@ +# permissions for end users to edit clustercodeplans. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: clustercodeplan-editor-role +rules: + - apiGroups: + - clustercode.github.io + resources: + - clustercodeplans + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + - apiGroups: + - clustercode.github.io + resources: + - clustercodeplans/status + verbs: + - get + - patch + - update diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index c887f9f6..5350d57e 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -3,3 +3,4 @@ resources: - role_binding.yaml - leader_election_role.yaml - leader_election_role_binding.yaml +- clustercodeplan_editor_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 12751da0..f6d071fb 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -6,10 +6,42 @@ metadata: creationTimestamp: null name: manager-role rules: +- apiGroups: + - batch + resources: + - cronjobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - cronjobs/status + verbs: + - get + - patch + - update +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - clustercode.github.io resources: - - encodingplans + - clustercodeplans verbs: - create - delete @@ -21,8 +53,27 @@ rules: - apiGroups: - clustercode.github.io resources: - - encodingplans/status + - clustercodeplans/status verbs: - get - patch - update +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - delete + - get + - list +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + - roles + verbs: + - create + - delete + - get + - list diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index c98a339b..a1b38077 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -5,13 +5,21 @@ import ( "time" "github.com/go-logr/logr" - "k8s.io/apimachinery/pkg/api/errors" + batchv1 "k8s.io/api/batch/v1" + "k8s.io/api/batch/v1beta1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" ) type ( @@ -25,6 +33,7 @@ type ( ReconciliationContext struct { ctx context.Context plan *v1alpha1.ClustercodePlan + log logr.Logger } ) @@ -37,20 +46,160 @@ func (r *ClustercodePlanReconciler) SetupWithManager(mgr ctrl.Manager) error { // +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodeplans,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodeplans/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=batch,resources=cronjobs/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;create;delete +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;create;delete + func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, returnErr error) { - rc := ReconciliationContext{ + rc := &ReconciliationContext{ ctx: ctx, plan: &v1alpha1.ClustercodePlan{}, } err := r.Client.Get(ctx, req.NamespacedName, rc.plan) if err != nil { - if errors.IsNotFound(err) { + if apierrors.IsNotFound(err) { r.Log.Info("object not found, ignoring reconcile", "object", req.NamespacedName) return ctrl.Result{}, nil } r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } - + rc.log = r.Log.WithValues("plan", req.NamespacedName) + r.handlePlan(rc) return ctrl.Result{}, nil } + +func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { + + saName, err := r.createServiceAccountAndBinding(rc) + if err != nil { + rc.log.Error(err, "cannot ensure that scanner job have necessary RBAC permissions") + } + + cronJob := v1beta1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: rc.plan.Name + "-scan-job", + Namespace: rc.plan.Namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "clustercode", + }, + }, + Spec: v1beta1.CronJobSpec{ + Schedule: rc.plan.Spec.ScanSchedule, + ConcurrencyPolicy: v1beta1.ForbidConcurrent, + Suspend: &rc.plan.Spec.Suspend, + + JobTemplate: v1beta1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + BackoffLimit: pointer.Int32Ptr(1), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: saName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "scanner", + Env: []corev1.EnvVar{ + { + Name: "CC_LOG__DEBUG", + Value: "true", + }, + }, + Args: []string{ + "scan", + "--scan.namespace=" + rc.plan.Namespace, + "--scan.clustercode-plan-name=" + rc.plan.Name, + }, + Image: "localhost:5000/clustercode/operator:e2e", + }, + }, + }, + }, + }, + }, + SuccessfulJobsHistoryLimit: pointer.Int32Ptr(1), + FailedJobsHistoryLimit: pointer.Int32Ptr(1), + }, + } + if err := controllerutil.SetControllerReference(rc.plan, cronJob.GetObjectMeta(), r.Scheme); err != nil { + rc.log.Error(err, "could not set controller reference, deleting the plan will not delete the cronjob", "cronjob", cronJob.Name) + } + + if err := r.Client.Create(rc.ctx, &cronJob); err != nil { + if apierrors.IsAlreadyExists(err) { + rc.log.Info("cronjob already exists, updating it") + err = r.Client.Update(rc.ctx, &cronJob) + if err != nil { + rc.log.Error(err, "could not update cronjob") + } + return + } + if !apierrors.IsNotFound(err) { + rc.log.Error(err, "could not create cronjob") + return + } + } else { + rc.log.Info("created cronjob") + } +} + +func (r *ClustercodePlanReconciler) createServiceAccountAndBinding(rc *ReconciliationContext) (string, error) { + binding, sa := r.newRbacDefinition(rc) + + err := r.Client.Create(rc.ctx, &sa) + if err != nil { + if !apierrors.IsAlreadyExists(err) { + return sa.Name, err + } + } else { + rc.log.Info("service account created", "sa", sa.Name) + } + err = r.Client.Create(rc.ctx, &binding) + if err != nil { + if !apierrors.IsAlreadyExists(err) { + return sa.Name, err + } + } else { + rc.log.Info("rolebinding created", "roleBinding", binding.Name) + } + return sa.Name, nil +} + +func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ReconciliationContext) (rbacv1.RoleBinding, corev1.ServiceAccount) { + saName := rc.plan.Name + "-clustercode" + roleBinding := rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: saName + "-rolebinding", + Namespace: rc.plan.Namespace, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Namespace: rc.plan.Namespace, + Name: saName, + }, + }, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + Name: cfg.Config.Scan.ClusterRoleName, + APIGroup: rbacv1.GroupName, + }, + } + + account := corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: saName, + Namespace: rc.plan.Namespace, + }, + } + + if err := controllerutil.SetControllerReference(rc.plan, roleBinding.GetObjectMeta(), r.Scheme); err != nil { + rc.log.Error(err, "could not set controller reference on role", "roleBinding", roleBinding.Name) + } + if err := controllerutil.SetControllerReference(rc.plan, account.GetObjectMeta(), r.Scheme); err != nil { + rc.log.Error(err, "could not set controller reference on service account", "sa", account.Name) + } + return roleBinding, account +} diff --git a/e2e/test1.bats b/e2e/test1.bats index 44fbb17e..87d8f74a 100644 --- a/e2e/test1.bats +++ b/e2e/test1.bats @@ -14,8 +14,10 @@ DEBUG_DETIK="true" @test "verify the deployment" { go run sigs.k8s.io/kustomize/kustomize/v3 build test1 > debug/test1.yaml + run sed -i "s/\$RANDOM/'$RANDOM'/" debug/test1.yaml + debug "$output" run kubectl apply -f debug/test1.yaml - echo "$output" + debug "$output" try "at most 20 times every 2s to find 1 pod named 'clustercode-operator' with 'status' being 'running'" diff --git a/e2e/test1/deployment.yaml b/e2e/test1/deployment.yaml index 7c2b5185..804300b6 100644 --- a/e2e/test1/deployment.yaml +++ b/e2e/test1/deployment.yaml @@ -6,7 +6,14 @@ metadata: namespace: system spec: template: + metadata: + labels: + app.kubernetes.io/version: $RANDOM spec: containers: - name: clustercode - image: localhost:5000/ccremer/clustercode:e2e + image: localhost:5000/clustercode/operator:e2e + imagePullPolicy: Always + args: + - operate + - -v diff --git a/e2e/test1/plan.yaml b/e2e/test1/plan.yaml new file mode 100644 index 00000000..156bfef2 --- /dev/null +++ b/e2e/test1/plan.yaml @@ -0,0 +1,10 @@ +apiVersion: clustercode.github.io/v1alpha1 +kind: ClustercodePlan +metadata: + name: test-plan +spec: + scanSchedule: "*/1 * * * *" + sourceVolume: + name: fuckyou + emptyDir: {} + sourceVolumeSubdir: input diff --git a/generate.go b/generate.go index fca6ffff..4c23e692 100644 --- a/generate.go +++ b/generate.go @@ -3,7 +3,7 @@ package main //go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen object:headerFile="config/boilerplate.go.txt" paths="./..." -//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:trivialVersions=true rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=${CRD_ROOT_DIR} crd:crdVersions=v1 +//go:generate go run sigs.k8s.io/controller-tools/cmd/controller-gen crd:trivialVersions=true rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=${CRD_ROOT_DIR} crd:crdVersions=v1 import ( _ "sigs.k8s.io/controller-tools/cmd/controller-gen" diff --git a/go.mod b/go.mod index 8f2b0708..43869806 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,8 @@ require ( k8s.io/api v0.19.6 k8s.io/apimachinery v0.19.6 k8s.io/client-go v0.19.6 + k8s.io/utils v0.0.0-20200912215256-4140de9c8800 sigs.k8s.io/controller-runtime v0.7.0 sigs.k8s.io/controller-tools v0.4.1 - sigs.k8s.io/kustomize/kustomize/v3 v3.9.0 + sigs.k8s.io/kustomize/kustomize/v3 v3.8.8 ) diff --git a/go.sum b/go.sum index 8b43e4e5..d442e555 100644 --- a/go.sum +++ b/go.sum @@ -981,12 +981,12 @@ sigs.k8s.io/controller-runtime v0.7.0 h1:bU20IBBEPccWz5+zXpLnpVsgBYxqclaHu1pVDl/ sigs.k8s.io/controller-runtime v0.7.0/go.mod h1:pJ3YBrJiAqMAZKi6UVGuE98ZrroV1p+pIhoHsMm9wdU= sigs.k8s.io/controller-tools v0.4.1 h1:VkuV0MxlRPmRu5iTgBZU4UxUX2LiR99n3sdQGRxZF4w= sigs.k8s.io/controller-tools v0.4.1/go.mod h1:G9rHdZMVlBDocIxGkK3jHLWqcTMNvveypYJwrvYKjWU= -sigs.k8s.io/kustomize/api v0.7.0 h1:djxH9k1izeU1BvdP1i23qqKwhmWu2BuKNEKr/Da7Dpw= -sigs.k8s.io/kustomize/api v0.7.0/go.mod h1:3TxKEyaxwOIfHmRbQF14hDUSRmVQI0iSn8qDA5zaO/0= +sigs.k8s.io/kustomize/api v0.6.7 h1:12tbj8x8S3hqus6obQrMWTqpcibf3v4iFo+hX/jIQ8g= +sigs.k8s.io/kustomize/api v0.6.7/go.mod h1:3TxKEyaxwOIfHmRbQF14hDUSRmVQI0iSn8qDA5zaO/0= sigs.k8s.io/kustomize/cmd/config v0.8.6 h1:Rr7eyD+h32OfruN6V+cgUqHRpC2Y5ZnjjAPbjhKFLGE= sigs.k8s.io/kustomize/cmd/config v0.8.6/go.mod h1:e4PgdLUNnkf+Iapvjyb6gTG9DZQkDZIR6uS1Bv4YA6s= -sigs.k8s.io/kustomize/kustomize/v3 v3.9.0 h1:U2B1nfr4Dvz4aMiO9DJCi1dODSiB46vtHT0a0BaG5/0= -sigs.k8s.io/kustomize/kustomize/v3 v3.9.0/go.mod h1:IY5mZn2ehVHpWr5vpSo1MkZ7oZVQGZdN1sH2GMU5bfQ= +sigs.k8s.io/kustomize/kustomize/v3 v3.8.8 h1:GrpJqN3ydYxX90R8zt4+hlz+iV1vtC6cAspEPxz2P9U= +sigs.k8s.io/kustomize/kustomize/v3 v3.8.8/go.mod h1:K1vE0NdKn4V34nltlT6WtRzC+d9H/1s1pPUQVpByjXQ= sigs.k8s.io/kustomize/kyaml v0.10.3 h1:ARSJUMN/c3k31DYxRfZ+vp/UepUQjg9zCwny7Oj908I= sigs.k8s.io/kustomize/kyaml v0.10.3/go.mod h1:RA+iCHA2wPCOfv6uG6TfXXWhYsHpgErq/AljxWKuxtg= sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e h1:4Z09Hglb792X0kfOBBJUPFEyvVfQWrYT/l8h5EKA6JQ= From dccb55be2edc7dbe34edbedf4ab1297033936d5a Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 23 Dec 2020 21:23:07 +0100 Subject: [PATCH 06/13] Add namespace watcher --- cfg/config.go | 1 + cmd/operate.go | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cfg/config.go b/cfg/config.go index 22c95c77..7941c6a8 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -12,6 +12,7 @@ type ( // Enabling this will ensure there is only one active controller manager. EnableLeaderElection bool `koanf:"enable-leader-election"` + WatchNamespace string `koanf:"watch-namespace"` } LogConfig struct { Debug bool `koanf:"debug"` diff --git a/cmd/operate.go b/cmd/operate.go index bbb674b6..a9325460 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -24,6 +24,8 @@ func init() { // +kubebuilder:scaffold:scheme operateCmd.PersistentFlags().String("operator.metrics-bind-address", cfg.Config.Operator.MetricsBindAddress, "Prometheus metrics bind address") + operateCmd.PersistentFlags().String("operator.watch-namespace", cfg.Config.Operator.WatchNamespace, + "Restrict watching objects to the specified namespace. Watches all namespaces if left empty") } func startOperator(cmd *cobra.Command, args []string) error { @@ -35,6 +37,7 @@ func startOperator(cmd *cobra.Command, args []string) error { Port: 9443, LeaderElection: cfg.Config.Operator.EnableLeaderElection, LeaderElectionID: "clustercode.github.io", + Namespace: cfg.Config.Operator.WatchNamespace, }) if err != nil { return fmt.Errorf("unable to start operator: %w", err) @@ -55,4 +58,3 @@ func startOperator(cmd *cobra.Command, args []string) error { } return nil } - From d3ab9627c16631c1e0c4ef1aa788859a45a41a15 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 24 Dec 2020 03:19:01 +0100 Subject: [PATCH 07/13] Add clustercode task scaffolding --- api/v1alpha1/clustercodeplan_types.go | 31 +- api/v1alpha1/clustercodetask_types.go | 18 +- api/v1alpha1/zz_generated.deepcopy.go | 21 +- cfg/config.go | 15 +- cmd/operate.go | 12 +- cmd/scan.go | 121 ++- ...lustercode.github.io_clustercodeplans.yaml | 945 +----------------- ...lustercode.github.io_clustercodetasks.yaml | 38 +- config/rbac/clustercodeplan_editor_role.yaml | 4 +- config/rbac/role.yaml | 20 + controllers/clustercodeplan_controller.go | 40 +- controllers/clustercodetask_controller.go | 66 ++ controllers/utils.go | 17 + data/source/.keep | 0 data/source/movie.mp4 | 0 e2e/kind-config.yaml | 5 + e2e/test1/kustomization.yaml | 1 + e2e/test1/plan.yaml | 10 - e2e/test2.bats | 23 + e2e/test2/kustomization.yaml | 10 + e2e/test2/plan.yaml | 44 + e2e/test2/pv.yaml | 13 + e2e/test2/pvc.yaml | 17 + 23 files changed, 494 insertions(+), 977 deletions(-) create mode 100644 controllers/clustercodetask_controller.go create mode 100644 controllers/utils.go create mode 100644 data/source/.keep create mode 100644 data/source/movie.mp4 delete mode 100644 e2e/test1/plan.yaml create mode 100644 e2e/test2.bats create mode 100644 e2e/test2/kustomization.yaml create mode 100644 e2e/test2/plan.yaml create mode 100644 e2e/test2/pv.yaml create mode 100644 e2e/test2/pvc.yaml diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 3f802fe4..0f3a6e7f 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -1,13 +1,20 @@ package v1alpha1 import ( - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func init() { + SchemeBuilder.Register(&ClustercodePlan{}, &ClustercodePlanList{}) +} + type ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status + // +kubebuilder:printcolumn:name="Schedule",type="string",JSONPath=".spec.scanSchedule",description="Cron schedule of media scans" + // +kubebuilder:printcolumn:name="Suspended",type="boolean",JSONPath=".spec.suspend",description="Whether media scanning is suspended" + // +kubebuilder:printcolumn:name="Current Tasks",type="integer",JSONPath=".status.currentTasks",description="Currently active tasks" + // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // ClustercodePlan is the Schema for the ClusterCodePlan API ClustercodePlan struct { @@ -31,7 +38,7 @@ type ( ClustercodePlanSpec struct { ScanSchedule string `json:"scanSchedule"` // +kubebuilder:validation:Required - SourceVolume corev1.Volume `json:"sourceVolume,omitempty"` + SourcePvcRef string `json:"sourcePvcRef,omitempty"` // +kubebuilder:validation:Required SourceVolumeSubdir string `json:"sourceVolumeSubdir,omitempty"` @@ -51,22 +58,28 @@ type ( EncodeSpec struct { // +kubebuilder:default=-y;-hide_banner;-nostats - DefaultCommandArgs []string `json:"defaultFfmpegArgs"` + DefaultCommandArgs []string `json:"defaultCommandArgs"` // +kubebuilder:default=-i;"\"${INPUT}\"";-c;copy;-map;0;-segment_time;"\"${SLICE_SIZE}\"";-f;segment;"\"${OUTPUT}\"" - SplitCommandArgs []string `json:"splitFfmpegArgs"` + SplitCommandArgs []string `json:"splitCommandArgs"` // +kubebuilder:default=-i;"\"${INPUT}\"";"-c:v";copy;"-c:a";copy;"\"${OUTPUT}\"" - TranscodeCommandArgs []string `json:"transcodeArgs"` + TranscodeCommandArgs []string `json:"transcodeCommandArgs"` // +kubebuilder:default=-f;concat;-i;concat.txt;-c;copy;media_out.mkv - MergeCommandArgs []string `json:"mergeFfmpegArgs"` + MergeCommandArgs []string `json:"mergeCommandArgs"` SliceSize int `json:"sliceSize,omitempty"` } ClustercodePlanStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + CurrentTasks []ClusterCodeTaskRef `json:"currentTasks,omitempty"` + } + + ClusterCodeTaskRef struct { + TaskName string `json:"taskName,omitempty"` } ) -func init() { - SchemeBuilder.Register(&ClustercodePlan{}, &ClustercodePlanList{}) +// IsMaxParallelTaskLimitReached will return true if the count of current task has reached MaxParallelTasks. +func (plan *ClustercodePlan) IsMaxParallelTaskLimitReached() bool { + return len(plan.Status.CurrentTasks) >= plan.Spec.MaxParallelTasks } diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index d7dfc8d7..4a62f95c 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -7,6 +7,10 @@ import ( type ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status + // +kubebuilder:printcolumn:name="Source",type="string",JSONPath=".spec.sourceUrl",description="Source file name" + // +kubebuilder:printcolumn:name="Target",type="string",JSONPath=".spec.targetUrl",description="Target file name" + // +kubebuilder:printcolumn:name="Plan",type="string",JSONPath=`.metadata.ownerReferences[?(@.controller)].name`,description="Clustercode Plan" + // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // ClustercodePlan is the Schema for the archives API ClustercodeTask struct { @@ -28,17 +32,15 @@ type ( // EncodingTaskSpec defines the desired state of ClustercodeTask. ClustercodeTaskSpec struct { - SourceUrl string `json:"sourceUrl,omitempty"` - TargetUrl string `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec `json:"encodeSpec"` + SourceUrl string `json:"sourceUrl,omitempty"` + TargetUrl string `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec"` } ClustercodeTaskStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` - SourceMediaFileName string `json:"sourceMediaFileName,omitempty"` - TargetMediaFileName string `json:"targetMediaFileName,omitempty"` - SliceCount int `json:"sliceCount,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + SliceCount int `json:"sliceCount,omitempty"` } ) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 997fbcb7..1d30035f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -9,6 +9,21 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterCodeTaskRef) DeepCopyInto(out *ClusterCodeTaskRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterCodeTaskRef. +func (in *ClusterCodeTaskRef) DeepCopy() *ClusterCodeTaskRef { + if in == nil { + return nil + } + out := new(ClusterCodeTaskRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodePlan) DeepCopyInto(out *ClustercodePlan) { *out = *in @@ -71,7 +86,6 @@ func (in *ClustercodePlanList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodePlanSpec) DeepCopyInto(out *ClustercodePlanSpec) { *out = *in - in.SourceVolume.DeepCopyInto(&out.SourceVolume) in.ScanSpec.DeepCopyInto(&out.ScanSpec) in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } @@ -96,6 +110,11 @@ func (in *ClustercodePlanStatus) DeepCopyInto(out *ClustercodePlanStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.CurrentTasks != nil { + in, out := &in.CurrentTasks, &out.CurrentTasks + *out = make([]ClusterCodeTaskRef, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodePlanStatus. diff --git a/cfg/config.go b/cfg/config.go index 7941c6a8..7456116a 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -11,14 +11,15 @@ type ( MetricsBindAddress string `koanf:"metrics-bind-address"` // Enabling this will ensure there is only one active controller manager. - EnableLeaderElection bool `koanf:"enable-leader-election"` - WatchNamespace string `koanf:"watch-namespace"` + EnableLeaderElection bool `koanf:"enable-leader-election"` + WatchNamespace string `koanf:"watch-namespace"` } LogConfig struct { Debug bool `koanf:"debug"` } ScanConfig struct { - ClusterRoleName string `koanf:"cluster-role-name"` + RoleKind string `koanf:"role-kind"` + RoleName string `koanf:"role-name"` ClustercodePlanName string `koanf:"clustercode-plan-name"` Namespace string `koanf:"namespace"` SourceRoot string `koanf:"source-root"` @@ -29,6 +30,11 @@ var ( Config = NewDefaultConfig() ) +const ( + ClusterRole = "ClusterRole" + Role = "Role" +) + // NewDefaultConfig retrieves the config with sane defaults func NewDefaultConfig() *Configuration { return &Configuration{ @@ -37,7 +43,8 @@ func NewDefaultConfig() *Configuration { }, Scan: ScanConfig{ SourceRoot: "/clustercode", - ClusterRoleName: "clustercode-clustercodeplan-editor-role", + RoleName: "clustercode-clustercode-editor-role", + RoleKind: "ClusterRole", }, } } diff --git a/cmd/operate.go b/cmd/operate.go index a9325460..25a0b08e 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -23,9 +23,10 @@ func init() { rootCmd.AddCommand(operateCmd) // +kubebuilder:scaffold:scheme - operateCmd.PersistentFlags().String("operator.metrics-bind-address", cfg.Config.Operator.MetricsBindAddress, "Prometheus metrics bind address") + operateCmd.PersistentFlags().String("operator.metrics-bind-address", cfg.Config.Operator.MetricsBindAddress, + "Prometheus metrics bind address.") operateCmd.PersistentFlags().String("operator.watch-namespace", cfg.Config.Operator.WatchNamespace, - "Restrict watching objects to the specified namespace. Watches all namespaces if left empty") + "Restrict watching objects to the specified namespace. Watches all namespaces if left empty.") } func startOperator(cmd *cobra.Command, args []string) error { @@ -50,6 +51,13 @@ func startOperator(cmd *cobra.Command, args []string) error { }).SetupWithManager(mgr); err != nil { return fmt.Errorf("unable to create controller '%s': %w", "clustercodeplan", err) } + if err = (&controllers.ClustercodeTaskReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("clustercodetask"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create controller '%s': %w", "clustercodetask", err) + } // +kubebuilder:scaffold:builder if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { diff --git a/cmd/scan.go b/cmd/scan.go index 3d6fad72..71a63152 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -3,14 +3,21 @@ package cmd import ( "context" "fmt" + "os" + "path/filepath" + "strings" "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/uuid" ctrl "sigs.k8s.io/controller-runtime" controllerclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/ccremer/clustercode/api/v1alpha1" "github.com/ccremer/clustercode/cfg" + "github.com/ccremer/clustercode/controllers" ) // scanCmd represents the scan command @@ -22,6 +29,8 @@ var ( RunE: scanMedia, } scanLog = ctrl.Log.WithName("scan") + // client is the K8s client for scan command + client controllerclient.Client ) func validateScanCmd(cmd *cobra.Command, args []string) error { @@ -31,6 +40,9 @@ func validateScanCmd(cmd *cobra.Command, args []string) error { if cfg.Config.Scan.Namespace == "" { return fmt.Errorf("'%s' cannot be empty", "scan.namespace") } + if !(cfg.Config.Scan.RoleKind == cfg.ClusterRole || cfg.Config.Scan.RoleKind == cfg.Role) { + return fmt.Errorf("scan.role-kind (%s) is not in %s", cfg.Config.Scan.RoleKind, []string{cfg.ClusterRole, cfg.Role}) + } return nil } @@ -44,20 +56,119 @@ func init() { func scanMedia(cmd *cobra.Command, args []string) error { registerScheme() - client, err := controllerclient.New(ctrl.GetConfigOrDie(), controllerclient.Options{Scheme: scheme}) + err := createClient() + if err != nil { + return err + } + plan, err := getClustercodePlan() + if err != nil { + return err + } + scanLog.Info("found plan", "plan", plan) + + if plan.IsMaxParallelTaskLimitReached() { + scanLog.Info("max parallel task count is reached, ignoring scan") + return nil + } + + files, err := scanSourceForMedia(plan) if err != nil { return err } + + if len(files) <= 0 { + scanLog.Info("no media files found") + return nil + } + + task := &v1alpha1.ClustercodeTask{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: cfg.Config.Scan.Namespace, + Name: string(uuid.NewUUID()), + Labels: controllers.ClusterCodeLabels, + }, + Spec: v1alpha1.ClustercodeTaskSpec{ + SourceUrl: files[0], + TargetUrl: files[0], + Suspend: false, + EncodeSpec: plan.Spec.EncodeSpec, + }, + } + if err := controllerutil.SetControllerReference(plan, task.GetObjectMeta(), scheme); err != nil { + scanLog.Error(err, "could not set controller reference. Deleting the plan might not delete this task") + } + if err := client.Create(context.Background(), task); err != nil { + return fmt.Errorf("could not create task: %w", err) + } else { + scanLog.Info("created task", "task", task.Name, "source", task.Spec.SourceUrl) + } + return nil +} + +func scanSourceForMedia(plan *v1alpha1.ClustercodePlan) (files []string, funcErr error) { + root := cfg.Config.Scan.SourceRoot + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + // could not access file, let's prevent a panic + return err + } + if info.IsDir() { + return nil + } + if !containsExtension(filepath.Ext(path), plan.Spec.ScanSpec.MediaFileExtensions) { + scanLog.V(1).Info("file extension not accepted", "path", path) + return nil + } + + files = append(files, path) + return nil + }) + + return files, err +} + +func getClustercodePlan() (*v1alpha1.ClustercodePlan, error) { ctx := context.Background() - plan := v1alpha1.ClustercodePlan{} + plan := &v1alpha1.ClustercodePlan{} name := types.NamespacedName{ - Name: cfg.Config.Scan.ClustercodePlanName, + Name: cfg.Config.Scan.ClustercodePlanName, Namespace: cfg.Config.Scan.Namespace, } - err = client.Get(ctx, name, &plan) + err := client.Get(ctx, name, plan) + if err != nil { + return &v1alpha1.ClustercodePlan{}, err + } + return plan, nil +} + +func createClient() error { + clientConfig, err := ctrl.GetConfig() + if err != nil { + return err + } + client, err = controllerclient.New(clientConfig, controllerclient.Options{Scheme: scheme}) if err != nil { return err } - scanLog.Info("found plan", "plan", plan) return nil } + +// containsExtension returns true if the given extension is in the given acceptableFileExtensions. For each entry in the list, +// the leading "." prefix is optional. The leading "." is mandatory for `extension` and it returns false if extension is empty +func containsExtension(extension string, acceptableFileExtensions []string) bool { + if extension == "" { + return false + } + for _, ext := range acceptableFileExtensions { + if strings.HasPrefix(ext, ".") { + if extension == ext { + return true + } + continue + } + if extension == "."+ext { + return true + } + } + return false +} diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index dc22f259..11b0b769 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -16,7 +16,23 @@ spec: singular: clustercodeplan scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - description: Cron schedule of media scans + jsonPath: .spec.scanSchedule + name: Schedule + type: string + - description: Whether media scanning is suspended + jsonPath: .spec.suspend + name: Suspended + type: boolean + - description: Currently active tasks + jsonPath: .status.currentTasks + name: Current Tasks + type: integer + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: ClustercodePlan is the Schema for the ClusterCodePlan API @@ -34,7 +50,7 @@ spec: properties: encodeSpec: properties: - defaultFfmpegArgs: + defaultCommandArgs: default: - -y - -hide_banner @@ -42,7 +58,7 @@ spec: items: type: string type: array - mergeFfmpegArgs: + mergeCommandArgs: default: - -f - concat @@ -56,7 +72,7 @@ spec: type: array sliceSize: type: integer - splitFfmpegArgs: + splitCommandArgs: default: - -i - '"${INPUT}"' @@ -72,7 +88,7 @@ spec: items: type: string type: array - transcodeArgs: + transcodeCommandArgs: default: - -i - '"${INPUT}"' @@ -85,10 +101,10 @@ spec: type: string type: array required: - - defaultFfmpegArgs - - mergeFfmpegArgs - - splitFfmpegArgs - - transcodeArgs + - defaultCommandArgs + - mergeCommandArgs + - splitCommandArgs + - transcodeCommandArgs type: object maxParallelTasks: default: 1 @@ -106,908 +122,8 @@ spec: type: string type: array type: object - sourceVolume: - description: Volume represents a named volume in a pod that may be accessed by any container in the pod. - properties: - awsElasticBlockStore: - description: 'AWSElasticBlockStore represents an AWS Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' - properties: - fsType: - description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore TODO: how do we prevent errors in the filesystem from compromising the machine' - type: string - partition: - description: 'The partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty).' - format: int32 - type: integer - readOnly: - description: 'Specify "true" to force and set the ReadOnly property in VolumeMounts to "true". If omitted, the default is "false". More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' - type: boolean - volumeID: - description: 'Unique ID of the persistent disk resource in AWS (Amazon EBS volume). More info: https://kubernetes.io/docs/concepts/storage/volumes#awselasticblockstore' - type: string - required: - - volumeID - type: object - azureDisk: - description: AzureDisk represents an Azure Data Disk mount on the host and bind mount to the pod. - properties: - cachingMode: - description: 'Host Caching mode: None, Read Only, Read Write.' - type: string - diskName: - description: The Name of the data disk in the blob storage - type: string - diskURI: - description: The URI the data disk in the blob storage - type: string - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. - type: string - kind: - description: 'Expected values Shared: multiple blob disks per storage account Dedicated: single blob disk per storage account Managed: azure managed data disk (only in managed availability set). defaults to shared' - type: string - readOnly: - description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. - type: boolean - required: - - diskName - - diskURI - type: object - azureFile: - description: AzureFile represents an Azure File Service mount on the host and bind mount to the pod. - properties: - readOnly: - description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. - type: boolean - secretName: - description: the name of secret that contains Azure Storage Account Name and Key - type: string - shareName: - description: Share Name - type: string - required: - - secretName - - shareName - type: object - cephfs: - description: CephFS represents a Ceph FS mount on the host that shares a pod's lifetime - properties: - monitors: - description: 'Required: Monitors is a collection of Ceph monitors More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' - items: - type: string - type: array - path: - description: 'Optional: Used as the mounted root, rather than the full Ceph tree, default is /' - type: string - readOnly: - description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' - type: boolean - secretFile: - description: 'Optional: SecretFile is the path to key ring for User, default is /etc/ceph/user.secret More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' - type: string - secretRef: - description: 'Optional: SecretRef is reference to the authentication secret for User, default is empty. More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - user: - description: 'Optional: User is the rados user name, default is admin More info: https://examples.k8s.io/volumes/cephfs/README.md#how-to-use-it' - type: string - required: - - monitors - type: object - cinder: - description: 'Cinder represents a cinder volume attached and mounted on kubelets host machine. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' - properties: - fsType: - description: 'Filesystem type to mount. Must be a filesystem type supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' - type: string - readOnly: - description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' - type: boolean - secretRef: - description: 'Optional: points to a secret object containing parameters used to connect to OpenStack.' - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - volumeID: - description: 'volume id used to identify the volume in cinder. More info: https://examples.k8s.io/mysql-cinder-pd/README.md' - type: string - required: - - volumeID - type: object - configMap: - description: ConfigMap represents a configMap that should populate this volume - properties: - defaultMode: - description: 'Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - items: - description: If unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. - items: - description: Maps a string key to a path within a volume. - properties: - key: - description: The key to project. - type: string - mode: - description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. - type: string - required: - - key - - path - type: object - type: array - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - optional: - description: Specify whether the ConfigMap or its keys must be defined - type: boolean - type: object - csi: - description: CSI (Container Storage Interface) represents ephemeral storage that is handled by certain external CSI drivers (Beta feature). - properties: - driver: - description: Driver is the name of the CSI driver that handles this volume. Consult with your admin for the correct name as registered in the cluster. - type: string - fsType: - description: Filesystem type to mount. Ex. "ext4", "xfs", "ntfs". If not provided, the empty value is passed to the associated CSI driver which will determine the default filesystem to apply. - type: string - nodePublishSecretRef: - description: NodePublishSecretRef is a reference to the secret object containing sensitive information to pass to the CSI driver to complete the CSI NodePublishVolume and NodeUnpublishVolume calls. This field is optional, and may be empty if no secret is required. If the secret object contains more than one secret, all secret references are passed. - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - readOnly: - description: Specifies a read-only configuration for the volume. Defaults to false (read/write). - type: boolean - volumeAttributes: - additionalProperties: - type: string - description: VolumeAttributes stores driver-specific properties that are passed to the CSI driver. Consult your driver's documentation for supported values. - type: object - required: - - driver - type: object - downwardAPI: - description: DownwardAPI represents downward API about the pod that should populate this volume - properties: - defaultMode: - description: 'Optional: mode bits to use on created files by default. Must be a Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - items: - description: Items is a list of downward API volume file - items: - description: DownwardAPIVolumeFile represents information to create the file containing the pod field - properties: - fieldRef: - description: 'Required: Selects a field of the pod: only annotations, labels, name and namespace are supported.' - properties: - apiVersion: - description: Version of the schema the FieldPath is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in the specified API version. - type: string - required: - - fieldPath - type: object - mode: - description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' - type: string - resourceFieldRef: - description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' - properties: - containerName: - description: 'Container name: required for volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - required: - - path - type: object - type: array - type: object - emptyDir: - description: 'EmptyDir represents a temporary directory that shares a pod''s lifetime. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' - properties: - medium: - description: 'What type of storage medium should back this directory. The default is "" which means to use the node''s default medium. Must be an empty string (default) or Memory. More info: https://kubernetes.io/docs/concepts/storage/volumes#emptydir' - type: string - sizeLimit: - anyOf: - - type: integer - - type: string - description: 'Total amount of local storage required for this EmptyDir volume. The size limit is also applicable for memory medium. The maximum usage on memory medium EmptyDir would be the minimum value between the SizeLimit specified here and the sum of memory limits of all containers in a pod. The default is nil which means that the limit is undefined. More info: http://kubernetes.io/docs/user-guide/volumes#emptydir' - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - type: object - ephemeral: - description: "Ephemeral represents a volume that is handled by a cluster storage driver (Alpha feature). The volume's lifecycle is tied to the pod that defines it - it will be created before the pod starts, and deleted when the pod is removed. \n Use this if: a) the volume is only needed while the pod runs, b) features of normal volumes like restoring from snapshot or capacity tracking are needed, c) the storage driver is specified through a storage class, and d) the storage driver supports dynamic volume provisioning through a PersistentVolumeClaim (see EphemeralVolumeSource for more information on the connection between this volume type and PersistentVolumeClaim). \n Use PersistentVolumeClaim or one of the vendor-specific APIs for volumes that persist for longer than the lifecycle of an individual pod. \n Use CSI for light-weight local ephemeral volumes if the CSI driver is meant to be used that way - see the documentation of the driver for more information. \n A pod can use both types of ephemeral volumes and persistent volumes at the same time." - properties: - readOnly: - description: Specifies a read-only configuration for the volume. Defaults to false (read/write). - type: boolean - volumeClaimTemplate: - description: "Will be used to create a stand-alone PVC to provision the volume. The pod in which this EphemeralVolumeSource is embedded will be the owner of the PVC, i.e. the PVC will be deleted together with the pod. The name of the PVC will be `-` where `` is the name from the `PodSpec.Volumes` array entry. Pod validation will reject the pod if the concatenated name is not valid for a PVC (for example, too long). \n An existing PVC with that name that is not owned by the pod will *not* be used for the pod to avoid using an unrelated volume by mistake. Starting the pod is then blocked until the unrelated PVC is removed. If such a pre-created PVC is meant to be used by the pod, the PVC has to updated with an owner reference to the pod once the pod exists. Normally this should not be necessary, but it may be useful when manually reconstructing a broken cluster. \n This field is read-only and no changes will be made by Kubernetes to the PVC after it has been created. \n Required, must not be nil." - properties: - metadata: - description: May contain labels and annotations that will be copied into the PVC when creating it. No other fields are allowed and will be rejected during validation. - type: object - spec: - description: The specification for the PersistentVolumeClaim. The entire content is copied unchanged into the PVC that gets created from this template. The same fields as in a PersistentVolumeClaim are also valid here. - properties: - accessModes: - description: 'AccessModes contains the desired access modes the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1' - items: - type: string - type: array - dataSource: - description: 'This field can be used to specify either: * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot - Beta) * An existing PVC (PersistentVolumeClaim) * An existing custom resource/object that implements data population (Alpha) In order to use VolumeSnapshot object types, the appropriate feature gate must be enabled (VolumeSnapshotDataSource or AnyVolumeDataSource) If the provisioner or an external controller can support the specified data source, it will create a new volume based on the contents of the specified data source. If the specified data source is not supported, the volume will not be created and the failure will be reported as an event. In the future, we plan to support more data source types and the behavior of the provisioner may change.' - properties: - apiGroup: - description: APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - required: - - kind - - name - type: object - resources: - description: 'Resources represents the minimum resources the volume should have. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources' - properties: - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: 'Limits describes the maximum amount of compute resources allowed. More info: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/' - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: 'Requests describes the minimum amount of compute resources required. If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, otherwise to an implementation-defined value. More info: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/' - type: object - type: object - selector: - description: A label query over volumes to consider for binding. - properties: - matchExpressions: - description: matchExpressions is a list of label selector requirements. The requirements are ANDed. - items: - description: A label selector requirement is a selector that contains values, a key, and an operator that relates the key and values. - properties: - key: - description: key is the label key that the selector applies to. - type: string - operator: - description: operator represents a key's relationship to a set of values. Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: values is an array of string values. If the operator is In or NotIn, the values array must be non-empty. If the operator is Exists or DoesNotExist, the values array must be empty. This array is replaced during a strategic merge patch. - items: - type: string - type: array - required: - - key - - operator - type: object - type: array - matchLabels: - additionalProperties: - type: string - description: matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent to an element of matchExpressions, whose key field is "key", the operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - storageClassName: - description: 'Name of the StorageClass required by the claim. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1' - type: string - volumeMode: - description: volumeMode defines what type of volume is required by the claim. Value of Filesystem is implied when not included in claim spec. - type: string - volumeName: - description: VolumeName is the binding reference to the PersistentVolume backing this claim. - type: string - type: object - required: - - spec - type: object - type: object - fc: - description: FC represents a Fibre Channel resource that is attached to a kubelet's host machine and then exposed to the pod. - properties: - fsType: - description: 'Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. TODO: how do we prevent errors in the filesystem from compromising the machine' - type: string - lun: - description: 'Optional: FC target lun number' - format: int32 - type: integer - readOnly: - description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' - type: boolean - targetWWNs: - description: 'Optional: FC target worldwide names (WWNs)' - items: - type: string - type: array - wwids: - description: 'Optional: FC volume world wide identifiers (wwids) Either wwids or combination of targetWWNs and lun must be set, but not both simultaneously.' - items: - type: string - type: array - type: object - flexVolume: - description: FlexVolume represents a generic volume resource that is provisioned/attached using an exec based plugin. - properties: - driver: - description: Driver is the name of the driver to use for this volume. - type: string - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". The default filesystem depends on FlexVolume script. - type: string - options: - additionalProperties: - type: string - description: 'Optional: Extra command options if any.' - type: object - readOnly: - description: 'Optional: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts.' - type: boolean - secretRef: - description: 'Optional: SecretRef is reference to the secret object containing sensitive information to pass to the plugin scripts. This may be empty if no secret object is specified. If the secret object contains more than one secret, all secrets are passed to the plugin scripts.' - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - required: - - driver - type: object - flocker: - description: Flocker represents a Flocker volume attached to a kubelet's host machine. This depends on the Flocker control service being running - properties: - datasetName: - description: Name of the dataset stored as metadata -> name on the dataset for Flocker should be considered as deprecated - type: string - datasetUUID: - description: UUID of the dataset. This is unique identifier of a Flocker dataset - type: string - type: object - gcePersistentDisk: - description: 'GCEPersistentDisk represents a GCE Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' - properties: - fsType: - description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk TODO: how do we prevent errors in the filesystem from compromising the machine' - type: string - partition: - description: 'The partition in the volume that you want to mount. If omitted, the default is to mount by volume name. Examples: For volume /dev/sda1, you specify the partition as "1". Similarly, the volume partition for /dev/sda is "0" (or you can leave the property empty). More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' - format: int32 - type: integer - pdName: - description: 'Unique name of the PD resource in GCE. Used to identify the disk in GCE. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' - type: string - readOnly: - description: 'ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#gcepersistentdisk' - type: boolean - required: - - pdName - type: object - gitRepo: - description: 'GitRepo represents a git repository at a particular revision. DEPRECATED: GitRepo is deprecated. To provision a container with a git repo, mount an EmptyDir into an InitContainer that clones the repo using git, then mount the EmptyDir into the Pod''s container.' - properties: - directory: - description: Target directory name. Must not contain or start with '..'. If '.' is supplied, the volume directory will be the git repository. Otherwise, if specified, the volume will contain the git repository in the subdirectory with the given name. - type: string - repository: - description: Repository URL - type: string - revision: - description: Commit hash for the specified revision. - type: string - required: - - repository - type: object - glusterfs: - description: 'Glusterfs represents a Glusterfs mount on the host that shares a pod''s lifetime. More info: https://examples.k8s.io/volumes/glusterfs/README.md' - properties: - endpoints: - description: 'EndpointsName is the endpoint name that details Glusterfs topology. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' - type: string - path: - description: 'Path is the Glusterfs volume path. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' - type: string - readOnly: - description: 'ReadOnly here will force the Glusterfs volume to be mounted with read-only permissions. Defaults to false. More info: https://examples.k8s.io/volumes/glusterfs/README.md#create-a-pod' - type: boolean - required: - - endpoints - - path - type: object - hostPath: - description: 'HostPath represents a pre-existing file or directory on the host machine that is directly exposed to the container. This is generally used for system agents or other privileged things that are allowed to see the host machine. Most containers will NOT need this. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath --- TODO(jonesdl) We need to restrict who can use host directory mounts and who can/can not mount host directories as read/write.' - properties: - path: - description: 'Path of the directory on the host. If the path is a symlink, it will follow the link to the real path. More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' - type: string - type: - description: 'Type for HostPath Volume Defaults to "" More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath' - type: string - required: - - path - type: object - iscsi: - description: 'ISCSI represents an ISCSI Disk resource that is attached to a kubelet''s host machine and then exposed to the pod. More info: https://examples.k8s.io/volumes/iscsi/README.md' - properties: - chapAuthDiscovery: - description: whether support iSCSI Discovery CHAP authentication - type: boolean - chapAuthSession: - description: whether support iSCSI Session CHAP authentication - type: boolean - fsType: - description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#iscsi TODO: how do we prevent errors in the filesystem from compromising the machine' - type: string - initiatorName: - description: Custom iSCSI Initiator Name. If initiatorName is specified with iscsiInterface simultaneously, new iSCSI interface : will be created for the connection. - type: string - iqn: - description: Target iSCSI Qualified Name. - type: string - iscsiInterface: - description: iSCSI Interface Name that uses an iSCSI transport. Defaults to 'default' (tcp). - type: string - lun: - description: iSCSI Target Lun number. - format: int32 - type: integer - portals: - description: iSCSI Target Portal List. The portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). - items: - type: string - type: array - readOnly: - description: ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. - type: boolean - secretRef: - description: CHAP Secret for iSCSI target and initiator authentication - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - targetPortal: - description: iSCSI Target Portal. The Portal is either an IP or ip_addr:port if the port is other than default (typically TCP ports 860 and 3260). - type: string - required: - - iqn - - lun - - targetPortal - type: object - name: - description: 'Volume''s name. Must be a DNS_LABEL and unique within the pod. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names' - type: string - nfs: - description: 'NFS represents an NFS mount on the host that shares a pod''s lifetime More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' - properties: - path: - description: 'Path that is exported by the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' - type: string - readOnly: - description: 'ReadOnly here will force the NFS export to be mounted with read-only permissions. Defaults to false. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' - type: boolean - server: - description: 'Server is the hostname or IP address of the NFS server. More info: https://kubernetes.io/docs/concepts/storage/volumes#nfs' - type: string - required: - - path - - server - type: object - persistentVolumeClaim: - description: 'PersistentVolumeClaimVolumeSource represents a reference to a PersistentVolumeClaim in the same namespace. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' - properties: - claimName: - description: 'ClaimName is the name of a PersistentVolumeClaim in the same namespace as the pod using this volume. More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#persistentvolumeclaims' - type: string - readOnly: - description: Will force the ReadOnly setting in VolumeMounts. Default false. - type: boolean - required: - - claimName - type: object - photonPersistentDisk: - description: PhotonPersistentDisk represents a PhotonController persistent disk attached and mounted on kubelets host machine - properties: - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. - type: string - pdID: - description: ID that identifies Photon Controller persistent disk - type: string - required: - - pdID - type: object - portworxVolume: - description: PortworxVolume represents a portworx volume attached and mounted on kubelets host machine - properties: - fsType: - description: FSType represents the filesystem type to mount Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs". Implicitly inferred to be "ext4" if unspecified. - type: string - readOnly: - description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. - type: boolean - volumeID: - description: VolumeID uniquely identifies a Portworx volume - type: string - required: - - volumeID - type: object - projected: - description: Items for all in one resources secrets, configmaps, and downward API - properties: - defaultMode: - description: Mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set. - format: int32 - type: integer - sources: - description: list of volume projections - items: - description: Projection that may be projected along with other supported volume types - properties: - configMap: - description: information about the configMap data to project - properties: - items: - description: If unspecified, each key-value pair in the Data field of the referenced ConfigMap will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the ConfigMap, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. - items: - description: Maps a string key to a path within a volume. - properties: - key: - description: The key to project. - type: string - mode: - description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. - type: string - required: - - key - - path - type: object - type: array - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - optional: - description: Specify whether the ConfigMap or its keys must be defined - type: boolean - type: object - downwardAPI: - description: information about the downwardAPI data to project - properties: - items: - description: Items is a list of DownwardAPIVolume file - items: - description: DownwardAPIVolumeFile represents information to create the file containing the pod field - properties: - fieldRef: - description: 'Required: Selects a field of the pod: only annotations, labels, name and namespace are supported.' - properties: - apiVersion: - description: Version of the schema the FieldPath is written in terms of, defaults to "v1". - type: string - fieldPath: - description: Path of the field to select in the specified API version. - type: string - required: - - fieldPath - type: object - mode: - description: 'Optional: mode bits used to set permissions on this file, must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: 'Required: Path is the relative path name of the file to be created. Must not be absolute or contain the ''..'' path. Must be utf-8 encoded. The first item of the relative path must not start with ''..''' - type: string - resourceFieldRef: - description: 'Selects a resource of the container: only resources limits and requests (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported.' - properties: - containerName: - description: 'Container name: required for volumes, optional for env vars' - type: string - divisor: - anyOf: - - type: integer - - type: string - description: Specifies the output format of the exposed resources, defaults to "1" - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - resource: - description: 'Required: resource to select' - type: string - required: - - resource - type: object - required: - - path - type: object - type: array - type: object - secret: - description: information about the secret data to project - properties: - items: - description: If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. - items: - description: Maps a string key to a path within a volume. - properties: - key: - description: The key to project. - type: string - mode: - description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. - type: string - required: - - key - - path - type: object - type: array - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - optional: - description: Specify whether the Secret or its key must be defined - type: boolean - type: object - serviceAccountToken: - description: information about the serviceAccountToken data to project - properties: - audience: - description: Audience is the intended audience of the token. A recipient of a token must identify itself with an identifier specified in the audience of the token, and otherwise should reject the token. The audience defaults to the identifier of the apiserver. - type: string - expirationSeconds: - description: ExpirationSeconds is the requested duration of validity of the service account token. As the token approaches expiration, the kubelet volume plugin will proactively rotate the service account token. The kubelet will start trying to rotate the token if the token is older than 80 percent of its time to live or if the token is older than 24 hours.Defaults to 1 hour and must be at least 10 minutes. - format: int64 - type: integer - path: - description: Path is the path relative to the mount point of the file to project the token into. - type: string - required: - - path - type: object - type: object - type: array - required: - - sources - type: object - quobyte: - description: Quobyte represents a Quobyte mount on the host that shares a pod's lifetime - properties: - group: - description: Group to map volume access to Default is no group - type: string - readOnly: - description: ReadOnly here will force the Quobyte volume to be mounted with read-only permissions. Defaults to false. - type: boolean - registry: - description: Registry represents a single or multiple Quobyte Registry services specified as a string as host:port pair (multiple entries are separated with commas) which acts as the central registry for volumes - type: string - tenant: - description: Tenant owning the given Quobyte volume in the Backend Used with dynamically provisioned Quobyte volumes, value is set by the plugin - type: string - user: - description: User to map volume access to Defaults to serivceaccount user - type: string - volume: - description: Volume is a string that references an already created Quobyte volume by name. - type: string - required: - - registry - - volume - type: object - rbd: - description: 'RBD represents a Rados Block Device mount on the host that shares a pod''s lifetime. More info: https://examples.k8s.io/volumes/rbd/README.md' - properties: - fsType: - description: 'Filesystem type of the volume that you want to mount. Tip: Ensure that the filesystem type is supported by the host operating system. Examples: "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. More info: https://kubernetes.io/docs/concepts/storage/volumes#rbd TODO: how do we prevent errors in the filesystem from compromising the machine' - type: string - image: - description: 'The rados image name. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - type: string - keyring: - description: 'Keyring is the path to key ring for RBDUser. Default is /etc/ceph/keyring. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - type: string - monitors: - description: 'A collection of Ceph monitors. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - items: - type: string - type: array - pool: - description: 'The rados pool name. Default is rbd. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - type: string - readOnly: - description: 'ReadOnly here will force the ReadOnly setting in VolumeMounts. Defaults to false. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - type: boolean - secretRef: - description: 'SecretRef is name of the authentication secret for RBDUser. If provided overrides keyring. Default is nil. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - user: - description: 'The rados user name. Default is admin. More info: https://examples.k8s.io/volumes/rbd/README.md#how-to-use-it' - type: string - required: - - image - - monitors - type: object - scaleIO: - description: ScaleIO represents a ScaleIO persistent volume attached and mounted on Kubernetes nodes. - properties: - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Default is "xfs". - type: string - gateway: - description: The host address of the ScaleIO API Gateway. - type: string - protectionDomain: - description: The name of the ScaleIO Protection Domain for the configured storage. - type: string - readOnly: - description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. - type: boolean - secretRef: - description: SecretRef references to the secret for ScaleIO user and other sensitive information. If this is not provided, Login operation will fail. - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - sslEnabled: - description: Flag to enable/disable SSL communication with Gateway, default false - type: boolean - storageMode: - description: Indicates whether the storage for a volume should be ThickProvisioned or ThinProvisioned. Default is ThinProvisioned. - type: string - storagePool: - description: The ScaleIO Storage Pool associated with the protection domain. - type: string - system: - description: The name of the storage system as configured in ScaleIO. - type: string - volumeName: - description: The name of a volume already created in the ScaleIO system that is associated with this volume source. - type: string - required: - - gateway - - secretRef - - system - type: object - secret: - description: 'Secret represents a secret that should populate this volume. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' - properties: - defaultMode: - description: 'Optional: mode bits used to set permissions on created files by default. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. Defaults to 0644. Directories within the path are not affected by this setting. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - items: - description: If unspecified, each key-value pair in the Data field of the referenced Secret will be projected into the volume as a file whose name is the key and content is the value. If specified, the listed keys will be projected into the specified paths, and unlisted keys will not be present. If a key is specified which is not present in the Secret, the volume setup will error unless it is marked optional. Paths must be relative and may not contain the '..' path or start with '..'. - items: - description: Maps a string key to a path within a volume. - properties: - key: - description: The key to project. - type: string - mode: - description: 'Optional: mode bits used to set permissions on this file. Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. If not specified, the volume defaultMode will be used. This might be in conflict with other options that affect the file mode, like fsGroup, and the result can be other mode bits set.' - format: int32 - type: integer - path: - description: The relative path of the file to map the key to. May not be an absolute path. May not contain the path element '..'. May not start with the string '..'. - type: string - required: - - key - - path - type: object - type: array - optional: - description: Specify whether the Secret or its keys must be defined - type: boolean - secretName: - description: 'Name of the secret in the pod''s namespace to use. More info: https://kubernetes.io/docs/concepts/storage/volumes#secret' - type: string - type: object - storageos: - description: StorageOS represents a StorageOS volume attached and mounted on Kubernetes nodes. - properties: - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. - type: string - readOnly: - description: Defaults to false (read/write). ReadOnly here will force the ReadOnly setting in VolumeMounts. - type: boolean - secretRef: - description: SecretRef specifies the secret to use for obtaining the StorageOS API credentials. If not specified, default values will be attempted. - properties: - name: - description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names TODO: Add other useful fields. apiVersion, kind, uid?' - type: string - type: object - volumeName: - description: VolumeName is the human-readable name of the StorageOS volume. Volume names are only unique within a namespace. - type: string - volumeNamespace: - description: VolumeNamespace specifies the scope of the volume within StorageOS. If no namespace is specified then the Pod's namespace will be used. This allows the Kubernetes name scoping to be mirrored within StorageOS for tighter integration. Set VolumeName to any name to override the default behaviour. Set to "default" if you are not using namespaces within StorageOS. Namespaces that do not pre-exist within StorageOS will be created. - type: string - type: object - vsphereVolume: - description: VsphereVolume represents a vSphere volume attached and mounted on kubelets host machine - properties: - fsType: - description: Filesystem type to mount. Must be a filesystem type supported by the host operating system. Ex. "ext4", "xfs", "ntfs". Implicitly inferred to be "ext4" if unspecified. - type: string - storagePolicyID: - description: Storage Policy Based Management (SPBM) profile ID associated with the StoragePolicyName. - type: string - storagePolicyName: - description: Storage Policy Based Management (SPBM) profile name. - type: string - volumePath: - description: Path that identifies vSphere volume vmdk - type: string - required: - - volumePath - type: object - required: - - name - type: object + sourcePvcRef: + type: string sourceVolumeSubdir: type: string suspend: @@ -1060,6 +176,13 @@ spec: - type type: object type: array + currentTasks: + items: + properties: + taskName: + type: string + type: object + type: array type: object type: object served: true diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 276b3e86..3fccbf13 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -16,7 +16,23 @@ spec: singular: clustercodetask scope: Namespaced versions: - - name: v1alpha1 + - additionalPrinterColumns: + - description: Source file name + jsonPath: .spec.sourceUrl + name: Source + type: string + - description: Target file name + jsonPath: .spec.targetUrl + name: Target + type: string + - description: Clustercode Plan + jsonPath: .metadata.ownerReferences[?(@.controller)].name + name: Plan + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 schema: openAPIV3Schema: description: ClustercodePlan is the Schema for the archives API @@ -34,7 +50,7 @@ spec: properties: encodeSpec: properties: - defaultFfmpegArgs: + defaultCommandArgs: default: - -y - -hide_banner @@ -42,7 +58,7 @@ spec: items: type: string type: array - mergeFfmpegArgs: + mergeCommandArgs: default: - -f - concat @@ -56,7 +72,7 @@ spec: type: array sliceSize: type: integer - splitFfmpegArgs: + splitCommandArgs: default: - -i - '"${INPUT}"' @@ -72,7 +88,7 @@ spec: items: type: string type: array - transcodeArgs: + transcodeCommandArgs: default: - -i - '"${INPUT}"' @@ -85,10 +101,10 @@ spec: type: string type: array required: - - defaultFfmpegArgs - - mergeFfmpegArgs - - splitFfmpegArgs - - transcodeArgs + - defaultCommandArgs + - mergeCommandArgs + - splitCommandArgs + - transcodeCommandArgs type: object sourceUrl: type: string @@ -146,10 +162,6 @@ spec: type: array sliceCount: type: integer - sourceMediaFileName: - type: string - targetMediaFileName: - type: string type: object type: object served: true diff --git a/config/rbac/clustercodeplan_editor_role.yaml b/config/rbac/clustercodeplan_editor_role.yaml index 7e585c9e..e9a63e2f 100644 --- a/config/rbac/clustercodeplan_editor_role.yaml +++ b/config/rbac/clustercodeplan_editor_role.yaml @@ -2,12 +2,13 @@ apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: clustercodeplan-editor-role + name: clustercode-editor-role rules: - apiGroups: - clustercode.github.io resources: - clustercodeplans + - clustercodetasks verbs: - create - delete @@ -20,6 +21,7 @@ rules: - clustercode.github.io resources: - clustercodeplans/status + - clustercodetasks/status verbs: - get - patch diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f6d071fb..af7b53f7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -58,6 +58,26 @@ rules: - get - patch - update +- apiGroups: + - clustercode.github.io + resources: + - clustercodetasks + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - clustercode.github.io + resources: + - clustercodetasks/status + verbs: + - get + - patch + - update - apiGroups: - "" resources: diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index a1b38077..df5ed54c 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -29,8 +29,8 @@ type ( Log logr.Logger Scheme *runtime.Scheme } - // ReconciliationContext holds the parameters of a single reconciliation - ReconciliationContext struct { + // ClustercodePlanContext holds the parameters of a single reconciliation + ClustercodePlanContext struct { ctx context.Context plan *v1alpha1.ClustercodePlan log logr.Logger @@ -52,8 +52,8 @@ func (r *ClustercodePlanReconciler) SetupWithManager(mgr ctrl.Manager) error { // +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;create;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;create;delete -func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, returnErr error) { - rc := &ReconciliationContext{ +func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + rc := &ClustercodePlanContext{ ctx: ctx, plan: &v1alpha1.ClustercodePlan{}, } @@ -68,10 +68,11 @@ func (r *ClustercodePlanReconciler) Reconcile(ctx context.Context, req ctrl.Requ } rc.log = r.Log.WithValues("plan", req.NamespacedName) r.handlePlan(rc) + rc.log.Info("reconciled plan") return ctrl.Result{}, nil } -func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { +func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { saName, err := r.createServiceAccountAndBinding(rc) if err != nil { @@ -82,9 +83,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { ObjectMeta: metav1.ObjectMeta{ Name: rc.plan.Name + "-scan-job", Namespace: rc.plan.Namespace, - Labels: map[string]string{ - "app.kubernetes.io/managed-by": "clustercode", - }, + Labels: ClusterCodeLabels, }, Spec: v1beta1.CronJobSpec{ Schedule: rc.plan.Spec.ScanSchedule, @@ -93,7 +92,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { JobTemplate: v1beta1.JobTemplateSpec{ Spec: batchv1.JobSpec{ - BackoffLimit: pointer.Int32Ptr(1), + BackoffLimit: pointer.Int32Ptr(0), Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ ServiceAccountName: saName, @@ -113,6 +112,19 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { "--scan.clustercode-plan-name=" + rc.plan.Name, }, Image: "localhost:5000/clustercode/operator:e2e", + VolumeMounts: []corev1.VolumeMount{ + {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.SourceVolumeSubdir}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "source", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: rc.plan.Spec.SourcePvcRef, + }, + }, }, }, }, @@ -145,7 +157,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ReconciliationContext) { } } -func (r *ClustercodePlanReconciler) createServiceAccountAndBinding(rc *ReconciliationContext) (string, error) { +func (r *ClustercodePlanReconciler) createServiceAccountAndBinding(rc *ClustercodePlanContext) (string, error) { binding, sa := r.newRbacDefinition(rc) err := r.Client.Create(rc.ctx, &sa) @@ -167,12 +179,13 @@ func (r *ClustercodePlanReconciler) createServiceAccountAndBinding(rc *Reconcili return sa.Name, nil } -func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ReconciliationContext) (rbacv1.RoleBinding, corev1.ServiceAccount) { +func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ClustercodePlanContext) (rbacv1.RoleBinding, corev1.ServiceAccount) { saName := rc.plan.Name + "-clustercode" roleBinding := rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ Name: saName + "-rolebinding", Namespace: rc.plan.Namespace, + Labels: ClusterCodeLabels, }, Subjects: []rbacv1.Subject{ { @@ -182,8 +195,8 @@ func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ReconciliationContext) }, }, RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - Name: cfg.Config.Scan.ClusterRoleName, + Kind: cfg.Config.Scan.RoleKind, + Name: cfg.Config.Scan.RoleName, APIGroup: rbacv1.GroupName, }, } @@ -192,6 +205,7 @@ func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ReconciliationContext) ObjectMeta: metav1.ObjectMeta{ Name: saName, Namespace: rc.plan.Namespace, + Labels: ClusterCodeLabels, }, } diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go new file mode 100644 index 00000000..dddab350 --- /dev/null +++ b/controllers/clustercodetask_controller.go @@ -0,0 +1,66 @@ +package controllers + +import ( + "context" + "time" + + "github.com/go-logr/logr" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/ccremer/clustercode/api/v1alpha1" +) + +type ( + // ClustercodeTaskReconciler reconciles ClustercodeTask objects + ClustercodeTaskReconciler struct { + Client client.Client + Log logr.Logger + Scheme *runtime.Scheme + } + // ClustercodeTaskContext holds the parameters of a single reconciliation + ClustercodeTaskContext struct { + ctx context.Context + task *v1alpha1.ClustercodeTask + log logr.Logger + } +) + +func (r *ClustercodeTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { + pred, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{MatchLabels: ClusterCodeLabels}) + if err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.ClustercodeTask{}, builder.WithPredicates(pred)). + WithEventFilter(predicate.GenerationChangedPredicate{}). + Complete(r) +} + +// +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodetasks,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=clustercode.github.io,resources=clustercodetasks/status,verbs=get;update;patch + +func (r *ClustercodeTaskReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + rc := &ClustercodeTaskContext{ + ctx: ctx, + task: &v1alpha1.ClustercodeTask{}, + } + err := r.Client.Get(ctx, req.NamespacedName, rc.task) + if err != nil { + if apierrors.IsNotFound(err) { + r.Log.Info("object not found, ignoring reconcile", "object", req.NamespacedName) + return ctrl.Result{}, nil + } + r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) + return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err + } + rc.log = r.Log.WithValues("task", req.NamespacedName) + //r.handleTask(rc) + rc.log.Info("reconciled task") + return ctrl.Result{}, nil +} diff --git a/controllers/utils.go b/controllers/utils.go new file mode 100644 index 00000000..ad21ae2a --- /dev/null +++ b/controllers/utils.go @@ -0,0 +1,17 @@ +package controllers + +var( + ClusterCodeLabels = map[string]string { + "app.kubernetes.io/managed-by": "clustercode", + } +) + +func mergeLabels(labels ...map[string]string) map[string]string { + merged := make(map[string]string) + for _, labelMap := range labels { + for k, v := range labelMap { + merged[k] = v + } + } + return merged +} diff --git a/data/source/.keep b/data/source/.keep new file mode 100644 index 00000000..e69de29b diff --git a/data/source/movie.mp4 b/data/source/movie.mp4 new file mode 100644 index 00000000..e69de29b diff --git a/e2e/kind-config.yaml b/e2e/kind-config.yaml index 3351464b..0c3590dd 100644 --- a/e2e/kind-config.yaml +++ b/e2e/kind-config.yaml @@ -4,3 +4,8 @@ containerdConfigPatches: - |- [plugins."io.containerd.grpc.v1.cri".registry.mirrors."localhost:5000"] endpoint = ["http://kind-registry:5000"] +nodes: + - role: control-plane + extraMounts: + - hostPath: ./data + containerPath: /pv/data diff --git a/e2e/test1/kustomization.yaml b/e2e/test1/kustomization.yaml index bf1c070b..83082697 100644 --- a/e2e/test1/kustomization.yaml +++ b/e2e/test1/kustomization.yaml @@ -9,3 +9,4 @@ namePrefix: clustercode- commonLabels: app.kubernetes.io/name: e2e app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/instance: test1 diff --git a/e2e/test1/plan.yaml b/e2e/test1/plan.yaml deleted file mode 100644 index 156bfef2..00000000 --- a/e2e/test1/plan.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: clustercode.github.io/v1alpha1 -kind: ClustercodePlan -metadata: - name: test-plan -spec: - scanSchedule: "*/1 * * * *" - sourceVolume: - name: fuckyou - emptyDir: {} - sourceVolumeSubdir: input diff --git a/e2e/test2.bats b/e2e/test2.bats new file mode 100644 index 00000000..74f2e63b --- /dev/null +++ b/e2e/test2.bats @@ -0,0 +1,23 @@ +#!/usr/bin/env bats + +load "lib/utils" +load "lib/detik" +load "lib/custom" + +DETIK_CLIENT_NAME="kubectl" +DETIK_CLIENT_NAMESPACE="default" +DEBUG_DETIK="true" + +@test "reset the debug file" { + reset_debug +} + +@test "verify the clustercode plan" { + go run sigs.k8s.io/kustomize/kustomize/v3 build test2 > debug/test2.yaml + sed -i "s/\$RANDOM/'$RANDOM'/" debug/test2.yaml + run kubectl apply -f debug/test2.yaml + debug "$output" + + try "at most 20 times every 2s to find 1 cronjob named 'test-plan-scan-job' with '.status.active[*].kind' being 'Job'" + +} diff --git a/e2e/test2/kustomization.yaml b/e2e/test2/kustomization.yaml new file mode 100644 index 00000000..536c591b --- /dev/null +++ b/e2e/test2/kustomization.yaml @@ -0,0 +1,10 @@ +resources: + - pv.yaml + - pvc.yaml + - plan.yaml +namespace: default + +commonLabels: + app.kubernetes.io/name: e2e + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/instance: test2 diff --git a/e2e/test2/plan.yaml b/e2e/test2/plan.yaml new file mode 100644 index 00000000..9b679e73 --- /dev/null +++ b/e2e/test2/plan.yaml @@ -0,0 +1,44 @@ +apiVersion: clustercode.github.io/v1alpha1 +kind: ClustercodePlan +metadata: + name: test-plan +spec: + scanSchedule: "*/1 * * * *" + sourcePvcRef: test2-claim + sourceVolumeSubdir: source + scanSpec: + mediaFileExtensions: + - mp4 + encodeSpec: + defaultCommandArgs: + - -y + - -hide_banner + - -nostats + splitCommandArgs: + - -i + - ${INPUT} + - -c + - copy + - map + - "0" + - -segment_time + - ${SLIZE_SIZE} + - -f + - segment + - ${OUTPUT} + transcodeCommandArgs: + - -i + - ${INPUT} + - -c:v + - copy + - -c:a + - copy + - ${OUTPUT} + mergeCommandArgs: + - -f + - concat + - -i + - concat.txt + - -c + - copy + - media_out.mkv diff --git a/e2e/test2/pv.yaml b/e2e/test2/pv.yaml new file mode 100644 index 00000000..a6b75e1b --- /dev/null +++ b/e2e/test2/pv.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: e2e-testdata +spec: + capacity: + storage: 1Gi + accessModes: + - ReadWriteMany + storageClassName: hostpath + hostPath: + path: /pv/data + type: Directory diff --git a/e2e/test2/pvc.yaml b/e2e/test2/pvc.yaml new file mode 100644 index 00000000..c6bed10f --- /dev/null +++ b/e2e/test2/pvc.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: test2-claim +spec: + accessModes: + - ReadWriteMany + volumeMode: Filesystem + resources: + requests: + storage: 1Gi + storageClassName: hostpath + selector: + matchLabels: + app.kubernetes.io/instance: test2 + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: e2e From 84cb309601a3a24b222f8e2f636f15599fcd4aef Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 24 Dec 2020 14:25:41 +0100 Subject: [PATCH 08/13] Task: can create first split now --- api/v1alpha1/clustercodeplan_types.go | 29 ++---- api/v1alpha1/clustercodetask_types.go | 23 +++-- api/v1alpha1/common.go | 26 ++++++ api/v1alpha1/zz_generated.deepcopy.go | 35 +++++++ cmd/scan.go | 57 ++++++++++-- ...lustercode.github.io_clustercodeplans.yaml | 39 +++++++- ...lustercode.github.io_clustercodetasks.yaml | 40 +++++++- controllers/clustercodeplan_controller.go | 9 +- controllers/clustercodetask_controller.go | 92 ++++++++++++++++++- controllers/utils.go | 14 +++ e2e/test2/plan.yaml | 15 ++- 11 files changed, 330 insertions(+), 49 deletions(-) create mode 100644 api/v1alpha1/common.go diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 0f3a6e7f..5b5bfa4f 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -38,12 +38,9 @@ type ( ClustercodePlanSpec struct { ScanSchedule string `json:"scanSchedule"` // +kubebuilder:validation:Required - SourcePvcRef string `json:"sourcePvcRef,omitempty"` - // +kubebuilder:validation:Required - SourceVolumeSubdir string `json:"sourceVolumeSubdir,omitempty"` - + Storage StorageSpec `json:"storage,omitempty"` // +kubebuilder:default=1 - MaxParallelTasks int `json:"maxParallelTasks,omitempty"` + MaxParallelTasks int `json:"maxParallelTasks"` Suspend bool `json:"suspend,omitempty"` @@ -56,19 +53,6 @@ type ( MediaFileExtensions []string `json:"mediaFileExtensions,omitempty"` } - EncodeSpec struct { - // +kubebuilder:default=-y;-hide_banner;-nostats - DefaultCommandArgs []string `json:"defaultCommandArgs"` - // +kubebuilder:default=-i;"\"${INPUT}\"";-c;copy;-map;0;-segment_time;"\"${SLICE_SIZE}\"";-f;segment;"\"${OUTPUT}\"" - SplitCommandArgs []string `json:"splitCommandArgs"` - // +kubebuilder:default=-i;"\"${INPUT}\"";"-c:v";copy;"-c:a";copy;"\"${OUTPUT}\"" - TranscodeCommandArgs []string `json:"transcodeCommandArgs"` - // +kubebuilder:default=-f;concat;-i;concat.txt;-c;copy;media_out.mkv - MergeCommandArgs []string `json:"mergeCommandArgs"` - - SliceSize int `json:"sliceSize,omitempty"` - } - ClustercodePlanStatus struct { Conditions []metav1.Condition `json:"conditions,omitempty"` CurrentTasks []ClusterCodeTaskRef `json:"currentTasks,omitempty"` @@ -80,6 +64,11 @@ type ( ) // IsMaxParallelTaskLimitReached will return true if the count of current task has reached MaxParallelTasks. -func (plan *ClustercodePlan) IsMaxParallelTaskLimitReached() bool { - return len(plan.Status.CurrentTasks) >= plan.Spec.MaxParallelTasks +func (in *ClustercodePlan) IsMaxParallelTaskLimitReached() bool { + return len(in.Status.CurrentTasks) >= in.Spec.MaxParallelTasks +} + +// GetServiceAccountName retrieves a ServiceAccount name that would go along with this plan. +func (in *ClustercodePlan) GetServiceAccountName() string { + return in.Name + "-clustercode" } diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index 4a62f95c..9772c9ce 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -4,6 +4,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +func init() { + SchemeBuilder.Register(&ClustercodeTask{}, &ClustercodeTaskList{}) +} + type ( // +kubebuilder:object:root=true // +kubebuilder:subresource:status @@ -32,18 +36,17 @@ type ( // EncodingTaskSpec defines the desired state of ClustercodeTask. ClustercodeTaskSpec struct { - SourceUrl string `json:"sourceUrl,omitempty"` - TargetUrl string `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec EncodeSpec `json:"encodeSpec"` + StorageSpec StorageSpec `json:"storageSpec,omitempty"` + SourceUrl string `json:"sourceUrl,omitempty"` + TargetUrl string `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec"` } ClustercodeTaskStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` - SliceCount int `json:"sliceCount,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + SlicesPlanned int `json:"slicesPlanned,omitempty"` + SlicesScheduled int `json:"slicesSchedules,omitempty"` + SlicesFinished int `json:"slicesFinished,omitempty"` } ) - -func init() { - SchemeBuilder.Register(&ClustercodeTask{}, &ClustercodeTaskList{}) -} diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go new file mode 100644 index 00000000..33ca7e64 --- /dev/null +++ b/api/v1alpha1/common.go @@ -0,0 +1,26 @@ +package v1alpha1 + +type ( + StorageSpec struct { + SourcePvc ClusterCodeVolumeRef `json:"sourcePvc"` + IntermediatePvc ClusterCodeVolumeRef `json:"intermediatePvc"` + TargetPvc ClusterCodeVolumeRef `json:"targetPvc"` + } + ClusterCodeVolumeRef struct { + // +kubebuilder:validation:Required + ClaimName string `json:"claimName"` + SubPath string `json:"subPath,omitempty"` + } + EncodeSpec struct { + // +kubebuilder:default=-y;-hide_banner;-nostats + DefaultCommandArgs []string `json:"defaultCommandArgs"` + // +kubebuilder:default=-i;"\"${INPUT}\"";-c;copy;-map;0;-segment_time;"\"${SLICE_SIZE}\"";-f;segment;"\"${OUTPUT}\"" + SplitCommandArgs []string `json:"splitCommandArgs"` + // +kubebuilder:default=-i;"\"${INPUT}\"";"-c:v";copy;"-c:a";copy;"\"${OUTPUT}\"" + TranscodeCommandArgs []string `json:"transcodeCommandArgs"` + // +kubebuilder:default=-f;concat;-i;concat.txt;-c;copy;media_out.mkv + MergeCommandArgs []string `json:"mergeCommandArgs"` + + SliceSize int `json:"sliceSize,omitempty"` + } +) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1d30035f..4ee9f1b9 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -24,6 +24,21 @@ func (in *ClusterCodeTaskRef) DeepCopy() *ClusterCodeTaskRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterCodeVolumeRef) DeepCopyInto(out *ClusterCodeVolumeRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterCodeVolumeRef. +func (in *ClusterCodeVolumeRef) DeepCopy() *ClusterCodeVolumeRef { + if in == nil { + return nil + } + out := new(ClusterCodeVolumeRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodePlan) DeepCopyInto(out *ClustercodePlan) { *out = *in @@ -86,6 +101,7 @@ func (in *ClustercodePlanList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodePlanSpec) DeepCopyInto(out *ClustercodePlanSpec) { *out = *in + out.Storage = in.Storage in.ScanSpec.DeepCopyInto(&out.ScanSpec) in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } @@ -189,6 +205,7 @@ func (in *ClustercodeTaskList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodeTaskSpec) DeepCopyInto(out *ClustercodeTaskSpec) { *out = *in + out.StorageSpec = in.StorageSpec in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } @@ -278,3 +295,21 @@ func (in *ScanSpec) DeepCopy() *ScanSpec { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StorageSpec) DeepCopyInto(out *StorageSpec) { + *out = *in + out.SourcePvc = in.SourcePvc + out.IntermediatePvc = in.IntermediatePvc + out.TargetPvc = in.TargetPvc +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StorageSpec. +func (in *StorageSpec) DeepCopy() *StorageSpec { + if in == nil { + return nil + } + out := new(StorageSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/scan.go b/cmd/scan.go index 71a63152..3341c9ce 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -11,6 +11,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/uuid" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" controllerclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -71,7 +72,13 @@ func scanMedia(cmd *cobra.Command, args []string) error { return nil } - files, err := scanSourceForMedia(plan) + tasks, err := getCurrentTasks(plan) + if err != nil { + return err + } + scanLog.Info("get list of current tasks", "tasks", tasks) + existingFiles := mapAndfilterTasks(tasks, plan) + files, err := scanSourceForMedia(plan, existingFiles) if err != nil { return err } @@ -84,14 +91,14 @@ func scanMedia(cmd *cobra.Command, args []string) error { task := &v1alpha1.ClustercodeTask{ ObjectMeta: metav1.ObjectMeta{ Namespace: cfg.Config.Scan.Namespace, - Name: string(uuid.NewUUID()), - Labels: controllers.ClusterCodeLabels, + Name: string(uuid.NewUUID()), + Labels: controllers.ClusterCodeLabels, }, - Spec: v1alpha1.ClustercodeTaskSpec{ + Spec: v1alpha1.ClustercodeTaskSpec{ SourceUrl: files[0], TargetUrl: files[0], - Suspend: false, EncodeSpec: plan.Spec.EncodeSpec, + StorageSpec: plan.Spec.Storage, }, } if err := controllerutil.SetControllerReference(plan, task.GetObjectMeta(), scheme); err != nil { @@ -105,7 +112,39 @@ func scanMedia(cmd *cobra.Command, args []string) error { return nil } -func scanSourceForMedia(plan *v1alpha1.ClustercodePlan) (files []string, funcErr error) { +func mapAndfilterTasks(tasks []v1alpha1.ClustercodeTask, plan *v1alpha1.ClustercodePlan) []string { + + var sourceFiles []string + for _, task := range tasks { + if task.GetDeletionTimestamp() != nil { + continue + } + sourceFiles = append(sourceFiles, task.Spec.SourceUrl) + } + + return sourceFiles +} + +func getCurrentTasks(plan *v1alpha1.ClustercodePlan) ([]v1alpha1.ClustercodeTask, error) { + list := v1alpha1.ClustercodeTaskList{} + err := client.List(context.Background(), &list, + controllerclient.MatchingLabels(controllers.ClusterCodeLabels), + controllerclient.InNamespace(plan.Namespace)) + if err != nil { + return list.Items, err + } + var tasks []v1alpha1.ClustercodeTask + for _, task := range list.Items { + for _, owner := range task.GetOwnerReferences() { + if pointer.BoolPtrDerefOr(owner.Controller, false) && owner.Name == plan.Name { + tasks = append(tasks, task) + } + } + } + return list.Items, err +} + +func scanSourceForMedia(plan *v1alpha1.ClustercodePlan, skipFiles []string) (files []string, funcErr error) { root := cfg.Config.Scan.SourceRoot err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -119,6 +158,12 @@ func scanSourceForMedia(plan *v1alpha1.ClustercodePlan) (files []string, funcErr scanLog.V(1).Info("file extension not accepted", "path", path) return nil } + for _, skipFile := range skipFiles { + if skipFile == path { + scanLog.V(1).Info("skipping already queued file", "path", path) + return nil + } + } files = append(files, path) return nil diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index 11b0b769..abd16250 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -122,13 +122,44 @@ spec: type: string type: array type: object - sourcePvcRef: - type: string - sourceVolumeSubdir: - type: string + storage: + properties: + intermediatePvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + sourcePvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + targetPvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + required: + - intermediatePvc + - sourcePvc + - targetPvc + type: object suspend: type: boolean required: + - maxParallelTasks - scanSchedule type: object status: diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 3fccbf13..636d8887 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -108,6 +108,40 @@ spec: type: object sourceUrl: type: string + storageSpec: + properties: + intermediatePvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + sourcePvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + targetPvc: + properties: + claimName: + type: string + subPath: + type: string + required: + - claimName + type: object + required: + - intermediatePvc + - sourcePvc + - targetPvc + type: object suspend: type: boolean targetUrl: @@ -160,7 +194,11 @@ spec: - type type: object type: array - sliceCount: + slicesFinished: + type: integer + slicesPlanned: + type: integer + slicesSchedules: type: integer type: object type: object diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index df5ed54c..9cefbb9f 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -13,6 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/pointer" + "k8s.io/utils/strings" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -113,7 +114,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { }, Image: "localhost:5000/clustercode/operator:e2e", VolumeMounts: []corev1.VolumeMount{ - {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.SourceVolumeSubdir}, + {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, }, }, }, @@ -122,7 +123,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { Name: "source", VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: rc.plan.Spec.SourcePvcRef, + ClaimName: rc.plan.Spec.Storage.SourcePvc.ClaimName, }, }, }, @@ -180,10 +181,10 @@ func (r *ClustercodePlanReconciler) createServiceAccountAndBinding(rc *Clusterco } func (r *ClustercodePlanReconciler) newRbacDefinition(rc *ClustercodePlanContext) (rbacv1.RoleBinding, corev1.ServiceAccount) { - saName := rc.plan.Name + "-clustercode" + saName := rc.plan.GetServiceAccountName() roleBinding := rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: saName + "-rolebinding", + Name: strings.ShortenString(saName, 51) + "-rolebinding", Namespace: rc.plan.Namespace, Labels: ClusterCodeLabels, }, diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go index dddab350..28714f16 100644 --- a/controllers/clustercodetask_controller.go +++ b/controllers/clustercodetask_controller.go @@ -2,15 +2,21 @@ package controllers import ( "context" + "strconv" "time" "github.com/go-logr/logr" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/ccremer/clustercode/api/v1alpha1" @@ -27,6 +33,7 @@ type ( ClustercodeTaskContext struct { ctx context.Context task *v1alpha1.ClustercodeTask + plan *v1alpha1.ClustercodePlan log logr.Logger } ) @@ -60,7 +67,90 @@ func (r *ClustercodeTaskReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } rc.log = r.Log.WithValues("task", req.NamespacedName) - //r.handleTask(rc) + + if err := r.handleTask(rc); err != nil { + return ctrl.Result{}, err + } rc.log.Info("reconciled task") return ctrl.Result{}, nil } + +func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error { + rc.plan = &v1alpha1.ClustercodePlan{} + if err := r.Client.Get(rc.ctx, r.getOwner(rc), rc.plan); err != nil { + return err + } + variables := map[string]string{ + "${INPUT}": rc.task.Spec.SourceUrl, + "${OUTPUT}": rc.task.Spec.TargetUrl, + "${SLICE_SIZE}": strconv.Itoa(rc.task.Spec.EncodeSpec.SliceSize), + } + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: rc.task.Name + "-split", + Namespace: rc.task.Namespace, + Labels: ClusterCodeLabels, + }, + Spec: batchv1.JobSpec{ + BackoffLimit: pointer.Int32Ptr(0), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: rc.plan.GetServiceAccountName(), + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "ffmpeg", + Image: "docker.io/jrottenberg/ffmpeg:4.1-alpine", + ImagePullPolicy: corev1.PullIfNotPresent, + Args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.SplitCommandArgs), + VolumeMounts: []corev1.VolumeMount{ + {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, + {Name: "intermediate", MountPath: "/clustercode/intermediate", SubPath: rc.plan.Spec.Storage.IntermediatePvc.SubPath}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: "source", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: rc.plan.Spec.Storage.SourcePvc.ClaimName, + }, + }, + }, + { + Name: "intermediate", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: rc.plan.Spec.Storage.IntermediatePvc.ClaimName, + }, + }, + }, + }, + }, + }, + }, + } + if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { + rc.log.Info("could not set controller reference, deleting the task won't delete the job", "err", err.Error()) + } + if err := r.Client.Create(rc.ctx, job); err != nil { + if apierrors.IsAlreadyExists(err) { + rc.log.Info("skip creating job, it already exists", "job", job.Name) + } else { + rc.log.Error(err, "could not create job", "job", job.Name) + } + } else { + rc.log.Info("job created", "job", job.Name) + } + return nil +} + +func (r *ClustercodeTaskReconciler) getOwner(rc *ClustercodeTaskContext) types.NamespacedName { + for _, owner := range rc.task.GetOwnerReferences() { + if pointer.BoolPtrDerefOr(owner.Controller, false) { + return types.NamespacedName{Namespace: rc.task.Namespace, Name: owner.Name} + } + } + return types.NamespacedName{} +} diff --git a/controllers/utils.go b/controllers/utils.go index ad21ae2a..400702fa 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -1,5 +1,7 @@ package controllers +import "strings" + var( ClusterCodeLabels = map[string]string { "app.kubernetes.io/managed-by": "clustercode", @@ -15,3 +17,15 @@ func mergeLabels(labels ...map[string]string) map[string]string { } return merged } + +func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]string) (merged []string) { + for _, args := range argsList { + for _, arg := range args { + for k, v := range variables { + arg = strings.ReplaceAll(arg, k, v) + } + merged = append(merged, arg) + } + } + return merged +} diff --git a/e2e/test2/plan.yaml b/e2e/test2/plan.yaml index 9b679e73..6658cba7 100644 --- a/e2e/test2/plan.yaml +++ b/e2e/test2/plan.yaml @@ -4,12 +4,21 @@ metadata: name: test-plan spec: scanSchedule: "*/1 * * * *" - sourcePvcRef: test2-claim - sourceVolumeSubdir: source + storage: + sourcePvc: + claimName: test2-claim + subPath: source + intermediatePvc: + claimName: test2-claim + subPath: intermediate + targetPvc: + claimName: test2-claim + subPath: target scanSpec: mediaFileExtensions: - mp4 encodeSpec: + slizeSize: 20 defaultCommandArgs: - -y - -hide_banner @@ -22,7 +31,7 @@ spec: - map - "0" - -segment_time - - ${SLIZE_SIZE} + - ${SLICE_SIZE} - -f - segment - ${OUTPUT} From 3ad5c0b0d810015b7a90de80bef56d4492829360 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 24 Dec 2020 17:14:06 +0100 Subject: [PATCH 09/13] Splitting with Ffmpeg now works --- .gitignore | 4 -- api/v1alpha1/clustercodeplan_types.go | 2 +- api/v1alpha1/clustercodetask_types.go | 10 ++--- api/v1alpha1/common.go | 40 +++++++++++++++++ cfg/config.go | 1 + cmd/scan.go | 20 ++++++--- ...lustercode.github.io_clustercodeplans.yaml | 1 - controllers/clustercodeplan_controller.go | 24 ++++++++-- controllers/clustercodetask_controller.go | 44 +++++++++++++------ controllers/utils.go | 21 +++++++-- data/intermediate/.gitignore | 2 + data/source/.gitignore | 2 + data/source/.keep | 0 data/source/movie.mp4 | 0 data/target/.gitignore | 2 + e2e/test2/plan.yaml | 8 ++-- e2e/test2/pv.yaml | 17 ++++++- e2e/test2/pvc.yaml | 21 ++++++++- 18 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 data/intermediate/.gitignore create mode 100644 data/source/.gitignore delete mode 100644 data/source/.keep delete mode 100644 data/source/movie.mp4 create mode 100644 data/target/.gitignore diff --git a/.gitignore b/.gitignore index 295dfb48..0e673ff0 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,4 @@ node_modules/ e2e/debug __debug_bin -# project -/tmp/ -/input/ -/output/ diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 5b5bfa4f..7927878e 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -40,7 +40,7 @@ type ( // +kubebuilder:validation:Required Storage StorageSpec `json:"storage,omitempty"` // +kubebuilder:default=1 - MaxParallelTasks int `json:"maxParallelTasks"` + MaxParallelTasks int `json:"maxParallelTasks,omitempty"` Suspend bool `json:"suspend,omitempty"` diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index 9772c9ce..0ea9a651 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -36,11 +36,11 @@ type ( // EncodingTaskSpec defines the desired state of ClustercodeTask. ClustercodeTaskSpec struct { - StorageSpec StorageSpec `json:"storageSpec,omitempty"` - SourceUrl string `json:"sourceUrl,omitempty"` - TargetUrl string `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec EncodeSpec `json:"encodeSpec"` + StorageSpec StorageSpec `json:"storageSpec,omitempty"` + SourceUrl ClusterCodeUrl `json:"sourceUrl,omitempty"` + TargetUrl ClusterCodeUrl `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec"` } ClustercodeTaskStatus struct { diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 33ca7e64..645bbeaf 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -1,5 +1,13 @@ package v1alpha1 +import ( + "fmt" + "net/url" + "strings" + + "k8s.io/apimachinery/pkg/util/runtime" +) + type ( StorageSpec struct { SourcePvc ClusterCodeVolumeRef `json:"sourcePvc"` @@ -23,4 +31,36 @@ type ( SliceSize int `json:"sliceSize,omitempty"` } + ClusterCodeUrl string ) + +func ToUrl(root, path string) ClusterCodeUrl { + newUrl, err := url.Parse(fmt.Sprintf("cc://%s/%s", root, strings.Replace(path, root, "", 1))) + runtime.Must(err) + return ClusterCodeUrl(newUrl.String()) +} + +func (u ClusterCodeUrl) GetRoot() string { + parsed, err := url.Parse(string(u)) + if err != nil { + return "" + } + return parsed.Host +} + +func (u ClusterCodeUrl) GetPath() string { + parsed, err := url.Parse(string(u)) + if err != nil { + return "" + } + return parsed.Path +} + +func (u ClusterCodeUrl) StripSubPath(subpath string) string { + path := u.GetPath() + return strings.Replace(path, subpath, "", 1) +} + +func (u ClusterCodeUrl) String() string { + return string(u) +} diff --git a/cfg/config.go b/cfg/config.go index 7456116a..02ce5ad1 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -23,6 +23,7 @@ type ( ClustercodePlanName string `koanf:"clustercode-plan-name"` Namespace string `koanf:"namespace"` SourceRoot string `koanf:"source-root"` + TargetRoot string `koanf:"target-root"` } ) diff --git a/cmd/scan.go b/cmd/scan.go index 3341c9ce..ce93d346 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -77,7 +77,7 @@ func scanMedia(cmd *cobra.Command, args []string) error { return err } scanLog.Info("get list of current tasks", "tasks", tasks) - existingFiles := mapAndfilterTasks(tasks, plan) + existingFiles := mapAndFilterTasks(tasks, plan) files, err := scanSourceForMedia(plan, existingFiles) if err != nil { return err @@ -88,6 +88,8 @@ func scanMedia(cmd *cobra.Command, args []string) error { return nil } + selectedFile, err := filepath.Rel(filepath.Join(cfg.Config.Scan.SourceRoot, controllers.SourceSubMountPath), files[0]) + task := &v1alpha1.ClustercodeTask{ ObjectMeta: metav1.ObjectMeta{ Namespace: cfg.Config.Scan.Namespace, @@ -95,9 +97,9 @@ func scanMedia(cmd *cobra.Command, args []string) error { Labels: controllers.ClusterCodeLabels, }, Spec: v1alpha1.ClustercodeTaskSpec{ - SourceUrl: files[0], - TargetUrl: files[0], - EncodeSpec: plan.Spec.EncodeSpec, + SourceUrl: v1alpha1.ToUrl(controllers.SourceSubMountPath, selectedFile), + TargetUrl: v1alpha1.ToUrl(controllers.TargetSubMountPath, selectedFile), + EncodeSpec: plan.Spec.EncodeSpec, StorageSpec: plan.Spec.Storage, }, } @@ -112,19 +114,23 @@ func scanMedia(cmd *cobra.Command, args []string) error { return nil } -func mapAndfilterTasks(tasks []v1alpha1.ClustercodeTask, plan *v1alpha1.ClustercodePlan) []string { +func mapAndFilterTasks(tasks []v1alpha1.ClustercodeTask, plan *v1alpha1.ClustercodePlan) []string { var sourceFiles []string for _, task := range tasks { if task.GetDeletionTimestamp() != nil { continue } - sourceFiles = append(sourceFiles, task.Spec.SourceUrl) + sourceFiles = append(sourceFiles, getAbsolutePath(task.Spec.SourceUrl)) } return sourceFiles } +func getAbsolutePath(uri v1alpha1.ClusterCodeUrl) string { + return filepath.Join(cfg.Config.Scan.SourceRoot, uri.GetRoot(), uri.GetPath()) +} + func getCurrentTasks(plan *v1alpha1.ClustercodePlan) ([]v1alpha1.ClustercodeTask, error) { list := v1alpha1.ClustercodeTaskList{} err := client.List(context.Background(), &list, @@ -145,7 +151,7 @@ func getCurrentTasks(plan *v1alpha1.ClustercodePlan) ([]v1alpha1.ClustercodeTask } func scanSourceForMedia(plan *v1alpha1.ClustercodePlan, skipFiles []string) (files []string, funcErr error) { - root := cfg.Config.Scan.SourceRoot + root := filepath.Join(cfg.Config.Scan.SourceRoot, controllers.SourceSubMountPath) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { // could not access file, let's prevent a panic diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index abd16250..810c8c2d 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -159,7 +159,6 @@ spec: suspend: type: boolean required: - - maxParallelTasks - scanSchedule type: object status: diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index 9cefbb9f..703c6dfc 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "path/filepath" "time" "github.com/go-logr/logr" @@ -84,7 +85,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { ObjectMeta: metav1.ObjectMeta{ Name: rc.plan.Name + "-scan-job", Namespace: rc.plan.Namespace, - Labels: ClusterCodeLabels, + Labels: mergeLabels(ClusterCodeLabels, ClusterCodeScanLabels), }, Spec: v1beta1.CronJobSpec{ Schedule: rc.plan.Spec.ScanSchedule, @@ -114,19 +115,36 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { }, Image: "localhost:5000/clustercode/operator:e2e", VolumeMounts: []corev1.VolumeMount{ - {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, + { + Name: SourceSubMountPath, + MountPath: filepath.Join("/clustercode", SourceSubMountPath), + SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath, + }, + { + Name: IntermediateSubMountPath, + MountPath: filepath.Join("/clustercode", IntermediateSubMountPath), + SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath, + }, }, }, }, Volumes: []corev1.Volume{ { - Name: "source", + Name: SourceSubMountPath, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: rc.plan.Spec.Storage.SourcePvc.ClaimName, }, }, }, + { + Name: IntermediateSubMountPath, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: rc.plan.Spec.Storage.IntermediatePvc.ClaimName, + }, + }, + }, }, }, }, diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go index 28714f16..fe69fe9f 100644 --- a/controllers/clustercodetask_controller.go +++ b/controllers/clustercodetask_controller.go @@ -2,6 +2,7 @@ package controllers import ( "context" + "path/filepath" "strconv" "time" @@ -76,20 +77,40 @@ func (r *ClustercodeTaskReconciler) Reconcile(ctx context.Context, req ctrl.Requ } func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error { + if rc.task.Status.SlicesPlanned == 0 { + return r.createSplitJob(rc) + } + + return nil +} + +func (r *ClustercodeTaskReconciler) getOwner(rc *ClustercodeTaskContext) types.NamespacedName { + for _, owner := range rc.task.GetOwnerReferences() { + if pointer.BoolPtrDerefOr(owner.Controller, false) { + return types.NamespacedName{Namespace: rc.task.Namespace, Name: owner.Name} + } + } + return types.NamespacedName{} +} + +func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) error { + rc.plan = &v1alpha1.ClustercodePlan{} if err := r.Client.Get(rc.ctx, r.getOwner(rc), rc.plan); err != nil { return err } + sourceMountRoot := filepath.Join("/clustercode)", SourceSubMountPath) + intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) variables := map[string]string{ - "${INPUT}": rc.task.Spec.SourceUrl, - "${OUTPUT}": rc.task.Spec.TargetUrl, + "${INPUT}": filepath.Join(sourceMountRoot, rc.task.Spec.SourceUrl.GetPath()), + "${OUTPUT}": getSegmentFileNameTemplate(rc, intermediateMountRoot), "${SLICE_SIZE}": strconv.Itoa(rc.task.Spec.EncodeSpec.SliceSize), } job := &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: rc.task.Name + "-split", Namespace: rc.task.Namespace, - Labels: ClusterCodeLabels, + Labels: mergeLabels(ClusterCodeLabels, ClusterCodeSplitLabels), }, Spec: batchv1.JobSpec{ BackoffLimit: pointer.Int32Ptr(0), @@ -104,14 +125,14 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error ImagePullPolicy: corev1.PullIfNotPresent, Args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.SplitCommandArgs), VolumeMounts: []corev1.VolumeMount{ - {Name: "source", MountPath: "/clustercode/source", SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, - {Name: "intermediate", MountPath: "/clustercode/intermediate", SubPath: rc.plan.Spec.Storage.IntermediatePvc.SubPath}, + {Name: SourceSubMountPath, MountPath: sourceMountRoot, SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, + {Name: IntermediateSubMountPath, MountPath: intermediateMountRoot, SubPath: rc.plan.Spec.Storage.IntermediatePvc.SubPath}, }, }, }, Volumes: []corev1.Volume{ { - Name: "source", + Name: SourceSubMountPath, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: rc.plan.Spec.Storage.SourcePvc.ClaimName, @@ -119,7 +140,7 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error }, }, { - Name: "intermediate", + Name: IntermediateSubMountPath, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: rc.plan.Spec.Storage.IntermediatePvc.ClaimName, @@ -146,11 +167,6 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error return nil } -func (r *ClustercodeTaskReconciler) getOwner(rc *ClustercodeTaskContext) types.NamespacedName { - for _, owner := range rc.task.GetOwnerReferences() { - if pointer.BoolPtrDerefOr(owner.Controller, false) { - return types.NamespacedName{Namespace: rc.task.Namespace, Name: owner.Name} - } - } - return types.NamespacedName{} +func getSegmentFileNameTemplate(rc *ClustercodeTaskContext, intermediateMountRoot string) string { + return filepath.Join(intermediateMountRoot, rc.task.Name+"_%d"+filepath.Ext(rc.task.Spec.SourceUrl.GetPath())) } diff --git a/controllers/utils.go b/controllers/utils.go index 400702fa..94914e80 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -2,10 +2,25 @@ package controllers import "strings" -var( - ClusterCodeLabels = map[string]string { +var ( + ClusterCodeLabels = map[string]string{ "app.kubernetes.io/managed-by": "clustercode", } + ClusterCodeScanLabels = map[string]string { + "clustercode.github.io/type": "scan", + } + ClusterCodeSplitLabels = map[string]string { + "clustercode.github.io/type": "split", + } + ClusterCodeCountLabels = map[string]string { + "clustercode.github.io/type": "count", + } +) + +const ( + SourceSubMountPath = "source" + TargetSubMountPath = "target" + IntermediateSubMountPath = "intermediate" ) func mergeLabels(labels ...map[string]string) map[string]string { @@ -18,7 +33,7 @@ func mergeLabels(labels ...map[string]string) map[string]string { return merged } -func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]string) (merged []string) { +func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]string) (merged []string) { for _, args := range argsList { for _, arg := range args { for k, v := range variables { diff --git a/data/intermediate/.gitignore b/data/intermediate/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/intermediate/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/data/source/.gitignore b/data/source/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/source/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/data/source/.keep b/data/source/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/source/movie.mp4 b/data/source/movie.mp4 deleted file mode 100644 index e69de29b..00000000 diff --git a/data/target/.gitignore b/data/target/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/data/target/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/e2e/test2/plan.yaml b/e2e/test2/plan.yaml index 6658cba7..b681d689 100644 --- a/e2e/test2/plan.yaml +++ b/e2e/test2/plan.yaml @@ -6,10 +6,10 @@ spec: scanSchedule: "*/1 * * * *" storage: sourcePvc: - claimName: test2-claim + claimName: test2-claim1 subPath: source intermediatePvc: - claimName: test2-claim + claimName: test2-claim2 subPath: intermediate targetPvc: claimName: test2-claim @@ -18,7 +18,7 @@ spec: mediaFileExtensions: - mp4 encodeSpec: - slizeSize: 20 + sliceSize: 2 defaultCommandArgs: - -y - -hide_banner @@ -28,7 +28,7 @@ spec: - ${INPUT} - -c - copy - - map + - -map - "0" - -segment_time - ${SLICE_SIZE} diff --git a/e2e/test2/pv.yaml b/e2e/test2/pv.yaml index a6b75e1b..a863bc4d 100644 --- a/e2e/test2/pv.yaml +++ b/e2e/test2/pv.yaml @@ -1,7 +1,22 @@ +--- apiVersion: v1 kind: PersistentVolume metadata: - name: e2e-testdata + name: e2e-testdata1 +spec: + capacity: + storage: 1Gi + accessModes: + - ReadWriteMany + storageClassName: hostpath + hostPath: + path: /pv/data + type: Directory +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: e2e-testdata2 spec: capacity: storage: 1Gi diff --git a/e2e/test2/pvc.yaml b/e2e/test2/pvc.yaml index c6bed10f..5f1a8bf2 100644 --- a/e2e/test2/pvc.yaml +++ b/e2e/test2/pvc.yaml @@ -1,7 +1,26 @@ +--- apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: test2-claim + name: test2-claim1 +spec: + accessModes: + - ReadWriteMany + volumeMode: Filesystem + resources: + requests: + storage: 1Gi + storageClassName: hostpath + selector: + matchLabels: + app.kubernetes.io/instance: test2 + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: e2e +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: test2-claim2 spec: accessModes: - ReadWriteMany From 06bdd2d4f431162c5ff4d4450e2cdb3bebefd3aa Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 25 Dec 2020 05:03:46 +0100 Subject: [PATCH 10/13] We can now count the number of ffmpeg segments created --- api/v1alpha1/clustercodetask_types.go | 13 +- api/v1alpha1/zz_generated.deepcopy.go | 2 +- cfg/config.go | 21 +- cmd/count.go | 142 +++++++++++++ cmd/operate.go | 11 + cmd/root.go | 4 + cmd/scan.go | 25 +-- ...lustercode.github.io_clustercodetasks.yaml | 8 +- config/rbac/clustercodeplan_editor_role.yaml | 12 ++ config/rbac/role.yaml | 12 ++ controllers/clustercodeplan_controller.go | 7 +- controllers/clustercodetask_controller.go | 36 ++-- controllers/job_controller.go | 189 ++++++++++++++++++ controllers/utils.go | 54 +++-- e2e/test1/deployment.yaml | 3 +- main.go | 2 + 16 files changed, 473 insertions(+), 68 deletions(-) create mode 100644 cmd/count.go create mode 100644 controllers/job_controller.go diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index 0ea9a651..b93fd31c 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -36,11 +36,14 @@ type ( // EncodingTaskSpec defines the desired state of ClustercodeTask. ClustercodeTaskSpec struct { - StorageSpec StorageSpec `json:"storageSpec,omitempty"` - SourceUrl ClusterCodeUrl `json:"sourceUrl,omitempty"` - TargetUrl ClusterCodeUrl `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec EncodeSpec `json:"encodeSpec"` + TaskId string `json:"taskId,omitempty"` + Storage StorageSpec `json:"storage,omitempty"` + SourceUrl ClusterCodeUrl `json:"sourceUrl,omitempty"` + TargetUrl ClusterCodeUrl `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + FileListConfigMapRef string `json:"fileListConfigMapRef,omitempty"` } ClustercodeTaskStatus struct { diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4ee9f1b9..1cd1fa84 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -205,7 +205,7 @@ func (in *ClustercodeTaskList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodeTaskSpec) DeepCopyInto(out *ClustercodeTaskSpec) { *out = *in - out.StorageSpec = in.StorageSpec + out.Storage = in.Storage in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } diff --git a/cfg/config.go b/cfg/config.go index 02ce5ad1..3122275d 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -3,16 +3,20 @@ package cfg // Configuration holds a strongly-typed tree of the configuration type ( Configuration struct { - Operator OperatorConfig - Scan ScanConfig - Log LogConfig + Operator OperatorConfig + Scan ScanConfig + Log LogConfig + Count CountConfig + Namespace string `koanf:"namespace"` } OperatorConfig struct { MetricsBindAddress string `koanf:"metrics-bind-address"` // Enabling this will ensure there is only one active controller manager. - EnableLeaderElection bool `koanf:"enable-leader-election"` - WatchNamespace string `koanf:"watch-namespace"` + EnableLeaderElection bool `koanf:"enable-leader-election"` + WatchNamespace string `koanf:"watch-namespace"` + ClustercodeContainerImage string `koanf:"clustercode-image"` + FfmpegContainerImage string `koanf:"ffmpeg-image"` } LogConfig struct { Debug bool `koanf:"debug"` @@ -21,10 +25,12 @@ type ( RoleKind string `koanf:"role-kind"` RoleName string `koanf:"role-name"` ClustercodePlanName string `koanf:"clustercode-plan-name"` - Namespace string `koanf:"namespace"` SourceRoot string `koanf:"source-root"` TargetRoot string `koanf:"target-root"` } + CountConfig struct { + TaskName string `koanf:"task-name"` + } ) var ( @@ -40,7 +46,8 @@ const ( func NewDefaultConfig() *Configuration { return &Configuration{ Operator: OperatorConfig{ - MetricsBindAddress: ":9090", + MetricsBindAddress: ":9090", + FfmpegContainerImage: "docker.io/jrottenberg/ffmpeg:4.1-alpine", }, Scan: ScanConfig{ SourceRoot: "/clustercode", diff --git a/cmd/count.go b/cmd/count.go new file mode 100644 index 00000000..a5a1bb76 --- /dev/null +++ b/cmd/count.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" + "github.com/ccremer/clustercode/controllers" +) + +// countCmd represents the count command +var ( + countCmd = &cobra.Command{ + Use: "count", + Short: "counts the number of generated intermediary media files", + PreRunE: validateCountCmd, + RunE: runCountCmd, + } + countLog = ctrl.Log.WithName("count") +) + +func init() { + rootCmd.AddCommand(countCmd) + + countCmd.PersistentFlags().String("count.task-name", cfg.Config.Count.TaskName, "ClustercodeTask Name") +} + +func validateCountCmd(cmd *cobra.Command, args []string) error { + if cfg.Config.Count.TaskName == "" { + return fmt.Errorf("'%s' cannot be empty", "count.task-name") + } + if cfg.Config.Namespace == "" { + return fmt.Errorf("'%s' cannot be empty", "namespace") + } + return nil +} + +func runCountCmd(cmd *cobra.Command, args []string) error { + + registerScheme() + err := createClient() + if err != nil { + return err + } + task, err := getClustercodeTask() + if err != nil { + return err + } + countLog.Info("found task", "task", task) + + files, err := scanSegmentFiles(task.Spec.TaskId + "_") + if err != nil { + return err + } + countLog.Info("found segments", "count", len(files)) + + err = createFileList(files, task) + if err != nil { + return err + } + + err = updateTask(task, len(files)) + if err != nil { + return err + } + countLog.Info("updated task") + + return nil +} + +func updateTask(task *v1alpha1.ClustercodeTask, count int) error { + task.Status.SlicesPlanned = count + return client.Status().Update(context.Background(), task) +} + +func createFileList(files []string, task *v1alpha1.ClustercodeTask) error { + fileList := strings.Join(files, "\n") + cm := &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: task.Spec.FileListConfigMapRef, + Namespace: task.Namespace, + Labels: labels.Merge(controllers.ClusterCodeLabels, controllers.JobIdLabel(task.Spec.TaskId)), + }, + Data: map[string]string{ + "file-list.txt": fileList, + }, + } + if err := controllerutil.SetControllerReference(task, cm.GetObjectMeta(), scheme); err != nil { + countLog.Error(err, "could not set controller reference. Deleting the task might not delete this config map") + } + if err := client.Create(context.Background(), cm); err != nil { + return fmt.Errorf("could not create config map: %w", err) + } else { + countLog.Info("created config map", "configmap", cm.Name) + } + return nil +} + +func scanSegmentFiles(prefix string) (files []string, funcErr error) { + root := filepath.Join(cfg.Config.Scan.SourceRoot, controllers.IntermediateSubMountPath) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + // could not access file, let's prevent a panic + return err + } + if info.IsDir() { + return nil + } + if !strings.HasPrefix(filepath.Base(path), prefix) { + return nil + } + files = append(files, path) + return nil + }) + return files, err +} + +func getClustercodeTask() (*v1alpha1.ClustercodeTask, error) { + ctx := context.Background() + task := &v1alpha1.ClustercodeTask{} + name := types.NamespacedName{ + Name: cfg.Config.Count.TaskName, + Namespace: cfg.Config.Namespace, + } + err := client.Get(ctx, name, task) + if err != nil { + return &v1alpha1.ClustercodeTask{}, err + } + return task, nil +} diff --git a/cmd/operate.go b/cmd/operate.go index 25a0b08e..848a618e 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -27,6 +27,10 @@ func init() { "Prometheus metrics bind address.") operateCmd.PersistentFlags().String("operator.watch-namespace", cfg.Config.Operator.WatchNamespace, "Restrict watching objects to the specified namespace. Watches all namespaces if left empty.") + operateCmd.PersistentFlags().String("operator.clustercode-image", cfg.Config.Operator.ClustercodeContainerImage, + "Container image to be used when launching Clustercode jobs.") + operateCmd.PersistentFlags().String("operator.ffmpeg-image", cfg.Config.Operator.FfmpegContainerImage, + "Container image to be used when launching Ffmpeg jobs.") } func startOperator(cmd *cobra.Command, args []string) error { @@ -58,6 +62,13 @@ func startOperator(cmd *cobra.Command, args []string) error { }).SetupWithManager(mgr); err != nil { return fmt.Errorf("unable to create controller '%s': %w", "clustercodetask", err) } + if err = (&controllers.JobReconciler{ + Client: mgr.GetClient(), + Log: ctrl.Log.WithName("controllers").WithName("job"), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create controller '%s': %w", "job", err) + } // +kubebuilder:scaffold:builder if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { diff --git a/cmd/root.go b/cmd/root.go index 787d421a..da9c83a5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/pflag" "go.uber.org/zap/zapcore" ctrl "sigs.k8s.io/controller-runtime" + controllerclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log/zap" "github.com/ccremer/clustercode/cfg" @@ -33,6 +34,8 @@ var ( // Global koanfInstance instance. Use . as the key path delimiter. koanfInstance = koanf.New(".") version = "undefined" + // client is the K8s client for client commands + client controllerclient.Client ) // Execute adds all child commands to the root command and sets flags appropriately. @@ -53,6 +56,7 @@ func initRootConfig() { func init() { rootCmd.PersistentFlags().BoolP("log.debug", "v", cfg.Config.Log.Debug, "Enable debug log") + rootCmd.PersistentFlags().StringP("namespace", "n", cfg.Config.Namespace, "Clustercode Namespace for certain commands") cobra.OnInitialize(initRootConfig) } diff --git a/cmd/scan.go b/cmd/scan.go index ce93d346..d9a77ab1 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -30,16 +30,14 @@ var ( RunE: scanMedia, } scanLog = ctrl.Log.WithName("scan") - // client is the K8s client for scan command - client controllerclient.Client ) func validateScanCmd(cmd *cobra.Command, args []string) error { if cfg.Config.Scan.ClustercodePlanName == "" { return fmt.Errorf("'%s' cannot be empty", "scan.clustercode-plan-name") } - if cfg.Config.Scan.Namespace == "" { - return fmt.Errorf("'%s' cannot be empty", "scan.namespace") + if cfg.Config.Namespace == "" { + return fmt.Errorf("'%s' cannot be empty", "namespace") } if !(cfg.Config.Scan.RoleKind == cfg.ClusterRole || cfg.Config.Scan.RoleKind == cfg.Role) { return fmt.Errorf("scan.role-kind (%s) is not in %s", cfg.Config.Scan.RoleKind, []string{cfg.ClusterRole, cfg.Role}) @@ -51,7 +49,6 @@ func init() { rootCmd.AddCommand(scanCmd) scanCmd.PersistentFlags().String("scan.clustercode-plan-name", cfg.Config.Scan.ClustercodePlanName, "Clustercode Plan name (namespace/name)") - scanCmd.PersistentFlags().StringP("scan.namespace", "n", cfg.Config.Scan.Namespace, "Namespace") } func scanMedia(cmd *cobra.Command, args []string) error { @@ -90,17 +87,21 @@ func scanMedia(cmd *cobra.Command, args []string) error { selectedFile, err := filepath.Rel(filepath.Join(cfg.Config.Scan.SourceRoot, controllers.SourceSubMountPath), files[0]) + taskId := string(uuid.NewUUID()) task := &v1alpha1.ClustercodeTask{ ObjectMeta: metav1.ObjectMeta{ - Namespace: cfg.Config.Scan.Namespace, - Name: string(uuid.NewUUID()), + Namespace: cfg.Config.Namespace, + Name: taskId, Labels: controllers.ClusterCodeLabels, }, Spec: v1alpha1.ClustercodeTaskSpec{ - SourceUrl: v1alpha1.ToUrl(controllers.SourceSubMountPath, selectedFile), - TargetUrl: v1alpha1.ToUrl(controllers.TargetSubMountPath, selectedFile), - EncodeSpec: plan.Spec.EncodeSpec, - StorageSpec: plan.Spec.Storage, + TaskId: taskId, + SourceUrl: v1alpha1.ToUrl(controllers.SourceSubMountPath, selectedFile), + TargetUrl: v1alpha1.ToUrl(controllers.TargetSubMountPath, selectedFile), + EncodeSpec: plan.Spec.EncodeSpec, + Storage: plan.Spec.Storage, + ServiceAccountName: plan.GetServiceAccountName(), + FileListConfigMapRef: taskId + "-slice-list", }, } if err := controllerutil.SetControllerReference(plan, task.GetObjectMeta(), scheme); err != nil { @@ -183,7 +184,7 @@ func getClustercodePlan() (*v1alpha1.ClustercodePlan, error) { plan := &v1alpha1.ClustercodePlan{} name := types.NamespacedName{ Name: cfg.Config.Scan.ClustercodePlanName, - Namespace: cfg.Config.Scan.Namespace, + Namespace: cfg.Config.Namespace, } err := client.Get(ctx, name, plan) if err != nil { diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 636d8887..68ea7a35 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -106,9 +106,13 @@ spec: - splitCommandArgs - transcodeCommandArgs type: object + fileListConfigMapRef: + type: string + serviceAccountName: + type: string sourceUrl: type: string - storageSpec: + storage: properties: intermediatePvc: properties: @@ -146,6 +150,8 @@ spec: type: boolean targetUrl: type: string + taskId: + type: string required: - encodeSpec type: object diff --git a/config/rbac/clustercodeplan_editor_role.yaml b/config/rbac/clustercodeplan_editor_role.yaml index e9a63e2f..2214859a 100644 --- a/config/rbac/clustercodeplan_editor_role.yaml +++ b/config/rbac/clustercodeplan_editor_role.yaml @@ -26,3 +26,15 @@ rules: - get - patch - update + - apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index af7b53f7..63b0a46b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -78,6 +78,18 @@ rules: - get - patch - update +- apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - "" resources: diff --git a/controllers/clustercodeplan_controller.go b/controllers/clustercodeplan_controller.go index 703c6dfc..a602dc43 100644 --- a/controllers/clustercodeplan_controller.go +++ b/controllers/clustercodeplan_controller.go @@ -12,6 +12,7 @@ import ( rbacv1 "k8s.io/api/rbac/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/utils/pointer" "k8s.io/utils/strings" @@ -85,7 +86,7 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { ObjectMeta: metav1.ObjectMeta{ Name: rc.plan.Name + "-scan-job", Namespace: rc.plan.Namespace, - Labels: mergeLabels(ClusterCodeLabels, ClusterCodeScanLabels), + Labels: labels.Merge(ClusterCodeLabels, ClusterCodeScanLabels), }, Spec: v1beta1.CronJobSpec{ Schedule: rc.plan.Spec.ScanSchedule, @@ -110,10 +111,10 @@ func (r *ClustercodePlanReconciler) handlePlan(rc *ClustercodePlanContext) { }, Args: []string{ "scan", - "--scan.namespace=" + rc.plan.Namespace, + "--namespace=" + rc.plan.Namespace, "--scan.clustercode-plan-name=" + rc.plan.Name, }, - Image: "localhost:5000/clustercode/operator:e2e", + Image: cfg.Config.Operator.ClustercodeContainerImage, VolumeMounts: []corev1.VolumeMount{ { Name: SourceSubMountPath, diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go index fe69fe9f..06e9361c 100644 --- a/controllers/clustercodetask_controller.go +++ b/controllers/clustercodetask_controller.go @@ -11,8 +11,8 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" @@ -21,6 +21,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" ) type ( @@ -84,21 +85,8 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error return nil } -func (r *ClustercodeTaskReconciler) getOwner(rc *ClustercodeTaskContext) types.NamespacedName { - for _, owner := range rc.task.GetOwnerReferences() { - if pointer.BoolPtrDerefOr(owner.Controller, false) { - return types.NamespacedName{Namespace: rc.task.Namespace, Name: owner.Name} - } - } - return types.NamespacedName{} -} - func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) error { - rc.plan = &v1alpha1.ClustercodePlan{} - if err := r.Client.Get(rc.ctx, r.getOwner(rc), rc.plan); err != nil { - return err - } sourceMountRoot := filepath.Join("/clustercode)", SourceSubMountPath) intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) variables := map[string]string{ @@ -110,23 +98,23 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e ObjectMeta: metav1.ObjectMeta{ Name: rc.task.Name + "-split", Namespace: rc.task.Namespace, - Labels: mergeLabels(ClusterCodeLabels, ClusterCodeSplitLabels), + Labels: labels.Merge(ClusterCodeLabels, labels.Merge(ClusterCodeSplitLabels, JobIdLabel(rc.task.Spec.TaskId))), }, Spec: batchv1.JobSpec{ BackoffLimit: pointer.Int32Ptr(0), Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - ServiceAccountName: rc.plan.GetServiceAccountName(), + ServiceAccountName: rc.task.Spec.ServiceAccountName, RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { Name: "ffmpeg", - Image: "docker.io/jrottenberg/ffmpeg:4.1-alpine", + Image: cfg.Config.Operator.FfmpegContainerImage, ImagePullPolicy: corev1.PullIfNotPresent, Args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.SplitCommandArgs), VolumeMounts: []corev1.VolumeMount{ - {Name: SourceSubMountPath, MountPath: sourceMountRoot, SubPath: rc.plan.Spec.Storage.SourcePvc.SubPath}, - {Name: IntermediateSubMountPath, MountPath: intermediateMountRoot, SubPath: rc.plan.Spec.Storage.IntermediatePvc.SubPath}, + {Name: SourceSubMountPath, MountPath: sourceMountRoot, SubPath: rc.task.Spec.Storage.SourcePvc.SubPath}, + {Name: IntermediateSubMountPath, MountPath: intermediateMountRoot, SubPath: rc.task.Spec.Storage.IntermediatePvc.SubPath}, }, }, }, @@ -135,7 +123,7 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e Name: SourceSubMountPath, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: rc.plan.Spec.Storage.SourcePvc.ClaimName, + ClaimName: rc.task.Spec.Storage.SourcePvc.ClaimName, }, }, }, @@ -143,7 +131,7 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e Name: IntermediateSubMountPath, VolumeSource: corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: rc.plan.Spec.Storage.IntermediatePvc.ClaimName, + ClaimName: rc.task.Spec.Storage.IntermediatePvc.ClaimName, }, }, }, @@ -167,6 +155,12 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e return nil } +func JobIdLabel(id string) map[string]string { + return map[string]string{ + ClustercodeTaskIdLabelKey: id, + } +} + func getSegmentFileNameTemplate(rc *ClustercodeTaskContext, intermediateMountRoot string) string { return filepath.Join(intermediateMountRoot, rc.task.Name+"_%d"+filepath.Ext(rc.task.Spec.SourceUrl.GetPath())) } diff --git a/controllers/job_controller.go b/controllers/job_controller.go new file mode 100644 index 00000000..7be0822f --- /dev/null +++ b/controllers/job_controller.go @@ -0,0 +1,189 @@ +package controllers + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/go-logr/logr" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" +) + +type ( + // JobReconciler reconciles Job objects + JobReconciler struct { + Client client.Client + Log logr.Logger + Scheme *runtime.Scheme + } + // JobContext holds the parameters of a single reconciliation + JobContext struct { + ctx context.Context + job *batchv1.Job + jobType ClusterCodeJobType + task *v1alpha1.ClustercodeTask + log logr.Logger + } +) + +func (r *JobReconciler) SetupWithManager(mgr ctrl.Manager) error { + pred, err := predicate.LabelSelectorPredicate(metav1.LabelSelector{MatchLabels: ClusterCodeLabels}) + if err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). + For(&batchv1.Job{}, builder.WithPredicates(pred)). + Complete(r) +} + +// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete + +func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + rc := &JobContext{ + job: &batchv1.Job{}, + ctx: ctx, + } + err := r.Client.Get(ctx, req.NamespacedName, rc.job) + if err != nil { + if apierrors.IsNotFound(err) { + r.Log.Info("object not found, ignoring reconcile", "object", req.NamespacedName) + return ctrl.Result{}, nil + } + r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) + return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err + } + rc.log = r.Log.WithValues("job", req.NamespacedName) + jobType, err := rc.getJobType() + if err != nil { + rc.log.V(1).Info("cannot determine job type, ignoring reconcile", "error", err.Error()) + return ctrl.Result{}, nil + } + rc.jobType = jobType + switch jobType { + case ClustercodeTypeSplit: + return ctrl.Result{}, r.handleSplitJob(rc) + } + return ctrl.Result{}, nil +} + +func (r *JobReconciler) handleSplitJob(rc *JobContext) error { + conditions := castConditions(rc.job.Status.Conditions) + rc.log.V(1).Info("job status", "conditions", conditions) + if !meta.IsStatusConditionPresentAndEqual(conditions, string(batchv1.JobComplete), metav1.ConditionTrue) { + rc.log.V(1).Info("job is not completed yet, ignoring reconcile") + return nil + } + + rc.task = &v1alpha1.ClustercodeTask{} + if err := r.Client.Get(rc.ctx, getOwner(rc.job), rc.task); err != nil { + return err + } + + return r.createCountJob(rc) +} + +func (r *JobReconciler) createCountJob(rc *JobContext) error { + + taskId := rc.task.Spec.TaskId + intermediateMountRoot := filepath.Join("/clustercode", IntermediateSubMountPath) + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%.*s-%s", 62-len(ClustercodeTypeCount), taskId, ClustercodeTypeCount), + Namespace: rc.job.Namespace, + Labels: labels.Merge(ClusterCodeLabels, ClusterCodeCountLabels), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: pointer.Int32Ptr(0), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: rc.job.Spec.Template.Spec.ServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "clustercode", + Image: cfg.Config.Operator.ClustercodeContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: []string{ + "-v", + "count", + "--count.task-name=" + rc.task.Name, + "--namespace=" + rc.job.Namespace, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: IntermediateSubMountPath, MountPath: intermediateMountRoot, SubPath: rc.task.Spec.Storage.IntermediatePvc.SubPath}, + }, + }, + }, + Volumes: []corev1.Volume{ + { + Name: IntermediateSubMountPath, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: rc.task.Spec.Storage.IntermediatePvc.ClaimName, + }, + }, + }, + }, + }, + }, + }, + } + if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { + rc.log.Info("could not set controller reference, deleting the task won't delete the job", "err", err.Error()) + } + if err := r.Client.Create(rc.ctx, job); err != nil { + if apierrors.IsAlreadyExists(err) { + rc.log.Info("skip creating job, it already exists", "job", job.Name) + } else { + rc.log.Error(err, "could not create job", "job", job.Name) + return err + } + } else { + rc.log.Info("job created", "job", job.Name) + } + return nil +} + +func (c JobContext) getJobType() (ClusterCodeJobType, error) { + set := labels.Set(c.job.Labels) + if !set.Has(ClustercodeTypeLabelKey) { + return "", fmt.Errorf("missing label key '%s", ClustercodeTypeLabelKey) + } + label := set.Get(ClustercodeTypeLabelKey) + for _, jobType := range ClustercodeTypes { + if label == string(jobType) { + return jobType, nil + } + } + return "", fmt.Errorf("value of label '%s' unrecognized: %s", ClustercodeTypeLabelKey, label) +} + +func castConditions(conditions []batchv1.JobCondition) (converted []metav1.Condition) { + for _, c := range conditions { + converted = append(converted, metav1.Condition{ + Type: string(c.Type), + Status: metav1.ConditionStatus(c.Status), + LastTransitionTime: c.LastTransitionTime, + Reason: c.Reason, + Message: c.Message, + }) + } + return converted +} diff --git a/controllers/utils.go b/controllers/utils.go index 94914e80..a9f9f079 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -1,19 +1,30 @@ package controllers -import "strings" +import ( + "strings" + + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/pointer" +) + +type ( + ClusterCodeJobType string +) var ( - ClusterCodeLabels = map[string]string{ + ClusterCodeLabels = labels.Set{ "app.kubernetes.io/managed-by": "clustercode", } - ClusterCodeScanLabels = map[string]string { - "clustercode.github.io/type": "scan", + ClusterCodeScanLabels = labels.Set{ + ClustercodeTypeLabelKey: string(ClustercodeTypeScan), } - ClusterCodeSplitLabels = map[string]string { - "clustercode.github.io/type": "split", + ClusterCodeSplitLabels = labels.Set{ + ClustercodeTypeLabelKey: string(ClustercodeTypeSplit), } - ClusterCodeCountLabels = map[string]string { - "clustercode.github.io/type": "count", + ClusterCodeCountLabels = labels.Set{ + ClustercodeTypeLabelKey: string(ClustercodeTypeCount), } ) @@ -21,17 +32,17 @@ const ( SourceSubMountPath = "source" TargetSubMountPath = "target" IntermediateSubMountPath = "intermediate" + + ClustercodeTypeLabelKey = "clustercode.github.io/type" + ClustercodeTaskIdLabelKey = "clustercode.github.io/task-id" + ClustercodeTypeScan ClusterCodeJobType = "scan" + ClustercodeTypeSplit ClusterCodeJobType = "split" + ClustercodeTypeCount ClusterCodeJobType = "count" ) -func mergeLabels(labels ...map[string]string) map[string]string { - merged := make(map[string]string) - for _, labelMap := range labels { - for k, v := range labelMap { - merged[k] = v - } - } - return merged -} +var ( + ClustercodeTypes = []ClusterCodeJobType{ClustercodeTypeScan, ClustercodeTypeSplit, ClustercodeTypeCount} +) func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]string) (merged []string) { for _, args := range argsList { @@ -44,3 +55,12 @@ func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]str } return merged } + +func getOwner(obj v1.Object) types.NamespacedName { + for _, owner := range obj.GetOwnerReferences() { + if pointer.BoolPtrDerefOr(owner.Controller, false) { + return types.NamespacedName{Namespace: obj.GetNamespace(), Name: owner.Name} + } + } + return types.NamespacedName{} +} diff --git a/e2e/test1/deployment.yaml b/e2e/test1/deployment.yaml index 804300b6..907e57c5 100644 --- a/e2e/test1/deployment.yaml +++ b/e2e/test1/deployment.yaml @@ -15,5 +15,6 @@ spec: image: localhost:5000/clustercode/operator:e2e imagePullPolicy: Always args: - - operate - -v + - operate + - --operator.clustercode-image=localhost:5000/clustercode/operator:e2e diff --git a/main.go b/main.go index 720756fe..66b6b9d5 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" + "github.com/ccremer/clustercode/cfg" "github.com/ccremer/clustercode/cmd" ) @@ -15,6 +16,7 @@ var ( func main() { + cfg.Config.Operator.ClustercodeContainerImage = "quay.io/ccremer/clustercode:" + version cmd.SetVersion(fmt.Sprintf("%s, commit %s, date %s", version, commit, date)) cmd.Execute() From 71cc6fe3e1758aff0bf5ceb93fc64b2d94b318fc Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 25 Dec 2020 19:55:53 +0100 Subject: [PATCH 11/13] Wasting hours for stupid bugs and other improvements --- api/v1alpha1/clustercodeplan_types.go | 3 +- api/v1alpha1/clustercodetask_types.go | 57 +++++-- api/v1alpha1/common.go | 4 + api/v1alpha1/zz_generated.deepcopy.go | 62 +++++++ cmd/count.go | 53 ++++-- cmd/count_test.go | 31 ++++ cmd/scan.go | 3 +- ...lustercode.github.io_clustercodeplans.yaml | 8 + ...lustercode.github.io_clustercodetasks.yaml | 36 +++- controllers/clustercodetask_controller.go | 157 +++++++++++------- controllers/job_controller.go | 8 +- controllers/utils.go | 79 ++++++++- e2e/test1/kustomization.yaml | 1 + e2e/test2/plan.yaml | 9 +- go.mod | 1 + 15 files changed, 413 insertions(+), 99 deletions(-) create mode 100644 cmd/count_test.go diff --git a/api/v1alpha1/clustercodeplan_types.go b/api/v1alpha1/clustercodeplan_types.go index 7927878e..f3b96d3f 100644 --- a/api/v1alpha1/clustercodeplan_types.go +++ b/api/v1alpha1/clustercodeplan_types.go @@ -42,7 +42,8 @@ type ( // +kubebuilder:default=1 MaxParallelTasks int `json:"maxParallelTasks,omitempty"` - Suspend bool `json:"suspend,omitempty"` + Suspend bool `json:"suspend,omitempty"` + TaskConcurrencyStrategy ClustercodeStrategy `json:"taskConcurrencyStrategy,omitempty"` ScanSpec ScanSpec `json:"scanSpec,omitempty"` EncodeSpec EncodeSpec `json:"encodeSpec,omitempty"` diff --git a/api/v1alpha1/clustercodetask_types.go b/api/v1alpha1/clustercodetask_types.go index b93fd31c..1e656808 100644 --- a/api/v1alpha1/clustercodetask_types.go +++ b/api/v1alpha1/clustercodetask_types.go @@ -2,6 +2,7 @@ package v1alpha1 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" ) func init() { @@ -14,6 +15,7 @@ type ( // +kubebuilder:printcolumn:name="Source",type="string",JSONPath=".spec.sourceUrl",description="Source file name" // +kubebuilder:printcolumn:name="Target",type="string",JSONPath=".spec.targetUrl",description="Target file name" // +kubebuilder:printcolumn:name="Plan",type="string",JSONPath=`.metadata.ownerReferences[?(@.controller)].name`,description="Clustercode Plan" + // +kubebuilder:printcolumn:name="Slices",type="string",JSONPath=`.spec.slicesPlannedCount`,description="Clustercode Total Slices" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // ClustercodePlan is the Schema for the archives API @@ -36,20 +38,51 @@ type ( // EncodingTaskSpec defines the desired state of ClustercodeTask. ClustercodeTaskSpec struct { - TaskId string `json:"taskId,omitempty"` - Storage StorageSpec `json:"storage,omitempty"` - SourceUrl ClusterCodeUrl `json:"sourceUrl,omitempty"` - TargetUrl ClusterCodeUrl `json:"targetUrl,omitempty"` - Suspend bool `json:"suspend,omitempty"` - EncodeSpec EncodeSpec `json:"encodeSpec"` - ServiceAccountName string `json:"serviceAccountName,omitempty"` - FileListConfigMapRef string `json:"fileListConfigMapRef,omitempty"` + TaskId ClustercodeTaskId `json:"taskId,omitempty"` + Storage StorageSpec `json:"storage,omitempty"` + SourceUrl ClusterCodeUrl `json:"sourceUrl,omitempty"` + TargetUrl ClusterCodeUrl `json:"targetUrl,omitempty"` + Suspend bool `json:"suspend,omitempty"` + EncodeSpec EncodeSpec `json:"encodeSpec"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + FileListConfigMapRef string `json:"fileListConfigMapRef,omitempty"` + ConcurrencyStrategy ClustercodeStrategy `json:"concurrencyStrategy,omitempty"` + SlicesPlannedCount int `json:"slicesPlannedCount,omitempty"` } ClustercodeTaskStatus struct { - Conditions []metav1.Condition `json:"conditions,omitempty"` - SlicesPlanned int `json:"slicesPlanned,omitempty"` - SlicesScheduled int `json:"slicesSchedules,omitempty"` - SlicesFinished int `json:"slicesFinished,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` + SlicesScheduledCount int `json:"slicesScheduledCount,omitempty"` + SlicesFinishedCount int `json:"slicesFinishedCount,omitempty"` + SlicesScheduled []ClustercodeSliceRef `json:"slicesScheduled,omitempty"` + SlicesFinished []ClustercodeSliceRef `json:"slicesFinished,omitempty"` } + + ClustercodeSliceRef struct { + JobName string `json:"jobName,omitempty"` + SliceIndex int `json:"sliceIndex"` + } + + ClustercodeStrategy struct { + ConcurrentCountStrategy *ClustercodeCountStrategy `json:"concurrentCountStrategy,omitempty"` + } + + ClustercodeCountStrategy struct { + MaxCount int `json:"maxCount,omitempty"` + } + ClustercodeTaskId string ) + +const ( + ClustercodeTaskIdLabelKey = "clustercode.github.io/task-id" +) + +func (id ClustercodeTaskId) AsLabels() labels.Set { + return map[string]string{ + ClustercodeTaskIdLabelKey: id.String(), + } +} + +func (id ClustercodeTaskId) String() string { + return string(id) +} diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 645bbeaf..5916b492 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -34,6 +34,10 @@ type ( ClusterCodeUrl string ) +const ( + MediaDoneSuffix = "_done" +) + func ToUrl(root, path string) ClusterCodeUrl { newUrl, err := url.Parse(fmt.Sprintf("cc://%s/%s", root, strings.Replace(path, root, "", 1))) runtime.Must(err) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 1cd1fa84..483a201f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -39,6 +39,21 @@ func (in *ClusterCodeVolumeRef) DeepCopy() *ClusterCodeVolumeRef { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeCountStrategy) DeepCopyInto(out *ClustercodeCountStrategy) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeCountStrategy. +func (in *ClustercodeCountStrategy) DeepCopy() *ClustercodeCountStrategy { + if in == nil { + return nil + } + out := new(ClustercodeCountStrategy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodePlan) DeepCopyInto(out *ClustercodePlan) { *out = *in @@ -102,6 +117,7 @@ func (in *ClustercodePlanList) DeepCopyObject() runtime.Object { func (in *ClustercodePlanSpec) DeepCopyInto(out *ClustercodePlanSpec) { *out = *in out.Storage = in.Storage + in.TaskConcurrencyStrategy.DeepCopyInto(&out.TaskConcurrencyStrategy) in.ScanSpec.DeepCopyInto(&out.ScanSpec) in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) } @@ -143,6 +159,41 @@ func (in *ClustercodePlanStatus) DeepCopy() *ClustercodePlanStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeSliceRef) DeepCopyInto(out *ClustercodeSliceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeSliceRef. +func (in *ClustercodeSliceRef) DeepCopy() *ClustercodeSliceRef { + if in == nil { + return nil + } + out := new(ClustercodeSliceRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClustercodeStrategy) DeepCopyInto(out *ClustercodeStrategy) { + *out = *in + if in.ConcurrentCountStrategy != nil { + in, out := &in.ConcurrentCountStrategy, &out.ConcurrentCountStrategy + *out = new(ClustercodeCountStrategy) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeStrategy. +func (in *ClustercodeStrategy) DeepCopy() *ClustercodeStrategy { + if in == nil { + return nil + } + out := new(ClustercodeStrategy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClustercodeTask) DeepCopyInto(out *ClustercodeTask) { *out = *in @@ -207,6 +258,7 @@ func (in *ClustercodeTaskSpec) DeepCopyInto(out *ClustercodeTaskSpec) { *out = *in out.Storage = in.Storage in.EncodeSpec.DeepCopyInto(&out.EncodeSpec) + in.ConcurrencyStrategy.DeepCopyInto(&out.ConcurrencyStrategy) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskSpec. @@ -229,6 +281,16 @@ func (in *ClustercodeTaskStatus) DeepCopyInto(out *ClustercodeTaskStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.SlicesScheduled != nil { + in, out := &in.SlicesScheduled, &out.SlicesScheduled + *out = make([]ClustercodeSliceRef, len(*in)) + copy(*out, *in) + } + if in.SlicesFinished != nil { + in, out := &in.SlicesFinished, &out.SlicesFinished + *out = make([]ClustercodeSliceRef, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClustercodeTaskStatus. diff --git a/cmd/count.go b/cmd/count.go index a5a1bb76..fd925168 100644 --- a/cmd/count.go +++ b/cmd/count.go @@ -5,10 +5,13 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" + "time" "github.com/spf13/cobra" v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" @@ -58,9 +61,10 @@ func runCountCmd(cmd *cobra.Command, args []string) error { if err != nil { return err } + countLog = countLog.WithValues("task", task.Name) countLog.Info("found task", "task", task) - files, err := scanSegmentFiles(task.Spec.TaskId + "_") + files, err := scanSegmentFiles(task.Spec.TaskId.String() + "_") if err != nil { return err } @@ -77,38 +81,58 @@ func runCountCmd(cmd *cobra.Command, args []string) error { } countLog.Info("updated task") + time.Sleep(5 * time.Second) + countLog.Info("done") return nil } func updateTask(task *v1alpha1.ClustercodeTask, count int) error { - task.Status.SlicesPlanned = count - return client.Status().Update(context.Background(), task) + countLog.Info("got task", "task", task.GetObjectMeta()) + task.Spec.SlicesPlannedCount = count + + err := client.Update(context.Background(), task) + if err != nil { + return err + } + countLog.Info("updated copy", "task", task) + return nil } func createFileList(files []string, task *v1alpha1.ClustercodeTask) error { - fileList := strings.Join(files, "\n") + var fileList []string + for _, file := range files { + fileList = append(fileList, fmt.Sprintf("file %s", file)) + } + data := strings.Join(fileList, "\n") cm := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: task.Spec.FileListConfigMapRef, Namespace: task.Namespace, - Labels: labels.Merge(controllers.ClusterCodeLabels, controllers.JobIdLabel(task.Spec.TaskId)), + Labels: labels.Merge(controllers.ClusterCodeLabels, task.Spec.TaskId.AsLabels()), }, Data: map[string]string{ - "file-list.txt": fileList, + "file-list.txt": data, }, } if err := controllerutil.SetControllerReference(task, cm.GetObjectMeta(), scheme); err != nil { - countLog.Error(err, "could not set controller reference. Deleting the task might not delete this config map") + return fmt.Errorf("could not set controller reference: %w", err) } if err := client.Create(context.Background(), cm); err != nil { + if apierrors.IsAlreadyExists(err) { + if err := client.Update(context.Background(), cm); err !=nil { + return fmt.Errorf("could not update config map: %w", err) + } + countLog.Info("updated config map", "configmap", cm.Name) + } return fmt.Errorf("could not create config map: %w", err) } else { - countLog.Info("created config map", "configmap", cm.Name) + countLog.Info("created config map", "configmap", cm.Name, "data", cm.Data) } return nil } -func scanSegmentFiles(prefix string) (files []string, funcErr error) { +func scanSegmentFiles(prefix string) ([]string, error) { + var files []string root := filepath.Join(cfg.Config.Scan.SourceRoot, controllers.IntermediateSubMountPath) err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -118,15 +142,24 @@ func scanSegmentFiles(prefix string) (files []string, funcErr error) { if info.IsDir() { return nil } - if !strings.HasPrefix(filepath.Base(path), prefix) { + if !matchesTaskSegment(path, prefix) { return nil } files = append(files, path) return nil }) + if len(files) <= 0 { + return files, fmt.Errorf("could not find any segments in '%s", root) + } + sort.Strings(files) return files, err } +func matchesTaskSegment(path string, prefix string) bool { + base := filepath.Base(path) + return strings.HasPrefix(base, prefix) && !strings.Contains(base, v1alpha1.MediaDoneSuffix) +} + func getClustercodeTask() (*v1alpha1.ClustercodeTask, error) { ctx := context.Background() task := &v1alpha1.ClustercodeTask{} diff --git a/cmd/count_test.go b/cmd/count_test.go new file mode 100644 index 00000000..22efcee8 --- /dev/null +++ b/cmd/count_test.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_matchesTaskSegment(t *testing.T) { + tests := map[string]struct { + path string + prefix string + expected bool + }{ + "GivenValidSourcePath_WhenMatching_ThenReturnTrue": { + path: "/clustercode/intermediate/task_0.mp4", + prefix: "task_", + expected: true, + }, + "GivenInValidSourcePath_WhenMatching_ThenReturnFalse": { + path: "/clustercode/intermediate/task_0_done.mp4", + prefix: "task_", + expected: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tt.expected, matchesTaskSegment(tt.path, tt.prefix)) + }) + } +} diff --git a/cmd/scan.go b/cmd/scan.go index d9a77ab1..5fd2bb37 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -95,13 +95,14 @@ func scanMedia(cmd *cobra.Command, args []string) error { Labels: controllers.ClusterCodeLabels, }, Spec: v1alpha1.ClustercodeTaskSpec{ - TaskId: taskId, + TaskId: v1alpha1.ClustercodeTaskId(taskId), SourceUrl: v1alpha1.ToUrl(controllers.SourceSubMountPath, selectedFile), TargetUrl: v1alpha1.ToUrl(controllers.TargetSubMountPath, selectedFile), EncodeSpec: plan.Spec.EncodeSpec, Storage: plan.Spec.Storage, ServiceAccountName: plan.GetServiceAccountName(), FileListConfigMapRef: taskId + "-slice-list", + ConcurrencyStrategy: plan.Spec.TaskConcurrencyStrategy, }, } if err := controllerutil.SetControllerReference(plan, task.GetObjectMeta(), scheme); err != nil { diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml index 810c8c2d..58c09d5a 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodeplans.yaml @@ -158,6 +158,14 @@ spec: type: object suspend: type: boolean + taskConcurrencyStrategy: + properties: + concurrentCountStrategy: + properties: + maxCount: + type: integer + type: object + type: object required: - scanSchedule type: object diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 68ea7a35..6414ca5a 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -29,6 +29,10 @@ spec: jsonPath: .metadata.ownerReferences[?(@.controller)].name name: Plan type: string + - description: Clustercode Total Slices + jsonPath: .spec.slicesPlannedCount + name: Slices + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -48,6 +52,14 @@ spec: spec: description: EncodingTaskSpec defines the desired state of ClustercodeTask. properties: + concurrencyStrategy: + properties: + concurrentCountStrategy: + properties: + maxCount: + type: integer + type: object + type: object encodeSpec: properties: defaultCommandArgs: @@ -110,6 +122,8 @@ spec: type: string serviceAccountName: type: string + slicesPlannedCount: + type: integer sourceUrl: type: string storage: @@ -201,10 +215,26 @@ spec: type: object type: array slicesFinished: + items: + properties: + jobName: + type: string + sliceIndex: + type: integer + type: object + type: array + slicesFinishedCount: type: integer - slicesPlanned: - type: integer - slicesSchedules: + slicesScheduled: + items: + properties: + jobName: + type: string + sliceIndex: + type: integer + type: object + type: array + slicesScheduledCount: type: integer type: object type: object diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go index 06e9361c..5cd7cf89 100644 --- a/controllers/clustercodetask_controller.go +++ b/controllers/clustercodetask_controller.go @@ -2,18 +2,15 @@ package controllers import ( "context" + "fmt" "path/filepath" "strconv" "time" "github.com/go-logr/logr" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/utils/pointer" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -21,7 +18,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/predicate" "github.com/ccremer/clustercode/api/v1alpha1" - "github.com/ccremer/clustercode/cfg" ) type ( @@ -38,6 +34,12 @@ type ( plan *v1alpha1.ClustercodePlan log logr.Logger } + TaskOpts struct { + args []string + jobType ClusterCodeJobType + mountSource bool + mountIntermediate bool + } ) func (r *ClustercodeTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -47,6 +49,7 @@ func (r *ClustercodeTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { } return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ClustercodeTask{}, builder.WithPredicates(pred)). + //Owns(&batchv1.Job{}). WithEventFilter(predicate.GenerationChangedPredicate{}). Complete(r) } @@ -68,9 +71,10 @@ func (r *ClustercodeTaskReconciler) Reconcile(ctx context.Context, req ctrl.Requ r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } - rc.log = r.Log.WithValues("task", req.NamespacedName) + rc.log = r.Log.WithValues("task", req.NamespacedName).WithValues("meta", rc.task.GetObjectMeta()) if err := r.handleTask(rc); err != nil { + rc.log.Error(err, "could not reconcile task") return ctrl.Result{}, err } rc.log.Info("reconciled task") @@ -78,68 +82,68 @@ func (r *ClustercodeTaskReconciler) Reconcile(ctx context.Context, req ctrl.Requ } func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error { - if rc.task.Status.SlicesPlanned == 0 { + if rc.task.Spec.SlicesPlannedCount == 0 { return r.createSplitJob(rc) } - return nil + // Todo: Check condition whether more jobs are needed + nextSliceIndex := r.determineNextSliceIndex(rc) + if nextSliceIndex < 0 { + return nil + } else { + rc.log.Info("scheduling next slice", "index", nextSliceIndex) + return r.createSliceJob(rc, nextSliceIndex) + } } -func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) error { +func (r *ClustercodeTaskReconciler) determineNextSliceIndex(rc *ClustercodeTaskContext) int { + status := rc.task.Status + if len(status.SlicesFinished) >= rc.task.Spec.SlicesPlannedCount { + rc.log.Info("no more slices to schedule") + return -1 + } + if rc.task.Spec.ConcurrencyStrategy.ConcurrentCountStrategy != nil { + maxCount := rc.task.Spec.ConcurrencyStrategy.ConcurrentCountStrategy.MaxCount + if len(status.SlicesScheduled) >= maxCount { + rc.log.V(1).Info("reached concurrent max count, cannot schedule more", "max", maxCount) + return -1 + } + } + for i := 0; i < rc.task.Spec.SlicesPlannedCount; i++ { + if containsSliceIndex(status.SlicesScheduled, i) { + continue + } + if containsSliceIndex(status.SlicesFinished, i) { + continue + } + return i + } + return -1 +} +func containsSliceIndex(list []v1alpha1.ClustercodeSliceRef, index int) bool { + for _, t := range list { + if t.SliceIndex == index { + return true + } + } + return false +} + +func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) error { sourceMountRoot := filepath.Join("/clustercode)", SourceSubMountPath) intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) variables := map[string]string{ "${INPUT}": filepath.Join(sourceMountRoot, rc.task.Spec.SourceUrl.GetPath()), - "${OUTPUT}": getSegmentFileNameTemplate(rc, intermediateMountRoot), + "${OUTPUT}": getSegmentFileNameTemplatePath(rc, intermediateMountRoot), "${SLICE_SIZE}": strconv.Itoa(rc.task.Spec.EncodeSpec.SliceSize), } - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: rc.task.Name + "-split", - Namespace: rc.task.Namespace, - Labels: labels.Merge(ClusterCodeLabels, labels.Merge(ClusterCodeSplitLabels, JobIdLabel(rc.task.Spec.TaskId))), - }, - Spec: batchv1.JobSpec{ - BackoffLimit: pointer.Int32Ptr(0), - Template: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - ServiceAccountName: rc.task.Spec.ServiceAccountName, - RestartPolicy: corev1.RestartPolicyNever, - Containers: []corev1.Container{ - { - Name: "ffmpeg", - Image: cfg.Config.Operator.FfmpegContainerImage, - ImagePullPolicy: corev1.PullIfNotPresent, - Args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.SplitCommandArgs), - VolumeMounts: []corev1.VolumeMount{ - {Name: SourceSubMountPath, MountPath: sourceMountRoot, SubPath: rc.task.Spec.Storage.SourcePvc.SubPath}, - {Name: IntermediateSubMountPath, MountPath: intermediateMountRoot, SubPath: rc.task.Spec.Storage.IntermediatePvc.SubPath}, - }, - }, - }, - Volumes: []corev1.Volume{ - { - Name: SourceSubMountPath, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: rc.task.Spec.Storage.SourcePvc.ClaimName, - }, - }, - }, - { - Name: IntermediateSubMountPath, - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: rc.task.Spec.Storage.IntermediatePvc.ClaimName, - }, - }, - }, - }, - }, - }, - }, - } + job := createFfmpegJobDefinition(rc.task, &TaskOpts{ + args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.SplitCommandArgs), + jobType: ClustercodeTypeSplit, + mountSource: true, + mountIntermediate: true, + }) if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { rc.log.Info("could not set controller reference, deleting the task won't delete the job", "err", err.Error()) } @@ -155,12 +159,45 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e return nil } -func JobIdLabel(id string) map[string]string { - return map[string]string{ - ClustercodeTaskIdLabelKey: id, +func (r *ClustercodeTaskReconciler) createSliceJob(rc *ClustercodeTaskContext, index int) error { + intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) + variables := map[string]string{ + "${INPUT}": getSourceSegmentFileNameIndexPath(rc, intermediateMountRoot, index), + "${OUTPUT}": getTargetSegmentFileNameIndexPath(rc, intermediateMountRoot, index), } + job := createFfmpegJobDefinition(rc.task, &TaskOpts{ + args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.TranscodeCommandArgs), + jobType: ClustercodeTypeSlice, + mountIntermediate: true, + }) + job.Name = fmt.Sprintf("%s-%d", job.Name, index) + if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { + return fmt.Errorf("could not set controller reference: %w", err) + } + if err := r.Client.Create(rc.ctx, job); err != nil { + if apierrors.IsAlreadyExists(err) { + rc.log.Info("skip creating job, it already exists", "job", job.Name) + } else { + rc.log.Error(err, "could not create job", "job", job.Name) + } + } else { + rc.log.Info("job created", "job", job.Name) + } + rc.task.Status.SlicesScheduled = append(rc.task.Status.SlicesScheduled, v1alpha1.ClustercodeSliceRef{ + JobName: job.Name, + SliceIndex: index, + }) + return r.Client.Status().Update(rc.ctx, rc.task) } -func getSegmentFileNameTemplate(rc *ClustercodeTaskContext, intermediateMountRoot string) string { +func getSegmentFileNameTemplatePath(rc *ClustercodeTaskContext, intermediateMountRoot string) string { return filepath.Join(intermediateMountRoot, rc.task.Name+"_%d"+filepath.Ext(rc.task.Spec.SourceUrl.GetPath())) } + +func getSourceSegmentFileNameIndexPath(rc *ClustercodeTaskContext, intermediateMountRoot string, index int) string { + return filepath.Join(intermediateMountRoot, fmt.Sprintf("%s_%d%s", rc.task.Name, index, filepath.Ext(rc.task.Spec.SourceUrl.GetPath()))) +} + +func getTargetSegmentFileNameIndexPath(rc *ClustercodeTaskContext, intermediateMountRoot string, index int) string { + return filepath.Join(intermediateMountRoot, fmt.Sprintf("%s_%d%s%s", rc.task.Name, index, v1alpha1.MediaDoneSuffix, filepath.Ext(rc.task.Spec.TargetUrl.GetPath()))) +} diff --git a/controllers/job_controller.go b/controllers/job_controller.go index 7be0822f..efbd3b91 100644 --- a/controllers/job_controller.go +++ b/controllers/job_controller.go @@ -70,6 +70,10 @@ func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{Requeue: true, RequeueAfter: time.Minute}, err } rc.log = r.Log.WithValues("job", req.NamespacedName) + if rc.job.GetDeletionTimestamp() != nil { + rc.log.V(1).Info("job is being deleted, ignoring reconcile") + return ctrl.Result{}, nil + } jobType, err := rc.getJobType() if err != nil { rc.log.V(1).Info("cannot determine job type, ignoring reconcile", "error", err.Error()) @@ -79,6 +83,8 @@ func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R switch jobType { case ClustercodeTypeSplit: return ctrl.Result{}, r.handleSplitJob(rc) + case ClustercodeTypeCount: + rc.log.Info("reconciled count job") } return ctrl.Result{}, nil } @@ -107,7 +113,7 @@ func (r *JobReconciler) createCountJob(rc *JobContext) error { ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("%.*s-%s", 62-len(ClustercodeTypeCount), taskId, ClustercodeTypeCount), Namespace: rc.job.Namespace, - Labels: labels.Merge(ClusterCodeLabels, ClusterCodeCountLabels), + Labels: labels.Merge(ClusterCodeLabels, labels.Merge(ClustercodeTypeCount.AsLabels(), taskId.AsLabels())), }, Spec: batchv1.JobSpec{ BackoffLimit: pointer.Int32Ptr(0), diff --git a/controllers/utils.go b/controllers/utils.go index a9f9f079..1b7b401d 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -1,12 +1,19 @@ package controllers import ( + "fmt" + "path/filepath" "strings" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/pointer" + + "github.com/ccremer/clustercode/api/v1alpha1" + "github.com/ccremer/clustercode/cfg" ) type ( @@ -33,17 +40,27 @@ const ( TargetSubMountPath = "target" IntermediateSubMountPath = "intermediate" - ClustercodeTypeLabelKey = "clustercode.github.io/type" - ClustercodeTaskIdLabelKey = "clustercode.github.io/task-id" - ClustercodeTypeScan ClusterCodeJobType = "scan" - ClustercodeTypeSplit ClusterCodeJobType = "split" - ClustercodeTypeCount ClusterCodeJobType = "count" + ClustercodeTypeLabelKey = "clustercode.github.io/type" + ClustercodeTypeScan ClusterCodeJobType = "scan" + ClustercodeTypeSplit ClusterCodeJobType = "split" + ClustercodeTypeSlice ClusterCodeJobType = "slice" + ClustercodeTypeCount ClusterCodeJobType = "count" ) var ( - ClustercodeTypes = []ClusterCodeJobType{ClustercodeTypeScan, ClustercodeTypeSplit, ClustercodeTypeCount} + ClustercodeTypes = []ClusterCodeJobType{ClustercodeTypeScan, ClustercodeTypeSplit, ClustercodeTypeCount, ClustercodeTypeSlice} ) +func (t ClusterCodeJobType) AsLabels() labels.Set { + return labels.Set{ + ClustercodeTypeLabelKey: string(t), + } +} + +func (t ClusterCodeJobType) String() string { + return string(t) +} + func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]string) (merged []string) { for _, args := range argsList { for _, arg := range args { @@ -56,7 +73,7 @@ func mergeArgsAndReplaceVariables(variables map[string]string, argsList ...[]str return merged } -func getOwner(obj v1.Object) types.NamespacedName { +func getOwner(obj metav1.Object) types.NamespacedName { for _, owner := range obj.GetOwnerReferences() { if pointer.BoolPtrDerefOr(owner.Controller, false) { return types.NamespacedName{Namespace: obj.GetNamespace(), Name: owner.Name} @@ -64,3 +81,49 @@ func getOwner(obj v1.Object) types.NamespacedName { } return types.NamespacedName{} } + +func createFfmpegJobDefinition(task *v1alpha1.ClustercodeTask, opts *TaskOpts) *batchv1.Job { + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-%s", task.Spec.TaskId, opts.jobType), + Namespace: task.Namespace, + Labels: labels.Merge(ClusterCodeLabels, labels.Merge(opts.jobType.AsLabels(), task.Spec.TaskId.AsLabels())), + }, + Spec: batchv1.JobSpec{ + BackoffLimit: pointer.Int32Ptr(0), + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: task.Spec.ServiceAccountName, + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{ + { + Name: "ffmpeg", + Image: cfg.Config.Operator.FfmpegContainerImage, + ImagePullPolicy: corev1.PullIfNotPresent, + Args: opts.args, + }, + }, + }, + }, + }, + } + if opts.mountSource { + addVolume(job, SourceSubMountPath, filepath.Join("/clustercode)", SourceSubMountPath), task.Spec.Storage.SourcePvc) + } + if opts.mountIntermediate { + addVolume(job, IntermediateSubMountPath, filepath.Join("/clustercode)", IntermediateSubMountPath), task.Spec.Storage.IntermediatePvc) + } + return job +} + +func addVolume(job *batchv1.Job, name, podMountRoot string, volume v1alpha1.ClusterCodeVolumeRef) { + job.Spec.Template.Spec.Containers[0].VolumeMounts = append(job.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{Name: name, MountPath: podMountRoot, SubPath: volume.SubPath}) + job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: volume.ClaimName, + }, + }}) +} diff --git a/e2e/test1/kustomization.yaml b/e2e/test1/kustomization.yaml index 83082697..a3c52994 100644 --- a/e2e/test1/kustomization.yaml +++ b/e2e/test1/kustomization.yaml @@ -1,6 +1,7 @@ resources: - ../../config/manager - ../../config/rbac +- ../../config/crd/v1alpha1 patchesStrategicMerge: - deployment.yaml namespace: clustercode-system diff --git a/e2e/test2/plan.yaml b/e2e/test2/plan.yaml index b681d689..9464de6c 100644 --- a/e2e/test2/plan.yaml +++ b/e2e/test2/plan.yaml @@ -17,8 +17,11 @@ spec: scanSpec: mediaFileExtensions: - mp4 + taskConcurrencyStrategy: + concurrentCountStrategy: + maxCount: 1 encodeSpec: - sliceSize: 2 + sliceSize: 1 defaultCommandArgs: - -y - -hide_banner @@ -47,7 +50,7 @@ spec: - -f - concat - -i - - concat.txt + - ${INPUT} - -c - copy - - media_out.mkv + - ${OUTPUT} diff --git a/go.mod b/go.mod index 43869806..ef6579b6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/knadh/koanf v0.14.0 github.com/spf13/cobra v1.1.1 github.com/spf13/pflag v1.0.5 + github.com/stretchr/testify v1.6.1 go.uber.org/zap v1.15.0 k8s.io/api v0.19.6 k8s.io/apimachinery v0.19.6 From 146e2b810bd2c801a22922725e2dee846e1a6f2a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 26 Dec 2020 00:03:11 +0100 Subject: [PATCH 12/13] Merging with Ffmpeg now works --- Makefile | 2 +- api/v1alpha1/common.go | 3 +- cmd/count.go | 16 ++---- cmd/operate.go | 14 +++++- ...lustercode.github.io_clustercodetasks.yaml | 4 ++ controllers/clustercodetask_controller.go | 50 +++++++++++++++---- controllers/job_controller.go | 36 ++++++++++++- controllers/utils.go | 43 ++++++++++++---- e2e/test2/plan.yaml | 4 +- e2e/test2/pv.yaml | 14 ++++++ e2e/test2/pvc.yaml | 19 +++++++ 11 files changed, 170 insertions(+), 35 deletions(-) diff --git a/Makefile b/Makefile index 0680101e..8b3fbdbf 100644 --- a/Makefile +++ b/Makefile @@ -132,7 +132,7 @@ e2e_test: install_bats $(SETUP_E2E_TEST) docker-build ## Runs the e2e test suite run_kind: export KUBECONFIG = $(KIND_KUBECONFIG) run_kind: export BACKUP_ENABLE_LEADER_ELECTION = $(ENABLE_LEADER_ELECTION) run_kind: $(SETUP_E2E_TEST) ## Runs the operator in kind - go run ./main.go + go run ./main.go -v operate --operator.clustercode-image=localhost:5000/clustercode/operator:e2e .PHONY: setup_e2e_test setup_e2e_test: $(SETUP_E2E_TEST) ## Run the e2e setup diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 5916b492..3ea3a128 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -35,7 +35,8 @@ type ( ) const ( - MediaDoneSuffix = "_done" + MediaFileDoneSuffix = "_done" + ConfigMapFileName = "file-list.txt" ) func ToUrl(root, path string) ClusterCodeUrl { diff --git a/cmd/count.go b/cmd/count.go index fd925168..2b041d41 100644 --- a/cmd/count.go +++ b/cmd/count.go @@ -7,7 +7,6 @@ import ( "path/filepath" "sort" "strings" - "time" "github.com/spf13/cobra" v1 "k8s.io/api/core/v1" @@ -81,29 +80,24 @@ func runCountCmd(cmd *cobra.Command, args []string) error { } countLog.Info("updated task") - time.Sleep(5 * time.Second) - countLog.Info("done") return nil } func updateTask(task *v1alpha1.ClustercodeTask, count int) error { - countLog.Info("got task", "task", task.GetObjectMeta()) task.Spec.SlicesPlannedCount = count - err := client.Update(context.Background(), task) if err != nil { return err } - countLog.Info("updated copy", "task", task) return nil } func createFileList(files []string, task *v1alpha1.ClustercodeTask) error { var fileList []string for _, file := range files { - fileList = append(fileList, fmt.Sprintf("file %s", file)) + fileList = append(fileList, fmt.Sprintf("file '%s'", file)) } - data := strings.Join(fileList, "\n") + data := strings.Join(fileList, "\n") + "\n" cm := &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: task.Spec.FileListConfigMapRef, @@ -111,7 +105,7 @@ func createFileList(files []string, task *v1alpha1.ClustercodeTask) error { Labels: labels.Merge(controllers.ClusterCodeLabels, task.Spec.TaskId.AsLabels()), }, Data: map[string]string{ - "file-list.txt": data, + v1alpha1.ConfigMapFileName: data, }, } if err := controllerutil.SetControllerReference(task, cm.GetObjectMeta(), scheme); err != nil { @@ -119,7 +113,7 @@ func createFileList(files []string, task *v1alpha1.ClustercodeTask) error { } if err := client.Create(context.Background(), cm); err != nil { if apierrors.IsAlreadyExists(err) { - if err := client.Update(context.Background(), cm); err !=nil { + if err := client.Update(context.Background(), cm); err != nil { return fmt.Errorf("could not update config map: %w", err) } countLog.Info("updated config map", "configmap", cm.Name) @@ -157,7 +151,7 @@ func scanSegmentFiles(prefix string) ([]string, error) { func matchesTaskSegment(path string, prefix string) bool { base := filepath.Base(path) - return strings.HasPrefix(base, prefix) && !strings.Contains(base, v1alpha1.MediaDoneSuffix) + return strings.HasPrefix(base, prefix) && !strings.Contains(base, v1alpha1.MediaFileDoneSuffix) } func getClustercodeTask() (*v1alpha1.ClustercodeTask, error) { diff --git a/cmd/operate.go b/cmd/operate.go index 848a618e..274133f6 100644 --- a/cmd/operate.go +++ b/cmd/operate.go @@ -4,7 +4,9 @@ import ( "fmt" "github.com/spf13/cobra" + batchv1 "k8s.io/api/batch/v1" ctrl "sigs.k8s.io/controller-runtime" + controllerclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/ccremer/clustercode/cfg" "github.com/ccremer/clustercode/controllers" @@ -48,6 +50,16 @@ func startOperator(cmd *cobra.Command, args []string) error { return fmt.Errorf("unable to start operator: %w", err) } + uncached, err := controllerclient.NewDelegatingClient(controllerclient.NewDelegatingClientInput{ + CacheReader: mgr.GetClient(), + Client: mgr.GetClient(), + UncachedObjects: []controllerclient.Object{ + &batchv1.Job{}, + }, + }) + if err != nil { + return err + } if err = (&controllers.ClustercodePlanReconciler{ Client: mgr.GetClient(), Log: ctrl.Log.WithName("controllers").WithName("clustercodeplan"), @@ -63,7 +75,7 @@ func startOperator(cmd *cobra.Command, args []string) error { return fmt.Errorf("unable to create controller '%s': %w", "clustercodetask", err) } if err = (&controllers.JobReconciler{ - Client: mgr.GetClient(), + Client: uncached, Log: ctrl.Log.WithName("controllers").WithName("job"), Scheme: mgr.GetScheme(), }).SetupWithManager(mgr); err != nil { diff --git a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml index 6414ca5a..a8104d43 100644 --- a/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml +++ b/config/crd/v1alpha1/clustercode.github.io_clustercodetasks.yaml @@ -221,6 +221,8 @@ spec: type: string sliceIndex: type: integer + required: + - sliceIndex type: object type: array slicesFinishedCount: @@ -232,6 +234,8 @@ spec: type: string sliceIndex: type: integer + required: + - sliceIndex type: object type: array slicesScheduledCount: diff --git a/controllers/clustercodetask_controller.go b/controllers/clustercodetask_controller.go index 5cd7cf89..e9eb53ba 100644 --- a/controllers/clustercodetask_controller.go +++ b/controllers/clustercodetask_controller.go @@ -39,6 +39,8 @@ type ( jobType ClusterCodeJobType mountSource bool mountIntermediate bool + mountTarget bool + mountConfig bool } ) @@ -50,7 +52,7 @@ func (r *ClustercodeTaskReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.ClustercodeTask{}, builder.WithPredicates(pred)). //Owns(&batchv1.Job{}). - WithEventFilter(predicate.GenerationChangedPredicate{}). + //WithEventFilter(predicate.GenerationChangedPredicate{}). Complete(r) } @@ -86,6 +88,10 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error return r.createSplitJob(rc) } + if len(rc.task.Status.SlicesFinished) >= rc.task.Spec.SlicesPlannedCount { + rc.log.Info("no more slices to schedule") + return r.createMergeJob(rc) + } // Todo: Check condition whether more jobs are needed nextSliceIndex := r.determineNextSliceIndex(rc) if nextSliceIndex < 0 { @@ -98,10 +104,6 @@ func (r *ClustercodeTaskReconciler) handleTask(rc *ClustercodeTaskContext) error func (r *ClustercodeTaskReconciler) determineNextSliceIndex(rc *ClustercodeTaskContext) int { status := rc.task.Status - if len(status.SlicesFinished) >= rc.task.Spec.SlicesPlannedCount { - rc.log.Info("no more slices to schedule") - return -1 - } if rc.task.Spec.ConcurrencyStrategy.ConcurrentCountStrategy != nil { maxCount := rc.task.Spec.ConcurrencyStrategy.ConcurrentCountStrategy.MaxCount if len(status.SlicesScheduled) >= maxCount { @@ -131,8 +133,8 @@ func containsSliceIndex(list []v1alpha1.ClustercodeSliceRef, index int) bool { } func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) error { - sourceMountRoot := filepath.Join("/clustercode)", SourceSubMountPath) - intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) + sourceMountRoot := filepath.Join("/clustercode", SourceSubMountPath) + intermediateMountRoot := filepath.Join("/clustercode", IntermediateSubMountPath) variables := map[string]string{ "${INPUT}": filepath.Join(sourceMountRoot, rc.task.Spec.SourceUrl.GetPath()), "${OUTPUT}": getSegmentFileNameTemplatePath(rc, intermediateMountRoot), @@ -160,7 +162,7 @@ func (r *ClustercodeTaskReconciler) createSplitJob(rc *ClustercodeTaskContext) e } func (r *ClustercodeTaskReconciler) createSliceJob(rc *ClustercodeTaskContext, index int) error { - intermediateMountRoot := filepath.Join("/clustercode)", IntermediateSubMountPath) + intermediateMountRoot := filepath.Join("/clustercode", IntermediateSubMountPath) variables := map[string]string{ "${INPUT}": getSourceSegmentFileNameIndexPath(rc, intermediateMountRoot, index), "${OUTPUT}": getTargetSegmentFileNameIndexPath(rc, intermediateMountRoot, index), @@ -171,6 +173,7 @@ func (r *ClustercodeTaskReconciler) createSliceJob(rc *ClustercodeTaskContext, i mountIntermediate: true, }) job.Name = fmt.Sprintf("%s-%d", job.Name, index) + job.Labels[ClustercodeSliceIndexLabelKey] = strconv.Itoa(index) if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { return fmt.Errorf("could not set controller reference: %w", err) } @@ -190,6 +193,35 @@ func (r *ClustercodeTaskReconciler) createSliceJob(rc *ClustercodeTaskContext, i return r.Client.Status().Update(rc.ctx, rc.task) } +func (r *ClustercodeTaskReconciler) createMergeJob(rc *ClustercodeTaskContext) error { + configMountRoot := filepath.Join("/clustercode", ConfigSubMountPath) + targetMountRoot := filepath.Join("/clustercode", TargetSubMountPath) + variables := map[string]string{ + "${INPUT}": filepath.Join(configMountRoot, v1alpha1.ConfigMapFileName), + "${OUTPUT}": filepath.Join(targetMountRoot, rc.task.Spec.TargetUrl.GetPath()), + } + job := createFfmpegJobDefinition(rc.task, &TaskOpts{ + args: mergeArgsAndReplaceVariables(variables, rc.task.Spec.EncodeSpec.DefaultCommandArgs, rc.task.Spec.EncodeSpec.MergeCommandArgs), + jobType: ClustercodeTypeMerge, + mountIntermediate: true, + mountTarget: true, + mountConfig: true, + }) + if err := controllerutil.SetControllerReference(rc.task, job.GetObjectMeta(), r.Scheme); err != nil { + return fmt.Errorf("could not set controller reference: %w", err) + } + if err := r.Client.Create(rc.ctx, job); err != nil { + if apierrors.IsAlreadyExists(err) { + rc.log.Info("skip creating job, it already exists", "job", job.Name) + } else { + rc.log.Error(err, "could not create job", "job", job.Name) + } + } else { + rc.log.Info("job created", "job", job.Name) + } + return nil +} + func getSegmentFileNameTemplatePath(rc *ClustercodeTaskContext, intermediateMountRoot string) string { return filepath.Join(intermediateMountRoot, rc.task.Name+"_%d"+filepath.Ext(rc.task.Spec.SourceUrl.GetPath())) } @@ -199,5 +231,5 @@ func getSourceSegmentFileNameIndexPath(rc *ClustercodeTaskContext, intermediateM } func getTargetSegmentFileNameIndexPath(rc *ClustercodeTaskContext, intermediateMountRoot string, index int) string { - return filepath.Join(intermediateMountRoot, fmt.Sprintf("%s_%d%s%s", rc.task.Name, index, v1alpha1.MediaDoneSuffix, filepath.Ext(rc.task.Spec.TargetUrl.GetPath()))) + return filepath.Join(intermediateMountRoot, fmt.Sprintf("%s_%d%s%s", rc.task.Name, index, v1alpha1.MediaFileDoneSuffix, filepath.Ext(rc.task.Spec.TargetUrl.GetPath()))) } diff --git a/controllers/job_controller.go b/controllers/job_controller.go index efbd3b91..5c282a1b 100644 --- a/controllers/job_controller.go +++ b/controllers/job_controller.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "path/filepath" + "strconv" "time" "github.com/go-logr/logr" @@ -63,7 +64,7 @@ func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R err := r.Client.Get(ctx, req.NamespacedName, rc.job) if err != nil { if apierrors.IsNotFound(err) { - r.Log.Info("object not found, ignoring reconcile", "object", req.NamespacedName) + r.Log.V(1).Info("object not found, ignoring reconcile", "object", req.NamespacedName) return ctrl.Result{}, nil } r.Log.Error(err, "could not retrieve object", "object", req.NamespacedName) @@ -85,13 +86,15 @@ func (r *JobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{}, r.handleSplitJob(rc) case ClustercodeTypeCount: rc.log.Info("reconciled count job") + case ClustercodeTypeSlice: + rc.log.Info("reconciling slice job") + return ctrl.Result{}, r.handleSliceJob(rc) } return ctrl.Result{}, nil } func (r *JobReconciler) handleSplitJob(rc *JobContext) error { conditions := castConditions(rc.job.Status.Conditions) - rc.log.V(1).Info("job status", "conditions", conditions) if !meta.IsStatusConditionPresentAndEqual(conditions, string(batchv1.JobComplete), metav1.ConditionTrue) { rc.log.V(1).Info("job is not completed yet, ignoring reconcile") return nil @@ -105,6 +108,35 @@ func (r *JobReconciler) handleSplitJob(rc *JobContext) error { return r.createCountJob(rc) } +func (r *JobReconciler) handleSliceJob(rc *JobContext) error { + indexStr, found := rc.job.Labels[ClustercodeSliceIndexLabelKey] + if !found { + return fmt.Errorf("cannot determine slice index, missing label '%s'", ClustercodeSliceIndexLabelKey) + } + index, err := strconv.Atoi(indexStr) + if err != nil { + return fmt.Errorf("cannot determine slice index from label '%s': %w", ClustercodeSliceIndexLabelKey, err) + } + conditions := castConditions(rc.job.Status.Conditions) + if !meta.IsStatusConditionPresentAndEqual(conditions, string(batchv1.JobComplete), metav1.ConditionTrue) { + rc.log.V(1).Info("job is not completed yet, ignoring reconcile") + return nil + } + + rc.task = &v1alpha1.ClustercodeTask{} + if err := r.Client.Get(rc.ctx, getOwner(rc.job), rc.task); err != nil { + return err + } + arr := rc.task.Status.SlicesFinished + arr = append(arr, v1alpha1.ClustercodeSliceRef{ + SliceIndex: index, + JobName: rc.job.Name, + }) + rc.task.Status.SlicesFinished = arr + rc.task.Status.SlicesFinishedCount = len(arr) + return r.Client.Status().Update(rc.ctx, rc.task) +} + func (r *JobReconciler) createCountJob(rc *JobContext) error { taskId := rc.task.Spec.TaskId diff --git a/controllers/utils.go b/controllers/utils.go index 1b7b401d..cd99fd54 100644 --- a/controllers/utils.go +++ b/controllers/utils.go @@ -39,16 +39,19 @@ const ( SourceSubMountPath = "source" TargetSubMountPath = "target" IntermediateSubMountPath = "intermediate" + ConfigSubMountPath = "config" - ClustercodeTypeLabelKey = "clustercode.github.io/type" - ClustercodeTypeScan ClusterCodeJobType = "scan" - ClustercodeTypeSplit ClusterCodeJobType = "split" - ClustercodeTypeSlice ClusterCodeJobType = "slice" - ClustercodeTypeCount ClusterCodeJobType = "count" + ClustercodeTypeLabelKey = "clustercode.github.io/type" + ClustercodeSliceIndexLabelKey = "clustercode.github.io/slice-index" + ClustercodeTypeScan ClusterCodeJobType = "scan" + ClustercodeTypeSplit ClusterCodeJobType = "split" + ClustercodeTypeSlice ClusterCodeJobType = "slice" + ClustercodeTypeCount ClusterCodeJobType = "count" + ClustercodeTypeMerge ClusterCodeJobType = "merge" ) var ( - ClustercodeTypes = []ClusterCodeJobType{ClustercodeTypeScan, ClustercodeTypeSplit, ClustercodeTypeCount, ClustercodeTypeSlice} + ClustercodeTypes = []ClusterCodeJobType{ClustercodeTypeScan, ClustercodeTypeSplit, ClustercodeTypeCount, ClustercodeTypeSlice, ClustercodeTypeMerge} ) func (t ClusterCodeJobType) AsLabels() labels.Set { @@ -108,15 +111,21 @@ func createFfmpegJobDefinition(task *v1alpha1.ClustercodeTask, opts *TaskOpts) * }, } if opts.mountSource { - addVolume(job, SourceSubMountPath, filepath.Join("/clustercode)", SourceSubMountPath), task.Spec.Storage.SourcePvc) + addPvcVolume(job, SourceSubMountPath, filepath.Join("/clustercode", SourceSubMountPath), task.Spec.Storage.SourcePvc) } if opts.mountIntermediate { - addVolume(job, IntermediateSubMountPath, filepath.Join("/clustercode)", IntermediateSubMountPath), task.Spec.Storage.IntermediatePvc) + addPvcVolume(job, IntermediateSubMountPath, filepath.Join("/clustercode", IntermediateSubMountPath), task.Spec.Storage.IntermediatePvc) + } + if opts.mountTarget { + addPvcVolume(job, TargetSubMountPath, filepath.Join("/clustercode", TargetSubMountPath), task.Spec.Storage.TargetPvc) + } + if opts.mountConfig { + addConfigMapVolume(job, ConfigSubMountPath, filepath.Join("/clustercode", ConfigSubMountPath), task.Spec.FileListConfigMapRef) } return job } -func addVolume(job *batchv1.Job, name, podMountRoot string, volume v1alpha1.ClusterCodeVolumeRef) { +func addPvcVolume(job *batchv1.Job, name, podMountRoot string, volume v1alpha1.ClusterCodeVolumeRef) { job.Spec.Template.Spec.Containers[0].VolumeMounts = append(job.Spec.Template.Spec.Containers[0].VolumeMounts, corev1.VolumeMount{Name: name, MountPath: podMountRoot, SubPath: volume.SubPath}) job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{ @@ -127,3 +136,19 @@ func addVolume(job *batchv1.Job, name, podMountRoot string, volume v1alpha1.Clus }, }}) } + +func addConfigMapVolume(job *batchv1.Job, name, podMountRoot, configMapName string) { + job.Spec.Template.Spec.Containers[0].VolumeMounts = append(job.Spec.Template.Spec.Containers[0].VolumeMounts, + corev1.VolumeMount{ + Name: name, + MountPath: podMountRoot, + }) + job.Spec.Template.Spec.Volumes = append(job.Spec.Template.Spec.Volumes, corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: configMapName}, + }, + }, + }) +} diff --git a/e2e/test2/plan.yaml b/e2e/test2/plan.yaml index 9464de6c..5fe45d0c 100644 --- a/e2e/test2/plan.yaml +++ b/e2e/test2/plan.yaml @@ -12,7 +12,7 @@ spec: claimName: test2-claim2 subPath: intermediate targetPvc: - claimName: test2-claim + claimName: test2-claim3 subPath: target scanSpec: mediaFileExtensions: @@ -49,6 +49,8 @@ spec: mergeCommandArgs: - -f - concat + - -safe + - "0" - -i - ${INPUT} - -c diff --git a/e2e/test2/pv.yaml b/e2e/test2/pv.yaml index a863bc4d..c9581553 100644 --- a/e2e/test2/pv.yaml +++ b/e2e/test2/pv.yaml @@ -26,3 +26,17 @@ spec: hostPath: path: /pv/data type: Directory +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: e2e-testdata3 +spec: + capacity: + storage: 1Gi + accessModes: + - ReadWriteMany + storageClassName: hostpath + hostPath: + path: /pv/data + type: Directory diff --git a/e2e/test2/pvc.yaml b/e2e/test2/pvc.yaml index 5f1a8bf2..63b066f9 100644 --- a/e2e/test2/pvc.yaml +++ b/e2e/test2/pvc.yaml @@ -34,3 +34,22 @@ spec: app.kubernetes.io/instance: test2 app.kubernetes.io/managed-by: kustomize app.kubernetes.io/name: e2e + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: test2-claim3 +spec: + accessModes: + - ReadWriteMany + volumeMode: Filesystem + resources: + requests: + storage: 1Gi + storageClassName: hostpath + selector: + matchLabels: + app.kubernetes.io/instance: test2 + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: e2e From 10ebd9b052fb2bce98953d73de6a82c9acc8acd9 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 26 Dec 2020 01:23:33 +0100 Subject: [PATCH 13/13] Update Readme and scaffolding to clustercode operator --- .github/workflows/build.yml | 2 +- .gitignore | 16 --- .goreleaser.yml | 10 +- Makefile | 16 +-- README.md | 200 ++++++++++++++++++------------------ api/v1alpha1/common.go | 2 +- cfg/config.go | 2 +- cmd/count_test.go | 8 +- config/manager/manager.yaml | 2 + 9 files changed, 118 insertions(+), 140 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ef822fc..ad9354eb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,7 @@ jobs: strategy: matrix: include: - - kind-node-version: v1.18.8 + - kind-node-version: v1.19.4 crd-spec-version: v1 steps: - uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 0e673ff0..36f38041 100644 --- a/.gitignore +++ b/.gitignore @@ -22,20 +22,6 @@ gen### Go template # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 -.glide/ - -# Antora related -.asciidoctor -.cache -_public/ -_archive/ - -*.DS_Store -cmd/*/debug -cmd/operator/operator -cmd/restic/restic - bin/ dist/ clustercode-crd*.yaml @@ -44,5 +30,3 @@ testbin/ node_modules/ e2e/debug __debug_bin - - diff --git a/.goreleaser.yml b/.goreleaser.yml index e4654708..07c7311f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,12 +17,14 @@ snapshot: dockers: - image_templates: - - "docker.io/ccremer/clustercode:latest" - "docker.io/ccremer/clustercode:v{{ .Version }}" - - "docker.io/ccremer/clustercode:v{{ .Major }}" - - "quay.io/ccremer/clustercode:latest" - "quay.io/ccremer/clustercode:v{{ .Version }}" - - "quay.io/ccremer/clustercode:v{{ .Major }}" + + - "docker.io/ccremer/clustercode:{{ if .Prerelease }}v{{ .Version }}{{ else }}v{{ .Major }}{{ end }}" + - "quay.io/ccremer/clustercode:{{ if .Prerelease }}v{{ .Version }}{{ else }}v{{ .Major }}{{ end }}" + + - "docker.io/ccremer/clustercode:{{ if .Prerelease }}v{{ .Version }}{{ else }}latest{{ end }}" + - "quay.io/ccremer/clustercode:{{ if .Prerelease }}v{{ .Version }}{{ else }}latest{{ end }}" changelog: sort: asc diff --git a/Makefile b/Makefile index 8b3fbdbf..f9db4220 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,3 @@ -# Current Operator version -VERSION ?= 0.0.1 -# Default bundle image tag -BUNDLE_IMG ?= controller-bundle:$(VERSION) -# Options for 'bundle-build' -ifneq ($(origin CHANNELS), undefined) -BUNDLE_CHANNELS := --channels=$(CHANNELS) -endif -ifneq ($(origin DEFAULT_CHANNEL), undefined) -BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL) -endif -BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL) - IMG_TAG ?= latest BIN_FILENAME ?= clustercode @@ -97,8 +84,7 @@ generate: ## Generate manifests e.g. CRD, RBAC etc. @rm config/*.yaml crd: generate ## Generate CRD to file - $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1 > $(CRD_FILE) - $(KUSTOMIZE) build $(CRD_ROOT_DIR)/v1beta1 > $(CRD_FILE_LEGACY) + $(KUSTOMIZE) build $(CRD_ROOT_DIR) > $(CRD_FILE) fmt: ## Run go fmt against code go fmt ./... diff --git a/README.md b/README.md index 1f7c3998..24ad1c48 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,127 @@ # clustercode +[![Build](https://img.shields.io/github/workflow/status/ccremer/clustercode/Build)][build] +![Go version](https://img.shields.io/github/go-mod/go-version/ccremer/clustercode) +![Kubernetes version](https://img.shields.io/badge/k8s-v1.19-blue) +[![Version](https://img.shields.io/github/v/release/ccremer/clustercode?include_prereleases)][releases] +[![GitHub downloads](https://img.shields.io/github/downloads/ccremer/clustercode/total)][releases] +[![Docker image](https://img.shields.io/docker/pulls/ccremer/clustercode)][dockerhub] +[![License](https://img.shields.io/github/license/ccremer/clustercode)][license] + Automatically convert your movies and TV shows from one file format to another using ffmpeg in a cluster. +It's like an Ffmpeg operator. + +## How it works + +Clustercode reads an input file from a directory and splits it into multiple smaller chunks. +Those chunks are encoded individually, but in parallel when enabling concurrency. +That means, more nodes equals faster encoding. +After all chunks are converted, they are merged together again and put into target directory. + +Ffmpeg is used in the splitting, encoding and merging jobs. +It basically boils down to -![clustercode_webadmin](https://user-images.githubusercontent.com/12159026/31952107-193afa02-b8e0-11e7-9f88-8d3d20e0d84c.png) +1. Splitting: `ffmpeg -i movie.mp4 -c copy -map 0 -segment_time 120 -f segment job-id_%d.mp4` +2. Encoding: `ffmpeg -i job-id_1.mp4 -c:v copy -c:a copy job-id_1_done.mkv` +3. Merging: `ffmpeg -f concat -i file-list.txt -c copy movie_out.mkv` + +The encoding step can be executed in parallel by multiple Pods. +You can customize the arguments passed to ffmpeg (with a few rules and constraints). + +Under the hood, only 2 Kubernetes CRDs are used to describe the config. +All steps with Ffmpeg are executed with Kubernetes Cronjobs and Jobs. ## Features -* Scans and encodes video files from a directory and encodes them using customizable profiles. +* Scans and encodes video files from a directory and encodes them using customizable plans. * Encoded files are stored in an output directory. -* Take advantage of having multiple computers: Each node encodes a video, enabling parallelization. -* Works as a single node too. -* No designated master. All nodes share the same state. -* Supports arbiter nodes for providing a quorum. Quorums are needed to prevent a split-brain. Useful if you -have a spare Raspberry Pi or NAS that is just poor at encoding. -* Several and different cleanup strategies. -* Supports Handbrake and ffmpeg -* Basic REST API +* Schedule Scans for new files with Cron. +* Take advantage of having multiple computers: Each Pod encodes a segment, enabling parallelization. +* Works on single nodes too, but you might not get any speed benefits (in fact it's generating overhead). ## Installation -* The recommended platform is Docker. -* Windows (download zip from releases tab). -* Build it using Gradle if you prefer it another way. +Currently, there is Kustomize support. Helm is planned. +Install with `kustomize build config/default | kubectl apply -f -`. -I hate long `docker run` commands with tons of arguments, so here is a docker-compose template: +## Supported storage types -### Docker Compose +All file-writable ReadWriteMany volumes available in Kubernetes PersistentVolumeClaims. -| WARNING: To use the latest stable release, switch to the 1.3 branch! The config below is broken. | -| --- | +## Configuration ```yaml -version: "2.2" -services: - # The backend - clustercode: - restart: unless-stopped - image: braindoctor/clustercode:latest - container_name: clustercode - cpu_shares: 512 - volumes: - - "/path/to/input:/input" - - "/path/to/output:/output" - - "/path/to/profiles:/profiles" -# If you need modifications to the xml files, persist them: -# - "/path/to/config:/usr/src/clustercode/config" - environment: - # overwrite any settings from the default using env vars! - - CC_CLUSTER_JGROUPS_TCP_INITIAL_HOSTS=your.other.docker.node[7600],another.one[7600] - - CC_CLUSTER_JGROUPS_EXT_ADDR=192.168.1.100 - - # The frontend - clustercode-admin: - restart: unless-stopped - image: braindoctor/clustercode-admin:latest - container_name: clustercode-admin - volumes: - - /etc/localtime:/etc/localtime:ro - ports: - - "8080:8080" - - # This is entirely optional! - clustercode-netdata: - restart: unless-stopped - image: braindoctor/clustercode-netdata:latest - container_name: clustercode-netdata - volumes: - - /etc/localtime:/etc/localtime:ro - ports: - - "19999:19999" - environment: - - N_ENABLE_NODE_D=yes - - N_HOSTNAME=clustercode +apiVersion: clustercode.github.io/v1alpha1 +kind: ClustercodePlan +metadata: + name: test-plan +spec: + scanSchedule: "*/30 * * * *" + storage: + sourcePvc: + claimName: my-nfs-source + #subPath: source + intermediatePvc: + claimName: some-other-storage-claim + #subPath: intermediate + targetPvc: + claimName: my-nfs-target + #subPath: target + scanSpec: + mediaFileExtensions: + - mp4 + taskConcurrencyStrategy: + concurrentCountStrategy: + maxCount: 1 + encodeSpec: + sliceSize: 120 # after how many seconds to split + defaultCommandArgs: ["-y","-hide_banner","-nostats"] + splitCommandArgs: + - -i + - ${INPUT} + - -c + - copy + - -map + - "0" + - -segment_time + - ${SLICE_SIZE} + - -f + - segment + - ${OUTPUT} + transcodeCommandArgs: + - -i + - ${INPUT} + - -c:v + - copy + - -c:a + - copy + - ${OUTPUT} + mergeCommandArgs: + - -f + - concat + - -safe + - "0" + - -i + - ${INPUT} + - -c + - copy + - ${OUTPUT} ``` -## Configuration - -When you first start the container using docker compose, it will create a default configuration -file in `/usr/src/clustercode/config` (in the container). You can view the settings in the -`clustercode.properties` file and deviate from the default behaviour of the software. However, you should -modify the settings via Environment variables (same key/values syntax). Environment variables **always take precedence** -over the ones in `clustercode.properties`. If you made changes to the XML files, you need to mount a path from outside -in order to have them persistent. - ## Project status -Clustercode 2.0 is on hold as of March 2019 and no new changes will be made to 1.3 release. Currently I'm busy with private and work life. - -I thought that after modularizing I could release 1.4, but that wouldn't add any new features or fixes. Instead, I will focus fully on a -new release, which will consist of several microservices (say hello to Kubernetes!). It will also move to a chunk-based parallelization process -(1 file chunked into smaller pieces, processed by multiple nodes), which should really bring down the time to encode -a single job. However those are currently in WIP. Check these repos: - -* https://github.com/ccremer/clustercode-worker -* https://github.com/ccremer/clustercode-docs -* https://github.com/ccremer/clustercode-admin -* https://github.com/ccremer/clustercode-api-gateway -* https://github.com/ccremer/clustercode-netdata (I may scrap that, not sure yet) +Clustercode 2.0 is released **as a Proof-of-concept** and no new changes will be made to old [1.3 release](https://github.com/ccremer/clustercode/tree/1.3.1). -## Future Plans - -Head over here: https://github.com/ccremer/clustercode/projects +The code is ugly, documentation inexistent and only the Happy Path works. +But feel free to try "early access" and report stuff. ## Docker Tags -* dev: latest automated build of the master branch -* 1.3.2: stable build of a tagged commit from a release -* tagged: tags following the 1.x.x pattern are specific releases - -## SSL - -The REST API and WebAdmin are easy to support with SSL/https. Just put a reverse proxy in front of clustercode -that handles https client connections and forwards the request via http to clustercode. -Check out https://github.com/jwilder/nginx-proxy for an excellent docker nginx proxy with SSL support. - -The cluster communication is more difficult to set up with encryption. Even though the -traffic is binary and hard enough to intercept, it is not encrypted by default. You need -to change the JGroups configuration. Instructions can be found -[in the manual](http://jgroups.org/manual4/index.html#Security). +* master: Floating image tag that points to the image built from master branch, usually unreleased changes. +* latest: Floating image tag that points to the latest stable release +* tagged: tags following the x.y.z pattern are specific releases -Generally this image and software is built with flexibility and simplicity in mind, not security. -Use it at your own risk. +[build]: https://github.com/ccremer/clustercode/actions?query=workflow%3ABuild +[releases]: https://github.com/ccremer/clustercode/releases +[license]: https://github.com/ccremer/clustercode/blob/master/LICENSE +[dockerhub]: https://hub.docker.com/r/ccremer/clustercode diff --git a/api/v1alpha1/common.go b/api/v1alpha1/common.go index 3ea3a128..6e74a873 100644 --- a/api/v1alpha1/common.go +++ b/api/v1alpha1/common.go @@ -36,7 +36,7 @@ type ( const ( MediaFileDoneSuffix = "_done" - ConfigMapFileName = "file-list.txt" + ConfigMapFileName = "file-list.txt" ) func ToUrl(root, path string) ClusterCodeUrl { diff --git a/cfg/config.go b/cfg/config.go index 3122275d..d2f6524e 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -29,7 +29,7 @@ type ( TargetRoot string `koanf:"target-root"` } CountConfig struct { - TaskName string `koanf:"task-name"` + TaskName string `koanf:"task-name"` } ) diff --git a/cmd/count_test.go b/cmd/count_test.go index 22efcee8..34203b36 100644 --- a/cmd/count_test.go +++ b/cmd/count_test.go @@ -13,13 +13,13 @@ func Test_matchesTaskSegment(t *testing.T) { expected bool }{ "GivenValidSourcePath_WhenMatching_ThenReturnTrue": { - path: "/clustercode/intermediate/task_0.mp4", - prefix: "task_", + path: "/clustercode/intermediate/task_0.mp4", + prefix: "task_", expected: true, }, "GivenInValidSourcePath_WhenMatching_ThenReturnFalse": { - path: "/clustercode/intermediate/task_0_done.mp4", - prefix: "task_", + path: "/clustercode/intermediate/task_0_done.mp4", + prefix: "task_", expected: false, }, } diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index c0c93ac4..29101035 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -25,3 +25,5 @@ spec: image: quay.io/ccremer/clustercode:latest args: - operate + - --operator.clustercode-image=quay.io/ccremer/clustercode:latest + - --operator.ffmpeg-image=docker.io/jrottenberg/ffmpeg:4.1-alpine