From 1279635d7fb78c95a858382c800d9f51ba1eeedd Mon Sep 17 00:00:00 2001 From: jiazhizhong Date: Thu, 10 Mar 2022 17:09:03 +0800 Subject: [PATCH] fix --- .github/ISSUE_TEMPLATE/bug_report.md | 10 + .gitignore | 47 ++ .travis.yml | 33 ++ CHANGELOG.md | 45 ++ Dockerfile | 24 + LICENSE | 72 +++ Makefile | 84 +++ README.md | 163 ++++++ VERSION | 1 + admire.jpg | Bin 0 -> 23362 bytes app/jiacrontab_admin/jiacrontab_admin.ini | 44 ++ app/jiacrontab_admin/main.go | 88 ++++ app/jiacrontabd/jiacrontabd.ini | 22 + app/jiacrontabd/main.go | 77 +++ deployment/jiacrontab_admin.service | 21 + deployment/jiacrontabctl | 262 ++++++++++ deployment/jiacrontabd.service | 20 + go.mod | 37 ++ go.sum | 491 ++++++++++++++++++ jiacrontab_admin/.gitignore | 1 + jiacrontab_admin/admin.go | 92 ++++ jiacrontab_admin/app.go | 172 ++++++ jiacrontab_admin/config.go | 216 ++++++++ jiacrontab_admin/const.go | 32 ++ jiacrontab_admin/crontab.go | 266 ++++++++++ jiacrontab_admin/ctx.go | 253 +++++++++ jiacrontab_admin/daemon.go | 223 ++++++++ jiacrontab_admin/debug.go | 21 + jiacrontab_admin/group.go | 66 +++ jiacrontab_admin/ldap.go | 124 +++++ jiacrontab_admin/node.go | 168 ++++++ jiacrontab_admin/params.go | 456 ++++++++++++++++ jiacrontab_admin/recover.go | 62 +++ jiacrontab_admin/runtime.go | 26 + jiacrontab_admin/srv.go | 143 +++++ jiacrontab_admin/system.go | 76 +++ jiacrontab_admin/user.go | 501 ++++++++++++++++++ jiacrontab_admin/util.go | 75 +++ jiacrontabd/cmd.go | 331 ++++++++++++ jiacrontabd/config.go | 110 ++++ jiacrontabd/const.go | 1 + jiacrontabd/daemon.go | 275 ++++++++++ jiacrontabd/dependencies.go | 122 +++++ jiacrontabd/jiacrontabd.go | 489 ++++++++++++++++++ jiacrontabd/job.go | 604 ++++++++++++++++++++++ jiacrontabd/srv.go | 571 ++++++++++++++++++++ jiacrontabd/util.go | 83 +++ models/crontab.go | 222 ++++++++ models/crontab_test.go | 9 + models/daemon.go | 33 ++ models/db.go | 115 ++++ models/event.go | 29 ++ models/group.go | 24 + models/history.go | 33 ++ models/node.go | 96 ++++ models/setting.go | 13 + models/user.go | 116 +++++ pkg/base/stat.go | 278 ++++++++++ pkg/base/storage.go | 53 ++ pkg/crontab/crontab.go | 88 ++++ pkg/crontab/crontab_test.go | 57 ++ pkg/crontab/job.go | 213 ++++++++ pkg/crontab/job_test.go | 70 +++ pkg/crontab/parse.go | 128 +++++ pkg/file/file.go | 134 +++++ pkg/finder/finder.go | 220 ++++++++ pkg/finder/reader.go | 53 ++ pkg/kproc/proc.go | 38 ++ pkg/kproc/proc_posix.go | 78 +++ pkg/kproc/proc_windows.go | 51 ++ pkg/mailer/login.go | 31 ++ pkg/mailer/mail.go | 204 ++++++++ pkg/pprof/pprof.go | 75 +++ pkg/pprof/pprof_posix.go | 20 + pkg/pprof/pprof_windows.go | 4 + pkg/pqueue/pqueue.go | 89 ++++ pkg/pqueue/pqueue_test.go | 79 +++ pkg/proto/apicode.go | 18 + pkg/proto/args.go | 66 +++ pkg/proto/const.go | 10 + pkg/proto/crontab.go | 70 +++ pkg/proto/daemon.go | 12 + pkg/proto/resp.go | 14 + pkg/rpc/client.go | 111 ++++ pkg/rpc/client_test.go | 66 +++ pkg/rpc/clients.go | 86 +++ pkg/rpc/server.go | 41 ++ pkg/test/assertions.go | 58 +++ pkg/test/fakes.go | 45 ++ pkg/test/logger.go | 22 + pkg/util/arr.go | 10 + pkg/util/fn.go | 173 +++++++ pkg/util/ip.go | 30 ++ pkg/util/time.go | 18 + pkg/util/wait_group_wrapper.go | 17 + pkg/version/ver.go | 12 + qq.png | Bin 0 -> 42533 bytes 97 files changed, 10632 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 VERSION create mode 100644 admire.jpg create mode 100644 app/jiacrontab_admin/jiacrontab_admin.ini create mode 100644 app/jiacrontab_admin/main.go create mode 100644 app/jiacrontabd/jiacrontabd.ini create mode 100644 app/jiacrontabd/main.go create mode 100644 deployment/jiacrontab_admin.service create mode 100644 deployment/jiacrontabctl create mode 100644 deployment/jiacrontabd.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 jiacrontab_admin/.gitignore create mode 100644 jiacrontab_admin/admin.go create mode 100644 jiacrontab_admin/app.go create mode 100644 jiacrontab_admin/config.go create mode 100644 jiacrontab_admin/const.go create mode 100644 jiacrontab_admin/crontab.go create mode 100644 jiacrontab_admin/ctx.go create mode 100644 jiacrontab_admin/daemon.go create mode 100644 jiacrontab_admin/debug.go create mode 100644 jiacrontab_admin/group.go create mode 100644 jiacrontab_admin/ldap.go create mode 100644 jiacrontab_admin/node.go create mode 100644 jiacrontab_admin/params.go create mode 100644 jiacrontab_admin/recover.go create mode 100644 jiacrontab_admin/runtime.go create mode 100644 jiacrontab_admin/srv.go create mode 100644 jiacrontab_admin/system.go create mode 100644 jiacrontab_admin/user.go create mode 100644 jiacrontab_admin/util.go create mode 100644 jiacrontabd/cmd.go create mode 100644 jiacrontabd/config.go create mode 100644 jiacrontabd/const.go create mode 100644 jiacrontabd/daemon.go create mode 100644 jiacrontabd/dependencies.go create mode 100644 jiacrontabd/jiacrontabd.go create mode 100644 jiacrontabd/job.go create mode 100644 jiacrontabd/srv.go create mode 100644 jiacrontabd/util.go create mode 100644 models/crontab.go create mode 100644 models/crontab_test.go create mode 100644 models/daemon.go create mode 100644 models/db.go create mode 100644 models/event.go create mode 100644 models/group.go create mode 100644 models/history.go create mode 100644 models/node.go create mode 100644 models/setting.go create mode 100644 models/user.go create mode 100644 pkg/base/stat.go create mode 100644 pkg/base/storage.go create mode 100644 pkg/crontab/crontab.go create mode 100644 pkg/crontab/crontab_test.go create mode 100644 pkg/crontab/job.go create mode 100644 pkg/crontab/job_test.go create mode 100644 pkg/crontab/parse.go create mode 100644 pkg/file/file.go create mode 100644 pkg/finder/finder.go create mode 100644 pkg/finder/reader.go create mode 100644 pkg/kproc/proc.go create mode 100644 pkg/kproc/proc_posix.go create mode 100644 pkg/kproc/proc_windows.go create mode 100644 pkg/mailer/login.go create mode 100644 pkg/mailer/mail.go create mode 100644 pkg/pprof/pprof.go create mode 100644 pkg/pprof/pprof_posix.go create mode 100644 pkg/pprof/pprof_windows.go create mode 100644 pkg/pqueue/pqueue.go create mode 100644 pkg/pqueue/pqueue_test.go create mode 100644 pkg/proto/apicode.go create mode 100644 pkg/proto/args.go create mode 100644 pkg/proto/const.go create mode 100644 pkg/proto/crontab.go create mode 100644 pkg/proto/daemon.go create mode 100644 pkg/proto/resp.go create mode 100644 pkg/rpc/client.go create mode 100644 pkg/rpc/client_test.go create mode 100644 pkg/rpc/clients.go create mode 100644 pkg/rpc/server.go create mode 100644 pkg/test/assertions.go create mode 100644 pkg/test/fakes.go create mode 100644 pkg/test/logger.go create mode 100644 pkg/util/arr.go create mode 100644 pkg/util/fn.go create mode 100644 pkg/util/ip.go create mode 100644 pkg/util/time.go create mode 100644 pkg/util/wait_group_wrapper.go create mode 100644 pkg/version/ver.go create mode 100644 qq.png diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..99ed4c5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,10 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae5499b --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# ---> Go +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof +*.json +client +server +.data +data +.idea + +.directory +.vscode + +build +binTmp +dump.rdb +app/jiacrontab_admin/jiacrontab_admin +app/jiacrontabd/jiacrontabd + +app/jiacrontabd/logs +app/jiacrontab_admin/logs +app/jiacrontabd/pprof +app/jiacrontab_admin/pprof +/node_modules + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f08bdd9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,33 @@ +sudo: false +language: go +go: + - 1.12.x + +# Only clone the most recent commit. +git: + depth: 1 + +# Skip the install step. Don't `go get` dependencies. Only build with the code +# in vendor/ +install: true + +matrix: + fast_finish: true + include: + - go: 1.11.x + env: GO111MODULE=on + - go: 1.12.x + env: GO111MODULE=on + +services: + - postgresql + +# Don't email me the results of the test runs. +notifications: + email: false + +script: + - make test + +after_success: + - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..63d19a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# 更新记录 + +## v2.2.0 +1. 支持钉钉通知 +2. 修复mysql下无法自动创建表 +3. 修复无法正确删除动态 +4. 修复重启jiacrontabd时任务进程数量异常 +5. 修复编辑定时任务时无法正确停止原有任务 + +## v2.1.0 +1. 日志默认倒序查询 +2. 任务支持根据关键词搜索 +3. 新增磁盘清理动态清理 +4. 节点日志管理 +5. 修复bug + + +## v2.0.5 + +1. 修复修改job后前一次调度计划不能马上停止 +2. 修复管理员审核时编辑普通用户job造成的普通用户job丢失 +3. 修复密码无法修改 +4. 新增不活跃节点列表 + +## v2.0.4 + +1. 修复常驻任务无法删除 +2. 修复查看日志在特殊情况下日志异常 +3. 新增修改分组 + +## v2.0.3 + +1. 修复特定情况下节点失去连接 +2. 修复定时任务隔天无法生成日志目录 + +## v2.0.2 + +1. 修复由于多次修改job造成的定时器重复调度 + +## v2.0.1 + +1. 修复进程数量显示异常 +2. 手动执行任务的支持kill +3. 修复死锁造成的运行异常 +4. 修复依赖任务自定义代码不执行 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c12a471 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM yarnpkg/dev as frontend-env + +WORKDIR /jiacrontab +RUN apt-get install git +RUN git clone https://github.com/jiacrontab/jiacrontab-frontend.git +WORKDIR /jiacrontab/jiacrontab-frontend +RUN yarn && yarn build + +FROM golang AS jiacrontab-build +WORKDIR /jiacrontab +COPY . . +COPY --from=frontend-env /jiacrontab/jiacrontab-frontend/build /jiacrontab/frontend-build +RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct +RUN GO111MODULE=on go get -u github.com/go-bindata/go-bindata/v3/go-bindata +RUN make build assets=frontend-build + +FROM debian AS jiarontab-run +COPY --from=jiacrontab-build /jiacrontab/build /jiacrontab/build +WORKDIR /jiacrontab/bin +VOLUME ["/jiacrontab/bin/data"] +EXPOSE 20001 20000 20003 +RUN mv /jiacrontab/build/jiacrontab/jiacrontabd/* . && mv /jiacrontab/build/jiacrontab/jiacrontab_admin/* . +ENTRYPOINT [] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dbfab25 --- /dev/null +++ b/LICENSE @@ -0,0 +1,72 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d981547 --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +# Go parameters +goCmd=go +version=$(shell cat VERSION) +goBuild=$(goCmd) build -ldflags "-X jiacrontab/pkg/version.Binary=$(version)" +goClean=$(goCmd) clean +goTest=$(goCmd) test +goGet=$(goCmd) get +sourceAdmDir=./app/jiacrontab_admin +sourceNodeDir=./app/jiacrontabd +binAdm=$(sourceAdmDir)/jiacrontab_admin +binNode=$(sourceNodeDir)/jiacrontabd + + +buildDir=./build +buildAdmDir=$(buildDir)/jiacrontab/jiacrontab_admin +buildNodeDir=$(buildDir)/jiacrontab/jiacrontabd + +admCfg=$(sourceAdmDir)/jiacrontab_admin.ini +nodeCfg=$(sourceNodeDir)/jiacrontabd.ini +staticDir=./jiacrontab_admin/static/build +staticSourceDir=./jiacrontab_admin/static +workDir=$(shell pwd) + + +.PHONY: all build test clean build-linux build-windows +all: test build +build: + $(call checkStatic) + $(call init) + $(goBuild) -o $(binAdm) -v $(sourceAdmDir) + $(goBuild) -o $(binNode) -v $(sourceNodeDir) + mv $(binAdm) $(buildAdmDir) + mv $(binNode) $(buildNodeDir) +build2: + $(call init) + $(goBuild) -o $(binAdm) -v $(sourceAdmDir) + $(goBuild) -o $(binNode) -v $(sourceNodeDir) + mv $(binAdm) $(buildAdmDir) + mv $(binNode) $(buildNodeDir) +docker: + docker build \ + -t iwannay/jiacrontab:$(version) \ + -f Dockerfile \ + . +test: + $(goTest) -v -race -coverprofile=coverage.txt -covermode=atomic $(sourceAdmDir) + $(goTest) -v -race -coverprofile=coverage.txt -covermode=atomic $(sourceNodeDir) +clean: + rm -f $(binAdm) + rm -f $(binNode) + rm -rf $(buildDir) + + +# Cross compilation +build-linux: + $(call checkStatic) + $(call init) + GOOS=linux GOARCH=amd64 $(goBuild) -o $(binAdm) -v $(sourceAdmDir) + GOOS=linux GOARCH=amd64 $(goBuild) -o $(binNode) -v $(sourceNodeDir) + mv $(binAdm) $(buildAdmDir) + mv $(binNode) $(buildNodeDir) + +build-windows: + $(call checkStatic) + $(call init) + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC="x86_64-w64-mingw32-gcc -fno-stack-protector -D_FORTIFY_SOURCE=0 -lssp" $(goBuild) -o $(binAdm).exe -v $(sourceAdmDir) + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC="x86_64-w64-mingw32-gcc -fno-stack-protector -D_FORTIFY_SOURCE=0 -lssp" $(goBuild) -o $(binNode).exe -v $(sourceNodeDir) + + mv $(binAdm).exe $(buildAdmDir) + mv $(binNode).exe $(buildNodeDir) + +define checkStatic +@if [ "$(assets)" = "" ]; then echo "no assets, see https://github.com/jiacrontab/jiacrontab-frontend"; exit -1;else echo "build release"; fi + go-bindata -pkg admin -prefix $(assets) -o jiacrontab_admin/bindata_gzip.go -fs $(assets)/... +endef + +define init + rm -rf $(buildDir) + mkdir $(buildDir) + mkdir -p $(buildAdmDir) + mkdir -p $(buildNodeDir) + cp $(admCfg) $(buildAdmDir) + cp $(nodeCfg) $(buildNodeDir) +endef \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f01cd9c --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +## jiacrontab + +[![Build Status](https://travis-ci.org/iwannay/jiacrontab.svg?branch=dev)](https://travis-ci.org/iwannay/jiacrontab) + +简单可信赖的任务管理工具 + +### v2.0.0版发布 + + +### [❤jiacrontab 最新版下载点这里❤ ](https://jiacrontab.iwannay.cn/download/) + + 1.自定义job执行 + 2.允许设置job的最大并发数 + 3.每个脚本都可在web界面下灵活配置,如测试脚本运行,查看日志,强杀进程,停止定时... + 4.允许添加脚本依赖(支持跨服务器),依赖脚本提供同步和异步的执行模式 + 5.支持异常通知 + 6.支持守护脚本进程 + 7.支持节点分组 + + +### 架构 + + + +### 说明 + +jiacrontab 由 jiacrontab_admin,jiacrontabd 两部分构成,两者完全独立通过 rpc 通信 +jiacrontab_admin:管理后台向用户提供web操作界面 +jiacrontabd:负责job数据存储,任务调度 + + +### 安装 + +#### 二进制安装 + +1.[下载](https://jiacrontab.iwannay.cn/download/) 二进制文件。 + +2.解压缩进入目录(jiarontab_admin,jiacrontabd)。 + +3.运行 + +```sh +$ nohup ./jiacrontab_admin &> jiacrontab_admin.log & +$ nohup ./jiacrontabd &> jiacrontabd.log & + +## 建议使用systemd守护 +``` + +#### v2.x.x源码安装 + +1.安装 git,golang(version 1.12.x);可参考官网。 +2.安装运行 + +```sh +$ git clone git@github.com:iwannay/jiacrontab.git +$ cd jiacrontab +# 配置代理 +$ go env -w GONOPROXY=\*\*.baidu.com\*\* ## 配置GONOPROXY环境变量,所有百度内代码,不走代理 +$ go env -w GONOSUMDB=\* ## 配置GONOSUMDB,暂不支持sumdb索引 +$ go env -w GOPROXY=https://goproxy.baidu.com ## 配置GOPROXY,可以下载墙外代码 + +# 编译 +# 注意需要先编译前端(https://github.com/jiacrontab/jiacrontab-frontend) +# 再安装go-bindata +# 然后assets指定前端资源编译后的位置 +$ GO111MODULE=on go get -u github.com/go-bindata/go-bindata/v3/go-bindata +$ make build assets=$HOME/project/jiacrontab-frontend/build + +$ cd build/jiacrontab/jiacrontab_admin/ +$ nohup ./jiacrontab_admin &> jiacrontab_admin.log & + +$ cd build/jiacrontab/jiacrontabd/ +$ nohup ./jiacrontabd &> jiacrontabd.log & +``` + +浏览器访问 host:port (eg: localhost:20000) 即可访问管理后台 + +#### docker 安装 +```sh +# 下载镜像 +$ docker pull iwannay/jiacrontab:2.3.0 + +# 创建自定义网络 +$ docker network create mybridge + +# 启动jiacrontab_admin +# 需要指定配置文件目录时需要先挂载目录,然后-config指定 +$ docker run --network mybridge --name jiacrontab_admin -p 20000:20000 -it iwannay/jiacrontab:2.3.0 ./jiacrontab_admin + +# 启动jiacrontabd +# 需要指定配置文件目录时需要先挂载目录,然后-config指定 +$ docker run -v $(pwd)/jiacrontabd:/config --name jiacrontabd --network mybridge -it iwannay/jiacrontab:2.3.0 ./jiacrontabd -config /config/jiacrontabd.ini + +``` + +### 升级版本 + +1、下载新版本压缩包,并解压。 + +2、替换旧版jiacrontab_admin,jiacrontabd为新版执行文件 + +3、运行 + +### 基本使用 + +#### 定时任务 + +##### 超时设置和超时操作 + +超时后会进行设置的超时操作 默认值为 0 不判断超时 + +##### 最大并发数 + +最大并发数控制同一job同一个时刻最多允许存在的进程数,默认最大并发数为1,当前一次未执行结束时则放弃后续执行。 +防止脚本无法正常退出而导致系统资源耗尽 + +##### 添加依赖 + +依赖就是用户脚本执行前,需要先执行依赖脚本,只有依赖脚本执行完毕才会执行当前脚本。 +1. **并发执行** +并发执行依赖脚本,任意一个脚本出错或超时不会影响其他依赖脚本,但是会中断用户job +2. **同步执行** +同步执行依赖脚本,执行顺序为添加顺序,如果有一个依赖脚本出错或超时,则会中断后继依赖,以及用户job + +##### 脚本异常退出通知 + +如果脚本退出码不为0,则认为是异常退出 + +#### 常驻任务 + +常驻任务检查脚本进程是否退出,如果退出再次重启,保证脚本不停运行。 +注意:不支持后台进程。 + +## 附录 + +### 错误日志 + +错误日志存放在配置文件设置的目录下 +定时任务为 logs/crontab_task +定时任务为 logs/daemon_task +日志文件准确为日期目录下的 ID.log (eg: logs/crontab_task/2018/01/01/1.log) + +#### 错误日志信息 + +1. 正常错误日志 +程序原因产生的错误日志 +2. 自定义错误日志 +程序中自定义输出的信息,需要在输出信息后面加入换行 + +### 截图 + + +### 演示地址 + +[2.0.0版本演示地址](http://jiacrontab-spa.iwannay.cn/) 账号:test 密码:123456 + +### 商务合作 +合作 + + +### 赞助 +本项目花费了作者大量时间,如果你觉的该项目对你有用,或者你希望该项目有更好的发展,欢迎赞助。 +赞助qq群 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..cc6612c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.3.0 \ No newline at end of file diff --git a/admire.jpg b/admire.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d6d1985db5215caff5f64e73901307a44db4942a GIT binary patch literal 23362 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 ${2}.log &` + #sleep 1; + _timeNow + echo "${2}[ StartTime: ${nowtime} ]" >> ${PROJECT_ROOT}/${STIME_LOGFILE} + echo -e " ${ECHO_PREFIX} \033[33m${2}\033[0m (\033[32mStarted\033[0m)" + else + echo -e " ${ECHO_PREFIX} \033[33m${2:=Unknow}\033[0m is running (\033[33mSkiped\033[0m)" + fi +} + + +function _cliListen(){ + for cli in `ps -ef | grep "$APP_KEYWORDS" | grep -v "grep" | awk -F ' ./' '{print $2}'` + do + if [ "${cli}" == "${ADMIN_SCRIPT_NAME}" ];then + ADMIN_CLI_IS_RUNNING=1 + fi + if [ "${cli}" == "${BD_SCRIPT_NAME}" ];then + BD_CLI_IS_RUNNING=1 + fi + done +} + +function _parseDuringTime(){ + #parseTimeResultStr="" + logfile="${PROJECT_ROOT}/${STIME_LOGFILE}" + if [ -f $logfile ];then + stime=`grep "${1}\[ StartTime:" ${logfile} | tail -1 | awk '{print $3 " "$4}'` + _timeNow + time1=$(($(date +%s -d "$nowtime") - $(date +%s -d "$stime"))); + array=("Duration: ") + config=(31579200 "Year" 2635200 "Month" 86400 "Day" 3600 "Hour" 60 "Minute" 1 "Second") + j=0 + for str in ${config[*]}; do + if [ $(($j%2)) -eq 0 ];then + if [ ${time1} -ge ${config[$j]} ];then + j1=$(($j+1)) + j2=$(($j+2)) + array[$j1]=$[ $time1 / ${config[$j]} ] + array[$j2]=${config[$j1]} + if [ ${array[$j1]} -gt 1 ];then + array[$j2]="${array[$j2]}s" + fi + time1=$(($time1 % ${config[$j]})) + fi + fi + j=$(($j+1)) + done + if [ "Null" == "${stime}Null" ];then + return 0 + fi + parseTimeResultStr="${stime} (${array[*]})" + return 1 + fi + parseTimeResultStr="" + return 0 +} + +function start(){ + _cliListen + if [ $ADMIN_CLI_IS_RUNNING -eq 0 -a $BD_CLI_IS_RUNNING -eq 0 ];then + echo "" > ${PROJECT_ROOT}/${STIME_LOGFILE} + fi + _runApp "${ADMIN_CLI_IS_RUNNING}" "${ADMIN_SCRIPT_NAME}" + _runApp "${BD_CLI_IS_RUNNING}" "${BD_SCRIPT_NAME}" +} + +function stop(){ + for line in `ps -ef | grep "$APP_KEYWORDS" | grep -v grep | awk -F ' ' '{print $2 " " $NF}'` + do + STR=`echo $line | awk '{print $1}'` + if [ $STR -gt 0 ] 2> /dev/null ;then + kill -9 $STR; + else + echo -e " ${ECHO_PREFIX} \033[33m${STR}\033[0m (\033[34mStoped\033[0m) " + #sleep 1; + fi + done + echo "" > ${PROJECT_ROOT}/${STIME_LOGFILE} +} + +function status(){ + _cliListen + echo $FORMATER_LINE + echo " ${ECHO_PREFIX}" + if [ $ADMIN_CLI_IS_RUNNING -eq 0 ];then + echo -e " ${ECHO_PREFIX} Active: \033[31minactive (dead)\033[0m \033[33m${ADMIN_SCRIPT_NAME}\033[0m since Unknow" + else + _parseDuringTime "${ADMIN_SCRIPT_NAME}" + if [ $? -gt 0 ] 2> /dev/null;then + # 全局获取函数返回的字符串 + echo -e " ${ECHO_PREFIX} Active: \033[32mactive (running)\033[0m \033[33m${ADMIN_SCRIPT_NAME}\033[0m since ${parseTimeResultStr}" + #sleep 1 + else + echo -e " ${ECHO_PREFIX} Active: \033[32mactive (running)\033[0m \033[33m${ADMIN_SCRIPT_NAME}\033[0m since Unknow" + fi + fi + + if [ $BD_CLI_IS_RUNNING -eq 0 ];then + echo -e " ${ECHO_PREFIX} Active: \033[31minactive (dead)\033[0m \033[33m${BD_SCRIPT_NAME}\033[0m since Unknow" + else + _parseDuringTime "${BD_SCRIPT_NAME}" + if [ $? -gt 0 ] 2> /dev/null;then + # 全局获取函数返回的字符串 + echo -e " ${ECHO_PREFIX} Active: \033[32mactive (running)\033[0m \033[33m${BD_SCRIPT_NAME}\033[0m since ${parseTimeResultStr}" + #sleep 1 + else + echo -e " ${ECHO_PREFIX} Active: \033[32mactive (running)\033[0m \033[33m${BD_SCRIPT_NAME}\033[0m since Unknow" + fi + fi + echo " ${ECHO_PREFIX}" + echo $FORMATER_LINE + if [ $ADMIN_CLI_IS_RUNNING -gt 0 -o $BD_CLI_IS_RUNNING -gt 0 ];then + + ps -ef | grep "$APP_KEYWORDS" | grep -v grep + sleep 1 + echo $FORMATER_LINE + netstat -tlpan | grep "$APP_KEYWORDS" | grep -v grep + fi +} + +function cli(){ + echo "input: $0 OPTION" >&2 + echo " OPTION :" + echo " start" + echo " stop" + echo " status" + echo " restart" + echo " h | help" +} + +function helpDoc() { + echo $FORMATER_LINE + #echo " " + echo " Jiacrontab 简单可信赖的任务管理工具(V2.2.0)" + echo " 1.自定义job执行" + echo " 2.允许设置job的最大并发数" + echo " 3.每个脚本都可在web界面下灵活配置,如测试脚本运行,查看日志,强杀进程,停止定时..." + echo " 4.允许添加脚本依赖(支持跨服务器),依赖脚本提供同步和异步的执行模式" + echo " 5.支持异常通知" + echo " 6.支持守护脚本进程" + echo " 7.支持节点分组" + echo " n.更多请访问仓库查看" + #echo " " + echo $FORMATER_LINE + #echo " " + echo " Github: https://github.com/iwannay/jiacrontab" + echo " Csdn: https://codechina.csdn.net/mirrors/iwannay/jiacrontab" + echo " " + echo " Package: https://jiacrontab.iwannay.cn/download/" + #echo " " + echo $FORMATER_LINE + cli +} +COMMAND=$1 +shift 1 +case $COMMAND in + start) + start; + ;; + stop) + stop; + ;; + status) + status; + ;; + restart) + stop; + sleep 1 + start; + sleep 1 + status; + ;; + h|help) + helpDoc; + ;; + *) + cli; + exit 1; + ;; +esac \ No newline at end of file diff --git a/deployment/jiacrontabd.service b/deployment/jiacrontabd.service new file mode 100644 index 0000000..584634b --- /dev/null +++ b/deployment/jiacrontabd.service @@ -0,0 +1,20 @@ +[Unit] +Description=jiacrontabd service +After=network.target + +[Install] +WantedBy=multi-user.target + +[Service] +Type=simple +User=root +Group=root +ProtectSystem=full +WorkingDirectory=/opt/jiacrontab/jiacrontabd +ExecStart=/opt/jiacrontab/jiacrontabd/jiacrontabd +KillMode=process +KillSignal=SIGTERM +SendSIGKILL=no +Restart=on-abort +RestartSec=5s +UMask=007 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9c1b2b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module jiacrontab + +go 1.14 + +require ( + github.com/Joker/hpp v1.0.0 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/go-ldap/ldap/v3 v3.3.0 + github.com/gofrs/uuid v3.2.0+incompatible + github.com/google/go-cmp v0.5.1 // indirect + github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f // indirect + github.com/iris-contrib/middleware/cors v0.0.0-20200810001613-32cf668f999f + github.com/iris-contrib/middleware/jwt v0.0.0-20200810001613-32cf668f999f + github.com/iwannay/log v0.0.0-20190630100042-7fa98f256ca1 + github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect + github.com/kataras/iris/v12 v12.1.9-0.20200814111841-d0d7679a98f2 + github.com/lib/pq v1.8.0 // indirect + github.com/mattn/go-colorable v0.1.7 // indirect + github.com/mattn/go-sqlite3 v1.14.4 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/onsi/ginkgo v1.10.1 // indirect + github.com/onsi/gomega v1.7.0 // indirect + github.com/smartystreets/assertions v1.0.1 // indirect + github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/yudai/pp v2.0.1+incompatible // indirect + golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + gopkg.in/ini.v1 v1.58.0 + gorm.io/driver/mysql v1.0.3 + gorm.io/driver/postgres v1.0.5 + gorm.io/driver/sqlite v1.1.3 + gorm.io/gorm v1.20.6 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d17fb0f --- /dev/null +++ b/go.sum @@ -0,0 +1,491 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= +github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v4 v4.1.0 h1:EvZLdRZYZ6iljADEhrfWSCSNOJRDntNqEJJmeP0Sjg0= +github.com/CloudyKit/jet/v4 v4.1.0/go.mod h1:DhUsGNEpjPmBD0zmGNP8DaSV1dGO8g9U4adIK8BCWmw= +github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398 h1:WDC6ySpJzbxGWFh4aMxFFC28wwGp5pEuoTtvA4q/qQ4= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/andybalholm/brotli v1.0.1-0.20200619015827-c3da72aa01ed h1:G/gj6aolvcaqMTCmlHRDsLLQlJ/fXTC4vE9o18KRZtw= +github.com/andybalholm/brotli v1.0.1-0.20200619015827-c3da72aa01ed/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible h1:Ppm0npCCsmuR9oQaBtRuZcmILVE74aXE+AmrJj8L2ns= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chris-ramon/douceur v0.2.0 h1:IDMEdxlEUUBYBKE4z/mJnFyVXox+MjuEVDJNN27glkU= +github.com/chris-ramon/douceur v0.2.0/go.mod h1:wDW5xjJdeoMm1mRt4sD4c/LbF/mWdEpRXQKjTR8nIBE= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= +github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +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-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +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/dgraph-io/badger/v2 v2.0.3/go.mod h1:3KY8+bsP8wI0OEnQJAKpd4wIJW/Mm32yw2j/9FUVnIM= +github.com/dgraph-io/ristretto v0.0.2-0.20200115201040-8f368f2f2ab3/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +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-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +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/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= +github.com/go-asn1-ber/asn1-ber v1.5.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-ldap/ldap/v3 v3.3.0 h1:lwx+SJpgOHd8tG6SumBQZXCmNX51zM8B1cfxJ5gv4tQ= +github.com/go-ldap/ldap/v3 v3.3.0/go.mod h1:iYS1MdmrmceOJ1QOTnRXrIs7i3kloqtmGQjRvjKpyMg= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.3/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +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.4/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 h1:ZFgWrT+bLgsYPirOnRfKLYJLvssAegOj/hgyMFdJZe0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.8.2/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +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.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.2-0.20200519141726-cb32006e483f h1:qa1wFcvZzVLbFVPdsdTsWL6k5IP6BEmFmd9SeahRQ5s= +github.com/google/uuid v1.1.2-0.20200519141726-cb32006e483f/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f h1:KMlcu9X58lhTA/KrfX8Bi1LQSO4pzoVjTiL3h4Jk+Zk= +github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/httpexpect/v2 v2.0.5 h1:b2Orx2FXRhnmZil4td66C8zzkHnssSoFQP2HQtyktJg= +github.com/iris-contrib/httpexpect/v2 v2.0.5/go.mod h1:JpRu+DEVVCA6KHLKUAs72QoaevQESqLHuG5s1CQ+QiA= +github.com/iris-contrib/jade v1.1.4 h1:WoYdfyJFfZIUgqNAeOyRfTNQZOksSlZ6+FnXR3AEpX0= +github.com/iris-contrib/jade v1.1.4/go.mod h1:EDqR+ur9piDl6DUgs6qRrlfzmlx/D5UybogqrXvJTBE= +github.com/iris-contrib/middleware/cors v0.0.0-20200810001613-32cf668f999f h1:twQZd21/tSQPaAuJKuYLKvNyB/lw1dIe2kWz9RDvxSs= +github.com/iris-contrib/middleware/cors v0.0.0-20200810001613-32cf668f999f/go.mod h1:dmEl2AW/sx5szUpqERCyD0uJBcCVZrbWZFBhvqHnEeQ= +github.com/iris-contrib/middleware/jwt v0.0.0-20200810001613-32cf668f999f h1:4PJtwZeyY7jqigFhO7PuNiyD2joElia2Nn6+BXi/dps= +github.com/iris-contrib/middleware/jwt v0.0.0-20200810001613-32cf668f999f/go.mod h1:CXIqdYkSJA3XCO1fAD6bHZozgH9CrGNCHmrdHI6hkk4= +github.com/iris-contrib/pongo2 v0.0.1 h1:zGP7pW51oi5eQZMIlGA3I+FHY9/HOQWDB+572yin0to= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.2 h1:qd3RU2sLPaTTamv6BGn+PDD5Gmny+i3jO8xDAmWnNLs= +github.com/iris-contrib/schema v0.0.2/go.mod h1:iYszG0IOsuIsfzjymw1kMzTL8YQcCWlm65f3wX8J5iA= +github.com/iwannay/log v0.0.0-20190630100042-7fa98f256ca1 h1:3CFmSUt7+8NHOo7S1nVDy+WpDXCBUNBJJOGx0sjndgA= +github.com/iwannay/log v0.0.0-20190630100042-7fa98f256ca1/go.mod h1:uZNVAfgxWNr2WSlZaPFV4nd8pLH3qDWD/A498RmicAI= +github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0= +github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= +github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= +github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= +github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA= +github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE= +github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s= +github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5lahk= +github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= +github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI= +github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U= +github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA= +github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= +github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA= +github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A= +github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA= +github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg= +github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM= +github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM= +github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= +github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg= +github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc= +github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw= +github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0= +github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po= +github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ= +github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc= +github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig= +github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y= +github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM= +github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc= +github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA= +github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o= +github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg= +github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c= +github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms= +github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/blocks v0.0.2 h1:XSeCznI3UW8MJZj2rH2lQRVrsyahdJzagBcQf7N8OeE= +github.com/kataras/blocks v0.0.2/go.mod h1:KPyOYc1M3MgzsznVcdjErtcYWO3AZXQbQ8fMYWcr3oA= +github.com/kataras/golog v0.0.18/go.mod h1:jRYl7dFYqP8aQj9VkwdBUXYZSfUktm+YYg1arJILfyw= +github.com/kataras/golog v0.1.0 h1:7MCQeWkC18dn1Y431E85sUCalTLrhYrFGhLnHo29/QI= +github.com/kataras/golog v0.1.0/go.mod h1:jOSQ+C5fUqsNSwurB/oAHq1IFSb0KI3l6GMa7xB6dZA= +github.com/kataras/iris/v12 v12.1.9-0.20200809192844-da029d6f3722/go.mod h1:cTaBLtn3jPr4QuZ9uF/o/GyWzpVfQLsk6uJpLLUUp8M= +github.com/kataras/iris/v12 v12.1.9-0.20200814111841-d0d7679a98f2 h1:JRJIgvyzB6Fwdi9YaFtK/Kt5zpaHYYdHdmBwsSmShn4= +github.com/kataras/iris/v12 v12.1.9-0.20200814111841-d0d7679a98f2/go.mod h1:gx7fL2wqb3bPpxMNoIII6R4dKxI9SZapItl0rS8F7Jo= +github.com/kataras/neffos v0.0.16/go.mod h1:BqWkF1c6cSyqw85dfCdqXxK5cMo/hyBGhtNuFkxHyMg= +github.com/kataras/pio v0.0.8/go.mod h1:NFfMp2kVP1rmV4N6gH6qgWpuoDKlrOeYi3VrAIWCGsE= +github.com/kataras/pio v0.0.10 h1:b0qtPUqOpM2O+bqa5wr2O6dN4cQNwSmFd6HQqgVae0g= +github.com/kataras/pio v0.0.10/go.mod h1:gS3ui9xSD+lAUpbYnjOGiQyY7sUMJO+EHpiRzhtZ5no= +github.com/kataras/sitemap v0.0.5 h1:4HCONX5RLgVy6G4RkYOV3vKNcma9p236LdGOipJsaFE= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kataras/tunnel v0.0.1 h1:VsphzEnN8es/0tw7lwxC94pHwUa60nE9SYojKhbWjME= +github.com/kataras/tunnel v0.0.1/go.mod h1:Gslr6f+Y0esb704OqMi8hNOfFImgXcmg4KiN2zScgvs= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/klauspost/compress v1.10.11 h1:K9z59aO18Aywg2b/WSgBaUX99mHy2BES18Cr5lBKZHk= +github.com/klauspost/compress v1.10.11/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +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.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= +github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI= +github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/mediocregopher/radix/v3 v3.5.0/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/mediocregopher/radix/v3 v3.5.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/microcosm-cc/bluemonday v1.0.3/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= +github.com/microcosm-cc/bluemonday v1.0.4 h1:p0L+CTpo/PLFdkoPcJemLXG+fpMD7pYOoDEq1axMbGg= +github.com/microcosm-cc/bluemonday v1.0.4/go.mod h1:8iwZnFn2CDDNZ0r6UXhF4xawGvzaqzCRa1n3/lO3W2w= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +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-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/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU= +github.com/nats-io/nats.go v1.9.2/go.mod h1:AjGArbfyR50+afOUotNX2Xs5SYHf+CoOa5HH1eEl2HE= +github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nkeys v0.1.4/go.mod h1:XdZpAbhgyyODYqjTawOnIOI7VlbKSarI9Gfy1tqEu/s= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= +github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v2.1.0+incompatible h1:j1Wcmh8OrK4Q7GXY+V7SVSY8nUWQxHW5TkBe7YUl+2s= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiyyjYS17cCYRqP13/SHk= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= +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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= +github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY= +github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.0.1 h1:voD4ITNjPL5jjBfgR/r8fPIIBrliWrWHeiJApdr3r4w= +github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 h1:WN9BUFbdyOsSH/XohnWpXOlq9NBD5sGAB2FciQMUEe8= +github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693/go.mod h1:6hSY48PjDm4UObWmGLyJE9DxYVKTgR9kbCspXXJEhcU= +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 v1.2.2/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/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/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/vmihailenco/msgpack/v4 v4.3.11/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 h1:d71/KA0LhvkrJ/Ok+Wx9qK7bU8meKA1Hk0jpVI5kJjk= +github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1/go.mod h1:xlngVLeyQ/Qi05oQxhQ+oTuqa03RjMwMfk/7/TCs+QI= +github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yosssi/ace v0.0.5 h1:tUkIP/BLdKqrlrPwcmH0shwEEhTRHoGnc1wFIWmaBUA= +github.com/yosssi/ace v0.0.5/go.mod h1:ALfIzm2vT7t5ZE7uoIZqF3TQ7SAOyupFZnkrF5id+K0= +github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/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-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +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-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +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-20181220203305-927f97764cc3/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-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +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-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20181205085412-a5c9d58dba9a/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-20190403152447-81d4e9dc473e/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-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9 h1:yi1hN8dcqI9l8klZfy4B8mJvFmmAxJEePIQQFNSd7Cs= +golang.org/x/sys v0.0.0-20200808120158-1030fc2bf1d9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/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-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-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/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-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-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/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/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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/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= +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.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/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-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +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.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= +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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= +gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.58.0 h1:VdDvTzv/005R8vEFyQ56bpEnOKTNPbpJhL0VCohxlQw= +gopkg.in/ini.v1 v1.58.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +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.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 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg= +gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI= +gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw= +gorm.io/driver/postgres v1.0.5/go.mod h1:qrD92UurYzNctBMVCJ8C3VQEjffEuphycXtxOudXNCA= +gorm.io/driver/sqlite v1.1.3 h1:BYfdVuZB5He/u9dt4qDpZqiqDJ6KhPqs5QUqsr/Eeuc= +gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c= +gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +gorm.io/gorm v1.20.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +gorm.io/gorm v1.20.6 h1:qa7tC1WcU+DBI/ZKMxvXy1FcrlGsvxlaKufHrT2qQ08= +gorm.io/gorm v1.20.6/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/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/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= +moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE= diff --git a/jiacrontab_admin/.gitignore b/jiacrontab_admin/.gitignore new file mode 100644 index 0000000..b36510d --- /dev/null +++ b/jiacrontab_admin/.gitignore @@ -0,0 +1 @@ +bindata_gzip.go \ No newline at end of file diff --git a/jiacrontab_admin/admin.go b/jiacrontab_admin/admin.go new file mode 100644 index 0000000..05b67f1 --- /dev/null +++ b/jiacrontab_admin/admin.go @@ -0,0 +1,92 @@ +package admin + +import ( + "errors" + "jiacrontab/models" + "jiacrontab/pkg/mailer" + "jiacrontab/pkg/rpc" + "time" + + "sync/atomic" + + "github.com/kataras/iris/v12" +) + +type Admin struct { + cfg atomic.Value + ldap *Ldap + initAdminUser int32 +} + +func (n *Admin) getOpts() *Config { + return n.cfg.Load().(*Config) +} + +func (n *Admin) swapOpts(opts *Config) { + n.cfg.Store(opts) +} + +func New(opt *Config) *Admin { + adm := &Admin{} + adm.swapOpts(opt) + return adm +} + +func (a *Admin) init() { + cfg := a.getOpts() + if err := models.InitModel(cfg.Database.DriverName, cfg.Database.DSN, cfg.App.Debug); err != nil { + panic(err) + } + if models.DB().Take(&models.User{}, "group_id=?", 1).Error == nil { + atomic.StoreInt32(&a.initAdminUser, 1) + } + // mail + if cfg.Mailer.Enabled { + mailer.InitMailer(&mailer.Mailer{ + QueueLength: cfg.Mailer.QueueLength, + SubjectPrefix: cfg.Mailer.SubjectPrefix, + From: cfg.Mailer.From, + Host: cfg.Mailer.Host, + User: cfg.Mailer.User, + Passwd: cfg.Mailer.Passwd, + FromEmail: cfg.Mailer.FromEmail, + DisableHelo: cfg.Mailer.DisableHelo, + HeloHostname: cfg.Mailer.HeloHostname, + SkipVerify: cfg.Mailer.SkipVerify, + UseCertificate: cfg.Mailer.UseCertificate, + CertFile: cfg.Mailer.CertFile, + KeyFile: cfg.Mailer.KeyFile, + UsePlainText: cfg.Mailer.UsePlainText, + HookMode: false, + }) + } + a.ldap = &Ldap{ + BindUserDn: cfg.Ldap.BindUserdn, + BindPwd: cfg.Ldap.BindPasswd, + BaseOn: cfg.Ldap.Basedn, + UserField: cfg.Ldap.UserField, + Addr: cfg.Ldap.Addr, + DisabledAnonymousQuery: cfg.Ldap.DisabledAnonymousQuery, + Timeout: time.Second * time.Duration(cfg.Ldap.Timeout), + } +} + +func (a *Admin) ResetPwd(username string, password string) error { + if username == "" || password == "" { + return errors.New("username or password cannot empty") + } + a.init() + user := models.User{ + Username: username, + Passwd: password, + } + return user.Update() +} + +func (a *Admin) Main() { + cfg := a.getOpts() + a.init() + go rpc.ListenAndServe(cfg.App.RPCListenAddr, NewSrv(a)) + app := newApp(a) + app.Run(iris.Addr(cfg.App.HTTPListenAddr)) +} diff --git a/jiacrontab_admin/app.go b/jiacrontab_admin/app.go new file mode 100644 index 0000000..c8ddcd1 --- /dev/null +++ b/jiacrontab_admin/app.go @@ -0,0 +1,172 @@ +package admin + +import ( + "net/url" + "sync/atomic" + + "github.com/kataras/iris/v12" + "github.com/kataras/iris/v12/middleware/logger" + + "jiacrontab/models" + + "fmt" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/iris-contrib/middleware/cors" + jwtmiddleware "github.com/iris-contrib/middleware/jwt" + "github.com/kataras/iris/v12/context" +) + +func newApp(adm *Admin) *iris.Application { + + app := iris.New() + app.UseGlobal(newRecover(adm)) + app.Logger().SetLevel(adm.getOpts().App.LogLevel) + app.Use(logger.New()) + app.HandleDir("/", AssetFile(), iris.DirOptions{ + IndexName: "index.html", + Cache: iris.DirCacheOptions{ + Enable: true, + CompressIgnore: iris.MatchImagesAssets, + Encodings: []string{"gzip", "deflate", "br", "snappy"}, + CompressMinSize: 50, + Verbose: 1, + }, + }) + // app.StaticEmbeddedGzip("/", "./assets/", GzipAsset, GzipAssetNames) + cfg := adm.getOpts() + + wrapHandler := func(h func(ctx *myctx)) context.Handler { + return func(c iris.Context) { + h(wrapCtx(c, adm)) + } + } + + jwtHandler := jwtmiddleware.New(jwtmiddleware.Config{ + ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { + return []byte(cfg.Jwt.SigningKey), nil + }, + + Extractor: func(ctx iris.Context) (string, error) { + token, err := url.QueryUnescape(ctx.GetHeader(cfg.Jwt.Name)) + return token, err + }, + Expiration: true, + + ErrorHandler: func(c iris.Context, err error) { + ctx := wrapCtx(c, adm) + if ctx.RequestPath(true) != "/user/login" { + ctx.respAuthFailed(fmt.Errorf("Token verification failed(%s)", err)) + return + } + ctx.Next() + }, + + SigningMethod: jwt.SigningMethodHS256, + }) + + crs := cors.New(cors.Options{ + Debug: false, + AllowedHeaders: []string{"Content-Type", "Token"}, + AllowedOrigins: []string{"*"}, // allows everything, use that to change the hosts. + AllowCredentials: true, + }) + + app.Use(crs) + app.AllowMethods(iris.MethodOptions) + + v1 := app.Party("/v1") + { + v1.Post("/user/login", wrapHandler(Login)) + v1.Post("/app/init", wrapHandler(InitApp)) + } + + v2 := app.Party("/v2") + { + v2.Use(jwtHandler.Serve) + v2.Use(wrapHandler(func(ctx *myctx) { + if err := ctx.parseClaimsFromToken(); err != nil { + ctx.respJWTError(err) + return + } + ctx.Next() + })) + v2.Post("/crontab/job/list", wrapHandler(GetJobList)) + v2.Post("/crontab/job/get", wrapHandler(GetJob)) + v2.Post("/crontab/job/log", wrapHandler(GetRecentLog)) + v2.Post("/crontab/job/edit", wrapHandler(EditJob)) + v2.Post("/crontab/job/action", wrapHandler(ActionTask)) + v2.Post("/crontab/job/exec", wrapHandler(ExecTask)) + + v2.Post("/config/get", wrapHandler(GetConfig)) + v2.Post("/config/mail/send", wrapHandler(SendTestMail)) + v2.Post("/system/info", wrapHandler(SystemInfo)) + v2.Post("/log/info", wrapHandler(LogInfo)) + v2.Post("/log/clean", wrapHandler(CleanLog)) + + v2.Post("/daemon/job/list", wrapHandler(GetDaemonJobList)) + v2.Post("/daemon/job/action", wrapHandler(ActionDaemonTask)) + v2.Post("/daemon/job/edit", wrapHandler(EditDaemonJob)) + v2.Post("/daemon/job/get", wrapHandler(GetDaemonJob)) + v2.Post("/daemon/job/log", wrapHandler(GetRecentDaemonLog)) + + v2.Post("/group/list", wrapHandler(GetGroupList)) + v2.Post("/group/edit", wrapHandler(EditGroup)) + + v2.Post("/node/list", wrapHandler(GetNodeList)) + v2.Post("/node/delete", wrapHandler(DeleteNode)) + v2.Post("/node/group_node", wrapHandler(GroupNode)) + v2.Post("/node/clean_log", wrapHandler(CleanNodeLog)) + + v2.Post("/user/activity_list", wrapHandler(GetActivityList)) + v2.Post("/user/job_history", wrapHandler(GetJobHistory)) + v2.Post("/user/audit_job", wrapHandler(AuditJob)) + v2.Post("/user/stat", wrapHandler(UserStat)) + v2.Post("/user/signup", wrapHandler(Signup)) + v2.Post("/user/edit", wrapHandler(EditUser)) + v2.Post("/user/delete", wrapHandler(DeleteUser)) + v2.Post("/user/group_user", wrapHandler(GroupUser)) + v2.Post("/user/list", wrapHandler(GetUserList)) + } + + debug := app.Party("/debug") + { + debug.Get("/stat", wrapHandler(stat)) + debug.Get("/pprof/", wrapHandler(indexDebug)) + debug.Get("/pprof/{key:string}", wrapHandler(pprofHandler)) + } + + return app +} + +// InitApp 初始化应用 +func InitApp(ctx *myctx) { + var ( + err error + user models.User + reqBody InitAppReqParams + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if ret := models.DB().Take(&user, "group_id=?", 1); ret.Error == nil && ret.RowsAffected > 0 { + ctx.respNotAllowed() + return + } + + user.Username = reqBody.Username + user.Passwd = reqBody.Passwd + user.Root = true + user.GroupID = models.SuperGroup.ID + user.Mail = reqBody.Mail + + if err = user.Create(); err != nil { + ctx.respBasicError(err) + return + } + atomic.StoreInt32(&ctx.adm.initAdminUser, 1) + ctx.respSucc("", true) +} diff --git a/jiacrontab_admin/config.go b/jiacrontab_admin/config.go new file mode 100644 index 0000000..9ada7f3 --- /dev/null +++ b/jiacrontab_admin/config.go @@ -0,0 +1,216 @@ +package admin + +import ( + "errors" + "jiacrontab/pkg/file" + "jiacrontab/pkg/mailer" + "reflect" + "time" + + "github.com/iwannay/log" + + ini "gopkg.in/ini.v1" +) + +const ( + appname = "jiacrontab" +) + +type AppOpt struct { + HTTPListenAddr string `opt:"http_listen_addr"` + RPCListenAddr string `opt:"rpc_listen_addr"` + AppName string `opt:"app_name" ` + Debug bool `opt:"debug" ` + LogLevel string `opt:"log_level"` + SigningKey string `opt:"signing_key"` + MaxClientAliveInterval int `opt:"max_client_alive_interval"` +} + +type JwtOpt struct { + SigningKey string `opt:"signing_key"` + Name string `opt:"name" ` + Expires int64 `opt:"expires"` +} + +type MailerOpt struct { + Enabled bool `opt:"enabled"` + QueueLength int `opt:"queue_length"` + SubjectPrefix string `opt:"subject_Prefix"` + Host string `opt:"host"` + From string `opt:"from"` + FromEmail string `opt:"from_email"` + User string `opt:"user"` + Passwd string `opt:"passwd"` + DisableHelo bool `opt:"disable_helo"` + HeloHostname string `opt:"helo_hostname"` + SkipVerify bool `opt:"skip_verify"` + UseCertificate bool `opt:"use_certificate"` + CertFile string `opt:"cert_file"` + KeyFile string `opt:"key_file"` + UsePlainText bool `opt:"use_plain_text"` +} + +type databaseOpt struct { + DriverName string `opt:"driver_name"` + DSN string `opt:"dsn"` +} + +type ldapOpt struct { + Addr string `opt:"addr"` + DisabledAnonymousQuery bool `opt:"disabled_anonymous_query"` + Basedn string `opt:"basedn"` + Timeout int `opt:"timeout"` + BindPasswd string `opt:"bind_passwd"` + BindUserdn string `opt:"bind_userdn"` + UserField string `opt:"user_field"` +} + +type Config struct { + Mailer *MailerOpt `section:"mail"` + Jwt *JwtOpt `section:"jwt"` + App *AppOpt `section:"app"` + Database *databaseOpt `section:"database"` + Ldap *ldapOpt `section:"ldap"` + + CfgPath string + iniFile *ini.File + ServerStartTime time.Time `json:"-"` +} + +func (c *Config) Resolve() { + + c.ServerStartTime = time.Now() + c.iniFile = c.loadConfig(c.CfgPath) + + val := reflect.ValueOf(c).Elem() + typ := val.Type() + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + section := field.Tag.Get("section") + if section == "" { + continue + } + subVal := reflect.ValueOf(val.Field(i).Interface()).Elem() + subtyp := subVal.Type() + for j := 0; j < subtyp.NumField(); j++ { + subField := subtyp.Field(j) + subOpt := subField.Tag.Get("opt") + if subOpt == "" { + continue + } + sec := c.iniFile.Section(section) + + if !sec.HasKey(subOpt) { + continue + } + + key := sec.Key(subOpt) + + switch subField.Type.Kind() { + case reflect.Bool: + v, err := key.Bool() + if err != nil { + log.Error(err) + continue + } + subVal.Field(j).SetBool(v) + case reflect.String: + subVal.Field(j).SetString(key.String()) + case reflect.Int64: + v, err := key.Int64() + if err != nil { + log.Error(err) + continue + } + subVal.Field(j).SetInt(v) + } + } + } +} + +func NewConfig() *Config { + return &Config{ + App: &AppOpt{ + Debug: false, + HTTPListenAddr: ":20000", + RPCListenAddr: ":20003", + AppName: "jiacrontab", + LogLevel: "warn", + SigningKey: "WERRTT1234$@#@@$", + MaxClientAliveInterval: 30, + }, + Mailer: &MailerOpt{ + Enabled: false, + QueueLength: 1000, + SubjectPrefix: "jiacrontab", + SkipVerify: true, + UseCertificate: false, + }, + Jwt: &JwtOpt{ + SigningKey: "ADSFdfs2342$@@#", + Name: "token", + Expires: 3600, + }, + Ldap: &ldapOpt{}, + Database: &databaseOpt{}, + } +} + +func (c *Config) loadConfig(path string) *ini.File { + if !file.Exist(path) { + f, err := file.CreateFile(path) + if err != nil { + panic(err) + } + f.Close() + } + + iniFile, err := ini.Load(path) + if err != nil { + panic(err) + } + return iniFile +} + +func GetConfig(ctx *myctx) { + cfg := ctx.adm.getOpts() + if !ctx.isSuper() { + ctx.respNotAllowed() + return + } + ctx.respSucc("", map[string]interface{}{ + "mail": map[string]interface{}{ + "host": cfg.Mailer.Host, + "user": cfg.Mailer.User, + "use_certificate": cfg.Mailer.UseCertificate, + "skip_verify": cfg.Mailer.SkipVerify, + "cert_file": cfg.Mailer.CertFile, + "key_file": cfg.Mailer.KeyFile, + }, + }) +} + +func SendTestMail(ctx *myctx) { + var ( + err error + reqBody SendTestMailReqParams + cfg = ctx.adm.getOpts() + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if cfg.Mailer.Enabled { + err = mailer.SendMail([]string{reqBody.MailTo}, "jiacrontab欢迎你", "来自jiacrontab的温馨祝福!") + if err != nil { + ctx.respBasicError(err) + return + } + ctx.respSucc("", nil) + return + } + + ctx.respBasicError(errors.New("邮箱服务未开启")) +} diff --git a/jiacrontab_admin/const.go b/jiacrontab_admin/const.go new file mode 100644 index 0000000..f724ef6 --- /dev/null +++ b/jiacrontab_admin/const.go @@ -0,0 +1,32 @@ +package admin + +const ( + event_DelNodeDesc = "{username}删除了节点{targetName}" + event_RenameNode = "{username}将{sourceName}重命名为{targetName}" + + event_EditCronJob = "{sourceName}{username}编辑了定时任务{targetName}" + event_DelCronJob = "{sourceName}{username}删除了定时任务{targetName}" + event_StopCronJob = "{sourceName}{username}停止了定时任务{targetName}" + event_StartCronJob = "{sourceName}{username}启动了定时任务{targetName}" + event_ExecCronJob = "{sourceName}{username}执行了定时任务{targetName}" + event_KillCronJob = "{sourceName}{username}kill了定时任务进程{targetName}" + + event_EditDaemonJob = "{sourceName}{username}编辑了常驻任务{targetName}" + event_DelDaemonJob = "{sourceName}{username}删除了常驻任务{targetName}" + event_StartDaemonJob = "{sourceName}{username}启动了常驻任务{targetName}" + event_StopDaemonJob = "{sourceName}{username}停止了常驻任务{targetName}" + + event_EditGroup = "{username}编辑了{targetName}组" + event_GroupNode = "{username}将节点{sourceName}添加到{targetName}组" + + event_SignUpUser = "{username}创建了用户{targetName}" + event_EditUser = "{username}更新了用户信息" + event_DeleteUser = "{username}删除了用户{targetName}" + event_GroupUser = "{username}将用户{sourceUsername}设置为{targetName}组" + event_AuditCrontabJob = "{sourceName}{username}审核了定时任务{targetName}" + event_AuditDaemonJob = "{sourceName}{username}审核了常驻任务{targetName}" + + event_CleanJobHistory = "{username}清除了{targetName}前的任务执行记录" + event_CleanUserEvent = "{username}清除了{targetName}前的用户动态" + event_CleanNodeLog = "{sourceName}{username}清除了{targetName}前的job动态" +) diff --git a/jiacrontab_admin/crontab.go b/jiacrontab_admin/crontab.go new file mode 100644 index 0000000..f491f1a --- /dev/null +++ b/jiacrontab_admin/crontab.go @@ -0,0 +1,266 @@ +package admin + +import ( + "errors" + "jiacrontab/models" + "jiacrontab/pkg/proto" + "strings" +) + +func GetJobList(ctx *myctx) { + + var ( + jobRet proto.QueryCrontabJobRet + err error + reqBody GetJobListReqParams + rpcReqParams proto.QueryJobArgs + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + rpcReqParams.Page = reqBody.Page + rpcReqParams.Pagesize = reqBody.Pagesize + rpcReqParams.UserID = ctx.claims.UserID + rpcReqParams.Root = ctx.claims.Root + rpcReqParams.GroupID = ctx.claims.GroupID + rpcReqParams.SearchTxt = reqBody.SearchTxt + + if err := rpcCall(reqBody.Addr, "CrontabJob.List", rpcReqParams, &jobRet); err != nil { + ctx.respRPCError(err) + return + } + + ctx.respSucc("", map[string]interface{}{ + "list": jobRet.List, + "page": jobRet.Page, + "pagesize": jobRet.Pagesize, + "total": jobRet.Total, + }) +} + +func GetRecentLog(ctx *myctx) { + var ( + err error + searchRet proto.SearchLogResult + reqBody GetLogReqParams + logList []string + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if err = rpcCall(reqBody.Addr, "CrontabJob.Log", proto.SearchLog{ + JobID: reqBody.JobID, + Offset: reqBody.Offset, + Pagesize: reqBody.Pagesize, + Date: reqBody.Date, + Root: ctx.claims.Root, + GroupID: ctx.claims.GroupID, + UserID: ctx.claims.UserID, + Pattern: reqBody.Pattern, + IsTail: reqBody.IsTail, + }, &searchRet); err != nil { + ctx.respRPCError(err) + return + } + + logList = strings.Split(string(searchRet.Content), "\n") + + ctx.respSucc("", map[string]interface{}{ + "logList": logList, + "curAddr": reqBody.Addr, + "offset": searchRet.Offset, + "filesize": searchRet.FileSize, + "pagesize": reqBody.Pagesize, + }) +} + +func EditJob(ctx *myctx) { + var ( + err error + reply models.CrontabJob + reqBody EditJobReqParams + job models.CrontabJob + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respBasicError(err) + return + } + + if !ctx.verifyNodePermission(reqBody.Addr) { + ctx.respNotAllowed() + return + } + + job = models.CrontabJob{ + Name: reqBody.Name, + Command: reqBody.Command, + GroupID: ctx.claims.GroupID, + Code: reqBody.Code, + TimeArgs: models.TimeArgs{ + Month: reqBody.Month, + Day: reqBody.Day, + Hour: reqBody.Hour, + Minute: reqBody.Minute, + Weekday: reqBody.Weekday, + Second: reqBody.Second, + }, + + UpdatedUserID: ctx.claims.UserID, + UpdatedUsername: ctx.claims.Username, + WorkDir: reqBody.WorkDir, + WorkUser: reqBody.WorkUser, + WorkIp: reqBody.WorkIp, + WorkEnv: reqBody.WorkEnv, + KillChildProcess: reqBody.KillChildProcess, + RetryNum: reqBody.RetryNum, + Timeout: reqBody.Timeout, + TimeoutTrigger: reqBody.TimeoutTrigger, + MailTo: reqBody.MailTo, + APITo: reqBody.APITo, + DingdingTo: reqBody.DingdingTo, + MaxConcurrent: reqBody.MaxConcurrent, + DependJobs: reqBody.DependJobs, + ErrorMailNotify: reqBody.ErrorMailNotify, + ErrorAPINotify: reqBody.ErrorAPINotify, + ErrorDingdingNotify: reqBody.ErrorDingdingNotify, + IsSync: reqBody.IsSync, + CreatedUserID: ctx.claims.UserID, + CreatedUsername: ctx.claims.Username, + } + + job.ID = reqBody.JobID + if ctx.claims.Root || ctx.claims.GroupID == models.SuperGroup.ID { + job.Status = models.StatusJobOk + } else { + job.Status = models.StatusJobUnaudited + } + + if err = rpcCall(reqBody.Addr, "CrontabJob.Edit", proto.EditCrontabJobArgs{ + Job: job, + GroupID: ctx.claims.GroupID, + Root: ctx.claims.Root, + UserID: ctx.claims.UserID, + }, &reply); err != nil { + ctx.respRPCError(err) + return + } + ctx.pubEvent(reply.Name, event_EditCronJob, models.EventSourceName(reqBody.Addr), reqBody) + ctx.respSucc("", reply) +} + +func ActionTask(ctx *myctx) { + var ( + err error + ok bool + method string + reqBody ActionTaskReqParams + jobReply []models.CrontabJob + methods = map[string]string{ + "start": "CrontabJob.Start", + "stop": "CrontabJob.Stop", + "delete": "CrontabJob.Delete", + "batch-exec": "CrontabJob.Execs", + "kill": "CrontabJob.Kill", + } + + eDesc = map[string]string{ + "start": event_StartCronJob, + "stop": event_StopCronJob, + "batch-exec": event_ExecCronJob, + "delete": event_DelCronJob, + "kill": event_KillCronJob, + } + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respBasicError(err) + return + } + + if method, ok = methods[reqBody.Action]; !ok { + ctx.respBasicError(errors.New("action无法识别")) + return + } + + if err = rpcCall(reqBody.Addr, method, proto.ActionJobsArgs{ + UserID: ctx.claims.UserID, + GroupID: ctx.claims.GroupID, + Root: ctx.claims.Root, + JobIDs: reqBody.JobIDs, + }, &jobReply); err != nil { + ctx.respRPCError(err) + return + } + if len(jobReply) > 0 { + var targetNames []string + for _, v := range jobReply { + targetNames = append(targetNames, v.Name) + } + ctx.pubEvent(strings.Join(targetNames, ","), eDesc[reqBody.Action], models.EventSourceName(reqBody.Addr), reqBody) + } + ctx.respSucc("", jobReply) +} + +func ExecTask(ctx *myctx) { + var ( + err error + logList []string + execJobReply proto.ExecCrontabJobReply + reqBody JobReqParams + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if err = rpcCall(reqBody.Addr, "CrontabJob.Exec", proto.GetJobArgs{ + UserID: ctx.claims.UserID, + Root: ctx.claims.Root, + JobID: reqBody.JobID, + GroupID: ctx.claims.GroupID, + }, &execJobReply); err != nil { + ctx.respRPCError(err) + return + } + logList = strings.Split(string(execJobReply.Content), "\n") + ctx.pubEvent(execJobReply.Job.Name, event_ExecCronJob, models.EventSourceName(reqBody.Addr), reqBody) + ctx.respSucc("", logList) +} + +func GetJob(ctx *myctx) { + var ( + reqBody GetJobReqParams + crontabJob models.CrontabJob + err error + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if !ctx.verifyNodePermission(reqBody.Addr) { + ctx.respNotAllowed() + return + } + + if err = rpcCall(reqBody.Addr, "CrontabJob.Get", proto.GetJobArgs{ + UserID: ctx.claims.UserID, + Root: ctx.claims.Root, + GroupID: ctx.claims.GroupID, + JobID: reqBody.JobID, + }, &crontabJob); err != nil { + ctx.respRPCError(err) + return + } + + ctx.respSucc("", crontabJob) +} diff --git a/jiacrontab_admin/ctx.go b/jiacrontab_admin/ctx.go new file mode 100644 index 0000000..d4afc5d --- /dev/null +++ b/jiacrontab_admin/ctx.go @@ -0,0 +1,253 @@ +package admin + +import ( + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "jiacrontab/models" + "jiacrontab/pkg/proto" + "net/http" + "strings" + "sync/atomic" + + "jiacrontab/pkg/version" + + "github.com/iwannay/log" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/kataras/iris/v12" +) + +type myctx struct { + iris.Context + adm *Admin + claims CustomerClaims +} + +func wrapCtx(ctx iris.Context, adm *Admin) *myctx { + key := "__ctx__" + if v := ctx.Values().Get(key); v != nil { + return v.(*myctx) + } + + c := &myctx{ + Context: ctx, + adm: adm, + } + if atomic.LoadInt32(&adm.initAdminUser) == 1 { + ctx.SetCookieKV("ready", "true", func(ctx iris.Context, c *http.Cookie, op uint8) { + if op == 1 { + c.HttpOnly = false + } + }) + } else { + ctx.SetCookieKV("ready", "false", func(ctx iris.Context, c *http.Cookie, op uint8) { + if op == 1 { + c.HttpOnly = false + } + }) + } + ctx.Values().Set(key, c) + return c +} + +func (ctx *myctx) respNotAllowed() { + ctx.respError(proto.Code_NotAllowed, proto.Msg_NotAllowed) +} + +func (ctx *myctx) respAuthFailed(err error) { + ctx.respError(proto.Code_FailedAuth, err) +} + +func (ctx *myctx) respDBError(err error) { + ctx.respError(proto.Code_DBError, err) +} + +func (ctx *myctx) respJWTError(err error) { + ctx.respError(proto.Code_JWTError, err) +} + +func (ctx *myctx) respBasicError(err error) { + ctx.respError(proto.Code_Error, err) +} + +func (ctx *myctx) respParamError(err error) { + ctx.respError(proto.Code_ParamsError, err) +} + +func (ctx *myctx) respRPCError(err error) { + ctx.respError(proto.Code_RPCError, err) +} + +func (ctx *myctx) respError(code int, err interface{}, v ...interface{}) { + + var ( + sign string + bts []byte + msgStr string + data interface{} + cfg = ctx.adm.getOpts() + ) + + if err == nil { + msgStr = "error" + } + msgStr = fmt.Sprintf("%s", err) + if len(v) >= 1 { + data = v[0] + } + + if strings.Contains(msgStr, "UNIQUE constraint failed") { + msgStr = strings.Replace(msgStr, "UNIQUE constraint failed", "数据已存在,请检查索引", -1) + } + + bts, err = json.Marshal(data) + if err != nil { + log.Error("errorResp:", err) + } + + sign = fmt.Sprintf("%x", md5.Sum(append(bts, []byte(cfg.App.SigningKey)...))) + + ctx.JSON(proto.Resp{ + Code: code, + Msg: msgStr, + Data: json.RawMessage(bts), + Sign: sign, + Version: version.String(cfg.App.AppName), + }) +} + +func (ctx *myctx) respSucc(msg string, v interface{}) { + + cfg := ctx.adm.getOpts() + if msg == "" { + msg = "success" + } + + bts, err := json.Marshal(v) + if err != nil { + log.Error("errorResp:", err) + } + + sign := fmt.Sprintf("%x", md5.Sum(append(bts, []byte(cfg.App.SigningKey)...))) + + ctx.JSON(proto.Resp{ + Code: proto.SuccessRespCode, + Msg: msg, + Data: json.RawMessage(bts), + Sign: sign, + Version: version.String(cfg.App.AppName), + }) +} + +func (ctx *myctx) isSuper() bool { + return ctx.claims.GroupID == models.SuperGroup.ID +} +func (ctx *myctx) isRoot() bool { + return ctx.claims.Root +} + +func (ctx *myctx) parseClaimsFromToken() error { + var ok bool + + if (ctx.claims != CustomerClaims{}) { + return nil + } + + token, ok := ctx.Values().Get("jwt").(*jwt.Token) + if !ok { + return errors.New("claims is nil") + } + bts, err := json.Marshal(token.Claims) + if err != nil { + return err + } + err = json.Unmarshal(bts, &ctx.claims) + if err != nil { + return fmt.Errorf("unmarshal claims error - (%s)", err) + } + var user models.User + if err := models.DB().Take(&user, "id=?", ctx.claims.UserID).Error; err != nil { + return fmt.Errorf("validate user from db error - (%s)", err) + } + if ctx.claims.Mail != user.Mail || ctx.claims.GroupID != user.GroupID || ctx.claims.Root != user.Root || ctx.claims.Version != user.Version { + return fmt.Errorf("token validate error") + } + + if ctx.claims.GroupID == models.SuperGroup.ID { + ctx.claims.Root = true + } + + return nil +} + +func (ctx *myctx) getGroupNodes() ([]models.Node, error) { + var nodes []models.Node + err := models.DB().Find(&nodes, "group_id=?", ctx.claims.GroupID).Error + return nodes, err +} + +func (ctx *myctx) verifyNodePermission(addr string) bool { + var node models.Node + return node.VerifyUserGroup(ctx.claims.UserID, ctx.claims.GroupID, addr) +} + +func (ctx *myctx) getGroupAddr() ([]string, error) { + var addrs []string + nodes, err := ctx.getGroupNodes() + if err != nil { + return nil, err + } + + for _, v := range nodes { + addrs = append(addrs, v.Addr) + } + return addrs, nil + +} + +func (ctx *myctx) Valid(i Parameter) error { + if err := ctx.ReadJSON(i); err != nil { + return err + } + + if err := i.Verify(ctx); err != nil { + return err + } + + if err := validStructRule(i); err != nil { + return err + } + return nil +} + +func (ctx *myctx) pubEvent(targetName, desc string, source interface{}, v interface{}) { + var content string + + if v != nil { + bts, err := json.Marshal(v) + if err != nil { + return + } + content = string(bts) + } + + e := models.Event{ + GroupID: ctx.claims.GroupID, + UserID: ctx.claims.UserID, + Username: ctx.claims.Username, + EventDesc: desc, + TargetName: targetName, + Content: content, + } + + switch v := source.(type) { + case models.EventSourceName: + e.SourceName = string(v) + case models.EventSourceUsername: + e.SourceUsername = string(v) + } + + e.Pub() +} diff --git a/jiacrontab_admin/daemon.go b/jiacrontab_admin/daemon.go new file mode 100644 index 0000000..26472a2 --- /dev/null +++ b/jiacrontab_admin/daemon.go @@ -0,0 +1,223 @@ +package admin + +import ( + "errors" + "jiacrontab/models" + "jiacrontab/pkg/proto" + "jiacrontab/pkg/rpc" + "strings" +) + +func GetDaemonJobList(ctx *myctx) { + var ( + reqBody GetJobListReqParams + jobRet proto.QueryDaemonJobRet + err error + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if err = rpcCall(reqBody.Addr, "DaemonJob.List", &proto.QueryJobArgs{ + Page: reqBody.Page, + Pagesize: reqBody.Pagesize, + SearchTxt: reqBody.SearchTxt, + Root: ctx.claims.Root, + GroupID: ctx.claims.GroupID, + UserID: ctx.claims.UserID, + }, &jobRet); err != nil { + ctx.respRPCError(err) + return + } + + ctx.respSucc("", map[string]interface{}{ + "list": jobRet.List, + "page": jobRet.Page, + "pagesize": jobRet.Pagesize, + "total": jobRet.Total, + }) +} + +func ActionDaemonTask(ctx *myctx) { + var ( + err error + reply []models.DaemonJob + ok bool + reqBody ActionTaskReqParams + + methods = map[string]string{ + "start": "DaemonJob.Start", + "delete": "DaemonJob.Delete", + "stop": "DaemonJob.Stop", + } + + eDesc = map[string]string{ + "start": event_StartDaemonJob, + "delete": event_DelDaemonJob, + "stop": event_StopDaemonJob, + } + method string + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respBasicError(err) + } + + if method, ok = methods[reqBody.Action]; !ok { + ctx.respBasicError(errors.New("参数错误")) + return + } + + if err = rpcCall(reqBody.Addr, method, proto.ActionJobsArgs{ + UserID: ctx.claims.UserID, + JobIDs: reqBody.JobIDs, + GroupID: ctx.claims.GroupID, + Root: ctx.claims.Root, + }, &reply); err != nil { + ctx.respRPCError(err) + return + } + + if len(reply) > 0 { + var targetNames []string + for _, v := range reply { + targetNames = append(targetNames, v.Name) + } + ctx.pubEvent(strings.Join(targetNames, ","), eDesc[reqBody.Action], models.EventSourceName(reqBody.Addr), reqBody) + } + + ctx.respSucc("", nil) +} + +// EditDaemonJob 修改常驻任务,jobID为0时新增 +func EditDaemonJob(ctx *myctx) { + var ( + err error + reply models.DaemonJob + reqBody EditDaemonJobReqParams + daemonJob models.DaemonJob + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + } + + if !ctx.verifyNodePermission(reqBody.Addr) { + ctx.respNotAllowed() + return + } + + daemonJob = models.DaemonJob{ + Name: reqBody.Name, + GroupID: ctx.claims.GroupID, + ErrorMailNotify: reqBody.ErrorMailNotify, + ErrorAPINotify: reqBody.ErrorAPINotify, + ErrorDingdingNotify: reqBody.ErrorDingdingNotify, + MailTo: reqBody.MailTo, + APITo: reqBody.APITo, + DingdingTo: reqBody.DingdingTo, + UpdatedUserID: ctx.claims.UserID, + UpdatedUsername: ctx.claims.Username, + Command: reqBody.Command, + WorkDir: reqBody.WorkDir, + WorkEnv: reqBody.WorkEnv, + WorkUser: reqBody.WorkUser, + WorkIp: reqBody.WorkIp, + Code: reqBody.Code, + RetryNum: reqBody.RetryNum, + FailRestart: reqBody.FailRestart, + Status: models.StatusJobUnaudited, + CreatedUserID: ctx.claims.UserID, + CreatedUsername: ctx.claims.Username, + } + daemonJob.ID = reqBody.JobID + if ctx.claims.Root || ctx.claims.GroupID == models.SuperGroup.ID { + daemonJob.Status = models.StatusJobOk + } else { + daemonJob.Status = models.StatusJobUnaudited + } + + if err = rpcCall(reqBody.Addr, "DaemonJob.Edit", proto.EditDaemonJobArgs{ + GroupID: ctx.claims.GroupID, + UserID: ctx.claims.UserID, + Job: daemonJob, + Root: ctx.claims.Root, + }, &reply); err != nil { + ctx.respRPCError(err) + return + } + + ctx.pubEvent(reply.Name, event_EditDaemonJob, models.EventSourceName(reqBody.Addr), reqBody) + ctx.respSucc("", reply) +} + +func GetDaemonJob(ctx *myctx) { + var ( + reqBody GetJobReqParams + daemonJob models.DaemonJob + err error + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if !ctx.verifyNodePermission(reqBody.Addr) { + ctx.respNotAllowed() + return + } + + if err = rpcCall(reqBody.Addr, "DaemonJob.Get", proto.GetJobArgs{ + UserID: ctx.claims.UserID, + GroupID: ctx.claims.GroupID, + Root: ctx.claims.Root, + JobID: reqBody.JobID, + }, &daemonJob); err != nil { + ctx.respRPCError(err) + return + } + + ctx.respSucc("", daemonJob) +} + +func GetRecentDaemonLog(ctx *myctx) { + var ( + err error + searchRet proto.SearchLogResult + reqBody GetLogReqParams + logList []string + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if err := rpc.Call(reqBody.Addr, "DaemonJob.Log", proto.SearchLog{ + JobID: reqBody.JobID, + GroupID: ctx.claims.GroupID, + Root: ctx.claims.Root, + UserID: ctx.claims.UserID, + Offset: reqBody.Offset, + Pagesize: reqBody.Pagesize, + Date: reqBody.Date, + Pattern: reqBody.Pattern, + IsTail: reqBody.IsTail, + }, &searchRet); err != nil { + ctx.respRPCError(err) + return + } + + logList = strings.Split(string(searchRet.Content), "\n") + + ctx.respSucc("", map[string]interface{}{ + "logList": logList, + "curAddr": reqBody.Addr, + "offset": searchRet.Offset, + "filesize": searchRet.FileSize, + "pagesize": reqBody.Pagesize, + }) +} diff --git a/jiacrontab_admin/debug.go b/jiacrontab_admin/debug.go new file mode 100644 index 0000000..0704152 --- /dev/null +++ b/jiacrontab_admin/debug.go @@ -0,0 +1,21 @@ +package admin + +import ( + "jiacrontab/pkg/base" + "net/http/pprof" +) + +func stat(ctx *myctx) { + data := base.Stat.Collect() + ctx.JSON(data) +} + +func pprofHandler(ctx *myctx) { + if h := pprof.Handler(ctx.Params().Get("key")); h != nil { + h.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) + } +} + +func indexDebug(ctx *myctx) { + pprof.Index(ctx.ResponseWriter(), ctx.Request()) +} diff --git a/jiacrontab_admin/group.go b/jiacrontab_admin/group.go new file mode 100644 index 0000000..c0356a1 --- /dev/null +++ b/jiacrontab_admin/group.go @@ -0,0 +1,66 @@ +package admin + +import ( + "jiacrontab/models" + "jiacrontab/pkg/proto" +) + +// GetGroupList 获得所有的分组列表 +func GetGroupList(ctx *myctx) { + var ( + err error + groupList []models.Group + count int64 + model = models.DB().Model(&models.Group{}) + reqBody GetGroupListReqParams + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if !ctx.isSuper() { + ctx.respNotAllowed() + return + } + + model.Where("name like ?", "%"+reqBody.SearchTxt+"%").Count(&count) + + err = model.Where("name like ?", "%"+reqBody.SearchTxt+"%").Offset((reqBody.Page - 1) * reqBody.Pagesize).Order("updated_at desc").Limit(reqBody.Pagesize).Find(&groupList).Error + if err != nil { + ctx.respError(proto.Code_Error, err.Error(), nil) + return + } + + ctx.respSucc("", map[string]interface{}{ + "list": groupList, + "total": count, + "page": reqBody.Page, + "pagesize": reqBody.Pagesize, + }) +} + +// EditGroup 编辑分组 +func EditGroup(ctx *myctx) { + var ( + reqBody EditGroupReqParams + err error + group models.Group + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + group.ID = reqBody.GroupID + group.Name = reqBody.GroupName + + if err = group.Save(); err != nil { + ctx.respBasicError(err) + return + } + ctx.pubEvent(group.Name, event_EditGroup, "", reqBody) + ctx.respSucc("", nil) +} diff --git a/jiacrontab_admin/ldap.go b/jiacrontab_admin/ldap.go new file mode 100644 index 0000000..7f527d0 --- /dev/null +++ b/jiacrontab_admin/ldap.go @@ -0,0 +1,124 @@ +package admin + +import ( + "fmt" + "jiacrontab/models" + "time" + + "errors" + + ld "github.com/go-ldap/ldap/v3" +) + +type Ldap struct { + BindUserDn string + BindPwd string + BaseOn string + UserField string + Addr string + Timeout time.Duration + fields []map[string]string + queryFields []string + lastSynced time.Time + DisabledAnonymousQuery bool +} + +func (l *Ldap) connect() (*ld.Conn, error) { + var err error + conn, err := ld.DialURL(l.Addr) + if err != nil { + return nil, err + } + conn.SetTimeout(l.Timeout) + return conn, nil +} + +func (l *Ldap) Login(username string, password string) (*models.User, error) { + err := l.loadLdapFields() + if err != nil { + return nil, err + } + + conn, err := l.connect() + if err != nil { + return nil, err + } + defer conn.Close() + + if l.DisabledAnonymousQuery { + err := conn.Bind(l.BindUserDn, l.BindPwd) + if err != nil { + return nil, err + } + } + query := ld.NewSearchRequest( + l.BaseOn, + ld.ScopeWholeSubtree, + ld.DerefAlways, + 0, 0, false, + fmt.Sprintf("(%v=%v)", l.UserField, username), + l.queryFields, nil, + ) + ret, err := conn.Search(query) + + if err != nil { + return nil, fmt.Errorf("在Ldap搜索用户失败 - %v", err) + } + if len(ret.Entries) == 0 { + return nil, fmt.Errorf("在Ldap中未查询到对应的用户信息 - %v", err) + } + + err = conn.Bind(ret.Entries[0].DN, password) + if err != nil { + return nil, errors.New("帐号或密码不正确") + } + return l.convert(ret.Entries[0]) +} + +func (l *Ldap) loadLdapFields() error { + var setting models.SysSetting + var list []map[string]string + + if time.Since(l.lastSynced).Hours() < 1 { + return nil + } + + err := models.DB().Model(&models.SysSetting{}).Where("class=?", 1).Find(&setting).Error + if err != nil { + return err + } + // err = json.Unmarshal(setting.Content, &list) + // if err != nil { + // return err + // } + for _, v := range list { + if v["ldap_field_name"] != "" { + l.fields = append(l.fields, v) + } + } + + l.queryFields = []string{"dn"} + for _, v := range l.fields { + l.queryFields = append(l.queryFields, v["ldap_field_name"]) + } + + return nil +} + +func (l *Ldap) convert(ldapUserInfo *ld.Entry) (*models.User, error) { + var userinfo models.User + for _, v := range l.fields { + switch v["local_field_name"] { + case "username": + userinfo.Username = ldapUserInfo.GetAttributeValue(v["ldap_field_name"]) + case "gender": + userinfo.Gender = ldapUserInfo.GetAttributeValue(v["ldap_field_name"]) + case "avatar": + userinfo.Avatar = ldapUserInfo.GetAttributeValue(v["ldap_field_name"]) + case "email": + userinfo.Mail = ldapUserInfo.GetAttributeValue(v["ldap_field_name"]) + } + } + err := models.DB().Where("username=?", userinfo.Username).FirstOrCreate(&userinfo).Error + return &userinfo, err +} diff --git a/jiacrontab_admin/node.go b/jiacrontab_admin/node.go new file mode 100644 index 0000000..111fbf0 --- /dev/null +++ b/jiacrontab_admin/node.go @@ -0,0 +1,168 @@ +package admin + +import ( + "fmt" + "jiacrontab/models" + "jiacrontab/pkg/proto" + "time" +) + +// GetNodeList 获得任务节点列表 +// 支持获得所属分组节点,指定分组节点(超级管理员) +func GetNodeList(ctx *myctx) { + var ( + err error + nodeList []models.Node + reqBody GetNodeListReqParams + count int64 + ) + + if err = ctx.Valid(&reqBody); err != nil { + ctx.respParamError(err) + return + } + + if reqBody.QueryGroupID != ctx.claims.GroupID && !ctx.isSuper() { + ctx.respNotAllowed() + return + } + + cfg := ctx.adm.getOpts() + maxClientAliveInterval := -1 * cfg.App.MaxClientAliveInterval + + currentTime := time.Now().Add(time.Second * time.Duration(maxClientAliveInterval)).Format("2006-01-02 15:04:05") + + //失联列表更新为已断开状态 + models.DB().Unscoped().Model(&models.Node{}).Where("updated_at%s):%v", addr, serviceMethod, err) + } + if err == rpc.ErrRpc || err == rpc.ErrShutdown { + models.DB().Unscoped().Model(&models.Node{}).Where("addr=?", addr).Update("disabled", true) + } + return err +} + +func validStructRule(i interface{}) error { + rt := reflect.TypeOf(i) + rv := reflect.ValueOf(i) + + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + + if rv.Kind() == reflect.Ptr { + rv = rv.Elem() + } + + for i := 0; i < rt.NumField(); i++ { + sf := rt.Field(i) + r := sf.Tag.Get("rule") + br := sf.Tag.Get("bind") + + if br == "required" && rv.Field(i).Kind() == reflect.Ptr { + if rv.Field(i).IsNil() { + return errors.New(sf.Name + " is required") + } + } + + if r == "" { + continue + } + if rs := strings.Split(r, ","); len(rs) == 2 { + rvf := rv.Field(i) + if rs[0] == "required" { + switch rvf.Kind() { + case reflect.String: + if rvf.Interface() == "" { + return errors.New(rs[1]) + } + case reflect.Array, reflect.Map: + if rvf.Len() == 0 { + return errors.New(rs[1]) + } + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if rvf.Interface() == 0 { + return errors.New(rs[1]) + } + default: + } + + } + continue + } + } + return nil +} diff --git a/jiacrontabd/cmd.go b/jiacrontabd/cmd.go new file mode 100644 index 0000000..08da805 --- /dev/null +++ b/jiacrontabd/cmd.go @@ -0,0 +1,331 @@ +package jiacrontabd + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "jiacrontab/pkg/kproc" + "jiacrontab/pkg/proto" + "jiacrontab/pkg/util" + "os" + "path/filepath" + "runtime/debug" + "time" + + "github.com/iwannay/log" +) + +type cmdUint struct { + ctx context.Context + id uint + args [][]string + logDir string + content []byte + logFile *os.File + label string + user string + logPath string + verboseLog bool + exportLog bool + ignoreFileLog bool + env []string + ip []string + killChildProcess bool + dir string + startTime time.Time + costTime time.Duration + jd *Jiacrontabd +} + +func (cu *cmdUint) release() { + if cu.logFile != nil { + cu.logFile.Close() + } + cu.costTime = time.Now().Sub(cu.startTime) +} + +func (cu *cmdUint) launch() error { + //todo: 需要添加 ip 校验 + defer func() { + if err := recover(); err != nil { + log.Errorf("wrapExecScript error:%v\n%s", err, debug.Stack()) + } + cu.release() + }() + cfg := cu.jd.getOpts() + cu.startTime = time.Now() + + var err error + + if err = cu.setLogFile(); err != nil { + return err + } + + if len(cu.args) > 1 { + err = cu.pipeExec() + } else { + err = cu.exec() + } + + if err != nil { + var errMsg string + var prefix string + if cu.verboseLog { + prefix = fmt.Sprintf("[%s %s %s] ", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, cu.label) + errMsg = prefix + err.Error() + "\n" + } else { + prefix = fmt.Sprintf("[%s %s] ", cfg.BoardcastAddr, cu.label) + errMsg = prefix + err.Error() + "\n" + } + + cu.writeLog([]byte(errMsg)) + if cu.exportLog { + cu.content = append(cu.content, []byte(errMsg)...) + } + + return err + } + + return nil +} + +func (cu *cmdUint) setLogFile() error { + var err error + + if cu.ignoreFileLog { + return nil + } + if cu.logPath == "" { + cu.logPath = filepath.Join(cu.logDir, time.Now().Format("2006/01/02"), fmt.Sprintf("%d.log", cu.id)) + } + + cu.logFile, err = util.TryOpen(cu.logPath, os.O_APPEND|os.O_CREATE|os.O_RDWR) + if err != nil { + return err + } + return nil +} + +func (cu *cmdUint) writeLog(b []byte) { + if cu.ignoreFileLog { + return + } + var err error + logPath := filepath.Join(cu.logDir, time.Now().Format("2006/01/02"), fmt.Sprintf("%d.log", cu.id)) + if logPath != cu.logPath { + cu.logFile.Close() + cu.logFile, err = util.TryOpen(logPath, os.O_APPEND|os.O_CREATE|os.O_RDWR) + if err != nil { + log.Errorf("writeLog failed - %v", err) + return + } + cu.logPath = logPath + } + + cu.logFile.Write(b) +} + +func (cu *cmdUint) exec() error { + log.Debug("cmd exec args:", cu.args) + if len(cu.args) == 0 { + return errors.New("invalid args") + } + cu.args[0] = util.FilterEmptyEle(cu.args[0]) + cmdName := cu.args[0][0] + args := cu.args[0][1:] + cmd := kproc.CommandContext(cu.ctx, cmdName, args...) + cfg := cu.jd.getOpts() + + cmd.SetDir(cu.dir) + cmd.SetEnv(cu.env) + cmd.SetUser(cu.user) + cmd.SetExitKillChildProcess(cu.killChildProcess) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + + defer stdout.Close() + + stderr, err := cmd.StderrPipe() + + if err != nil { + return err + } + + defer stderr.Close() + + if err := cmd.Start(); err != nil { + return err + } + + reader := bufio.NewReader(stdout) + readerErr := bufio.NewReader(stderr) + // 如果已经存在日志则直接写入 + cu.writeLog(cu.content) + go func() { + var ( + line []byte + ) + + for { + + line, _ = reader.ReadBytes('\n') + if len(line) == 0 { + break + } + + if cfg.VerboseJobLog { + prefix := fmt.Sprintf("[%s %s %s] ", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, cu.label) + line = append([]byte(prefix), line...) + } + + if cu.exportLog { + cu.content = append(cu.content, line...) + } + cu.writeLog(line) + } + + for { + line, _ = readerErr.ReadBytes('\n') + if len(line) == 0 { + break + } + // 默认给err信息加上日期标志 + if cfg.VerboseJobLog { + prefix := fmt.Sprintf("[%s %s %s] ", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, cu.label) + line = append([]byte(prefix), line...) + } + if cu.exportLog { + cu.content = append(cu.content, line...) + } + cu.writeLog(line) + } + }() + + if err = cmd.Wait(); err != nil { + return err + } + + return nil +} + +func (cu *cmdUint) pipeExec() error { + var ( + outBufer bytes.Buffer + errBufer bytes.Buffer + cmdEntryList []*pipeCmd + err, exitError error + line []byte + cfg = cu.jd.getOpts() + ) + + for _, v := range cu.args { + v = util.FilterEmptyEle(v) + cmdName := v[0] + args := v[1:] + + cmd := kproc.CommandContext(cu.ctx, cmdName, args...) + + cmd.SetDir(cu.dir) + cmd.SetEnv(cu.env) + cmd.SetUser(cu.user) + cmd.SetExitKillChildProcess(cu.killChildProcess) + + cmdEntryList = append(cmdEntryList, &pipeCmd{cmd}) + } + + exitError = execute(&outBufer, &errBufer, + cmdEntryList..., + ) + + // 如果已经存在日志则直接写入 + cu.writeLog(cu.content) + + for { + + line, err = outBufer.ReadBytes('\n') + if err != nil || err == io.EOF { + break + } + if cfg.VerboseJobLog { + prefix := fmt.Sprintf("[%s %s %s] ", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, cu.label) + line = append([]byte(prefix), line...) + } + + cu.content = append(cu.content, line...) + cu.writeLog(line) + } + + for { + line, err = errBufer.ReadBytes('\n') + if err != nil || err == io.EOF { + break + } + + if cfg.VerboseJobLog { + prefix := fmt.Sprintf("[%s %s %s] ", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, cu.label) + line = append([]byte(prefix), line...) + } + + if cu.exportLog { + cu.content = append(cu.content, line...) + } + cu.writeLog(line) + } + return exitError +} + +func call(stack []*pipeCmd, pipes []*io.PipeWriter) (err error) { + if stack[0].Process == nil { + if err = stack[0].Start(); err != nil { + return err + } + } + + if len(stack) > 1 { + if err = stack[1].Start(); err != nil { + return err + } + + defer func() { + pipes[0].Close() + if err == nil { + err = call(stack[1:], pipes[1:]) + } + if err != nil { + // fixed zombie process + stack[1].Wait() + } + }() + } + return stack[0].Wait() +} + +type pipeCmd struct { + *kproc.KCmd +} + +func execute(outputBuffer *bytes.Buffer, errorBuffer *bytes.Buffer, stack ...*pipeCmd) (err error) { + pipeStack := make([]*io.PipeWriter, len(stack)-1) + i := 0 + for ; i < len(stack)-1; i++ { + stdinPipe, stdoutPipe := io.Pipe() + stack[i].Stdout = stdoutPipe + stack[i].Stderr = errorBuffer + stack[i+1].Stdin = stdinPipe + pipeStack[i] = stdoutPipe + } + + stack[i].Stdout = outputBuffer + stack[i].Stderr = errorBuffer + + if err = call(stack, pipeStack); err != nil { + errorBuffer.WriteString(err.Error()) + } + return err +} diff --git a/jiacrontabd/config.go b/jiacrontabd/config.go new file mode 100644 index 0000000..57953f7 --- /dev/null +++ b/jiacrontabd/config.go @@ -0,0 +1,110 @@ +package jiacrontabd + +import ( + "jiacrontab/pkg/file" + "jiacrontab/pkg/util" + "net" + "reflect" + + "github.com/iwannay/log" + + ini "gopkg.in/ini.v1" +) + +const ( + appname = "jiacrontabd" +) + +type Config struct { + LogLevel string `opt:"log_level"` + VerboseJobLog bool `opt:"verbose_job_log"` + ListenAddr string `opt:"listen_addr"` + AdminAddr string `opt:"admin_addr"` + LogPath string `opt:"log_path"` + AutoCleanTaskLog bool `opt:"auto_clean_task_log"` + NodeName string `opt:"node_name"` + BoardcastAddr string `opt:"boardcast_addr"` + ClientAliveInterval int `opt:"client_alive_interval"` + CfgPath string + Debug bool `opt:"debug"` + iniFile *ini.File + DriverName string `opt:"driver_name"` + DSN string `opt:"dsn"` +} + +func (c *Config) Resolve() error { + c.iniFile = c.loadConfig(c.CfgPath) + + val := reflect.ValueOf(c).Elem() + typ := val.Type() + + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + opt := field.Tag.Get("opt") + if opt == "" { + continue + } + sec := c.iniFile.Section("jiacrontabd") + + if !sec.HasKey(opt) { + continue + } + + key := sec.Key(opt) + switch field.Type.Kind() { + case reflect.Bool: + v, err := key.Bool() + if err != nil { + log.Errorf("cannot resolve ini field %s err(%v)", opt, err) + } + val.Field(i).SetBool(v) + case reflect.String: + val.Field(i).SetString(key.String()) + case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Int8: + if v, err := key.Int64(); err != nil { + log.Errorf("cannot convert to int64 type (%s)", err) + } else { + val.Field(i).SetInt(v) + } + } + } + + if c.BoardcastAddr == "" { + _, port, _ := net.SplitHostPort(c.ListenAddr) + c.BoardcastAddr = util.InternalIP() + ":" + port + } + + return nil +} + +func NewConfig() *Config { + return &Config{ + LogLevel: "warn", + VerboseJobLog: true, + ListenAddr: "127.0.0.1:20001", + AdminAddr: "127.0.0.1:20003", + LogPath: "./logs", + AutoCleanTaskLog: true, + NodeName: util.GetHostname(), + CfgPath: "./jiacrontabd.ini", + DriverName: "sqlite3", + DSN: "data/jiacrontabd.db", + ClientAliveInterval: 30, + } +} + +func (c *Config) loadConfig(path string) *ini.File { + if !file.Exist(path) { + f, err := file.CreateFile(path) + if err != nil { + panic(err) + } + f.Close() + } + + iniFile, err := ini.Load(path) + if err != nil { + panic(err) + } + return iniFile +} diff --git a/jiacrontabd/const.go b/jiacrontabd/const.go new file mode 100644 index 0000000..8806d0c --- /dev/null +++ b/jiacrontabd/const.go @@ -0,0 +1 @@ +package jiacrontabd diff --git a/jiacrontabd/daemon.go b/jiacrontabd/daemon.go new file mode 100644 index 0000000..39f0683 --- /dev/null +++ b/jiacrontabd/daemon.go @@ -0,0 +1,275 @@ +package jiacrontabd + +import ( + "context" + "encoding/json" + "fmt" + "jiacrontab/models" + "jiacrontab/pkg/proto" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/iwannay/log" +) + +type ApiNotifyArgs struct { + JobName string + JobID uint + NodeAddr string + CreateUsername string + CreatedAt time.Time + NotifyType string +} + +type daemonJob struct { + job *models.DaemonJob + daemon *Daemon + ctx context.Context + cancel context.CancelFunc + processNum int +} + +func (d *daemonJob) do(ctx context.Context) { + + d.processNum = 1 + t := time.NewTicker(1 * time.Second) + defer t.Stop() + d.daemon.wait.Add(1) + cfg := d.daemon.jd.getOpts() + retryNum := d.job.RetryNum + + defer func() { + if err := recover(); err != nil { + log.Errorf("%s exec panic %s \n", d.job.Name, err) + } + d.processNum = 0 + if err := models.DB().Model(d.job).Update("status", models.StatusJobStop).Error; err != nil { + log.Error(err) + } + + d.daemon.wait.Done() + + }() + + if err := models.DB().Model(d.job).Updates(map[string]interface{}{ + "start_at": time.Now(), + "status": models.StatusJobRunning}).Error; err != nil { + log.Error(err) + } + + for { + + var ( + stop bool + err error + ) + arg := d.job.Command + if d.job.Code != "" { + arg = append(arg, d.job.Code) + } + myCmdUint := cmdUint{ + ctx: ctx, + args: [][]string{arg}, + env: d.job.WorkEnv, + ip: d.job.WorkIp, + dir: d.job.WorkDir, + user: d.job.WorkUser, + label: d.job.Name, + jd: d.daemon.jd, + id: d.job.ID, + logDir: filepath.Join(cfg.LogPath, "daemon_job"), + } + + log.Info("exec daemon job, jobName:", d.job.Name, " jobID", d.job.ID) + + err = myCmdUint.launch() + retryNum-- + d.handleNotify(err) + + select { + case <-ctx.Done(): + stop = true + case <-t.C: + } + + if stop || d.job.FailRestart == false || (d.job.RetryNum > 0 && retryNum == 0) { + break + } + + if err = d.syncJob(); err != nil { + break + } + + } + t.Stop() + + d.daemon.PopJob(d.job.ID) + + log.Info("daemon task end", d.job.Name) +} + +func (d *daemonJob) syncJob() error { + return models.DB().Take(d.job, "id=? and status=?", d.job.ID, models.StatusJobRunning).Error +} + +func (d *daemonJob) handleNotify(err error) { + if err == nil { + return + } + + var reply bool + cfg := d.daemon.jd.getOpts() + if d.job.ErrorMailNotify && len(d.job.MailTo) > 0 { + var reply bool + err := d.daemon.jd.rpcCallCtx(d.ctx, "Srv.SendMail", proto.SendMail{ + MailTo: d.job.MailTo, + Subject: cfg.BoardcastAddr + "提醒常驻脚本异常退出", + Content: fmt.Sprintf( + "任务名:%s
创建者:%s
开始时间:%s
异常:%s", + d.job.Name, d.job.CreatedUsername, time.Now().Format(proto.DefaultTimeLayout), err), + }, &reply) + if err != nil { + log.Error("Srv.SendMail error:", err, "server addr:", cfg.AdminAddr) + } + } + + if d.job.ErrorAPINotify && len(d.job.APITo) > 0 { + postData, err := json.Marshal(ApiNotifyArgs{ + JobName: d.job.Name, + JobID: d.job.ID, + CreateUsername: d.job.CreatedUsername, + CreatedAt: d.job.CreatedAt, + NodeAddr: cfg.BoardcastAddr, + NotifyType: "error", + }) + if err != nil { + log.Error("json.Marshal error:", err) + } + err = d.daemon.jd.rpcCallCtx(d.ctx, "Srv.ApiPost", proto.ApiPost{ + Urls: d.job.APITo, + Data: string(postData), + }, &reply) + + if err != nil { + log.Error("Logic.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + } + + // 钉钉webhook通知 + if d.job.ErrorDingdingNotify && len(d.job.DingdingTo) > 0 { + nodeAddr := cfg.BoardcastAddr + title := nodeAddr + "告警:常驻脚本异常退出" + notifyContent := fmt.Sprintf("> ###### 来自jiacrontabd: %s 的常驻脚本异常退出报警:\n> ##### 任务id:%d\n> ##### 任务名称:%s\n> ##### 异常:%s\n> ##### 报警时间:%s", nodeAddr, int(d.job.ID), d.job.Name, err, time.Now().Format("2006-01-02 15:04:05")) + notifyBody := fmt.Sprintf( + `{ + "msgtype": "markdown", + "markdown": { + "title": "%s", + "text": "%s" + } + }`, title, notifyContent) + err = d.daemon.jd.rpcCallCtx(d.ctx, "Srv.ApiPost", proto.ApiPost{ + Urls: d.job.DingdingTo, + Data: notifyBody, + }, &reply) + + if err != nil { + log.Error("Logic.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + } +} + +type Daemon struct { + taskChannel chan *daemonJob + taskMap map[uint]*daemonJob + jd *Jiacrontabd + lock sync.Mutex + wait sync.WaitGroup +} + +func newDaemon(taskChannelLength int, jd *Jiacrontabd) *Daemon { + return &Daemon{ + taskMap: make(map[uint]*daemonJob), + taskChannel: make(chan *daemonJob, taskChannelLength), + jd: jd, + } +} + +func (d *Daemon) add(t *daemonJob) { + if t != nil { + if len(t.job.WorkIp) > 0 && !checkIpInWhiteList(strings.Join(t.job.WorkIp, ",")) { + if err := models.DB().Model(t.job).Updates(map[string]interface{}{ + "status": models.StatusJobStop, + //"next_exec_time": time.Time{}, + //"last_exit_status": "IP受限制", + }).Error; err != nil { + log.Error(err) + } + return + } + + log.Debugf("daemon.add(%s)\n", t.job.Name) + t.daemon = d + d.taskChannel <- t + } +} + +// PopJob 删除调度列表中的任务 +func (d *Daemon) PopJob(jobID uint) { + d.lock.Lock() + t := d.taskMap[jobID] + if t != nil { + delete(d.taskMap, jobID) + d.lock.Unlock() + t.cancel() + } else { + d.lock.Unlock() + } +} + +func (d *Daemon) run() { + var jobList []models.DaemonJob + err := models.DB().Where("status=?", models.StatusJobRunning).Find(&jobList).Error + if err != nil { + log.Error("init daemon task error:", err) + } + + for _, v := range jobList { + job := v + d.add(&daemonJob{ + job: &job, + }) + } + + d.process() +} + +func (d *Daemon) process() { + go func() { + for v := range d.taskChannel { + d.lock.Lock() + if t := d.taskMap[v.job.ID]; t == nil { + d.taskMap[v.job.ID] = v + d.lock.Unlock() + v.ctx, v.cancel = context.WithCancel(context.Background()) + go v.do(v.ctx) + } else { + d.lock.Unlock() + } + } + }() +} + +func (d *Daemon) count() int { + var count int + d.lock.Lock() + count = len(d.taskMap) + d.lock.Unlock() + return count +} + +func (d *Daemon) waitDone() { + d.wait.Wait() +} diff --git a/jiacrontabd/dependencies.go b/jiacrontabd/dependencies.go new file mode 100644 index 0000000..a0c06d4 --- /dev/null +++ b/jiacrontabd/dependencies.go @@ -0,0 +1,122 @@ +package jiacrontabd + +import ( + "bytes" + "context" + "jiacrontab/pkg/proto" + "time" + + "github.com/iwannay/log" +) + +type depEntry struct { + jobID uint // 定时任务id + jobUniqueID string // job的唯一标志 + processID int // 当前依赖的父级任务(可能存在多个并发的task) + id string // depID uuid + once bool + workDir string + user string + env []string + from string + commands []string + dest string + done bool + timeout int64 + err error + ctx context.Context + name string + logPath string + logContent []byte +} + +func newDependencies(jd *Jiacrontabd) *dependencies { + return &dependencies{ + jd: jd, + dep: make(chan *depEntry, 100), + } +} + +type dependencies struct { + jd *Jiacrontabd + dep chan *depEntry +} + +func (d *dependencies) add(t *depEntry) { + select { + case d.dep <- t: + default: + log.Warnf("discard %v", t) + } + +} + +func (d *dependencies) run() { + go func() { + for { + select { + case t := <-d.dep: + go d.exec(t) + } + } + }() +} + +// TODO: 主任务退出杀死依赖 +func (d *dependencies) exec(task *depEntry) { + + var ( + reply bool + err error + ) + + if task.timeout == 0 { + // 默认超时10分钟 + task.timeout = 600 + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(task.timeout)*time.Second) + defer cancel() + myCmdUnit := cmdUint{ + args: [][]string{task.commands}, + ctx: ctx, + dir: task.workDir, + user: task.user, + logPath: task.logPath, + ignoreFileLog: true, + jd: d.jd, + exportLog: true, + } + + log.Infof("dep start exec %s->%v", task.name, task.commands) + task.err = myCmdUnit.launch() + task.logContent = bytes.TrimRight(myCmdUnit.content, "\x00") + task.done = true + log.Infof("exec %s %s cost %.4fs %v", task.name, task.commands, float64(myCmdUnit.costTime)/1000000000, err) + + task.dest, task.from = task.from, task.dest + + if !d.jd.SetDependDone(task) { + err = d.jd.rpcCallCtx(ctx, "Srv.SetDependDone", proto.DepJob{ + Name: task.name, + Dest: task.dest, + From: task.from, + ID: task.id, + JobUniqueID: task.jobUniqueID, + ProcessID: task.processID, + JobID: task.jobID, + Commands: task.commands, + LogContent: task.logContent, + Err: err, + Timeout: task.timeout, + }, &reply) + + if err != nil { + log.Error("Srv.SetDependDone error:", err, "server addr:", d.jd.getOpts().AdminAddr) + } + + if !reply { + log.Errorf("task %s %v call Srv.SetDependDone failed! err:%v", task.name, task.commands, err) + } + } +} diff --git a/jiacrontabd/jiacrontabd.go b/jiacrontabd/jiacrontabd.go new file mode 100644 index 0000000..25e4b9c --- /dev/null +++ b/jiacrontabd/jiacrontabd.go @@ -0,0 +1,489 @@ +package jiacrontabd + +import ( + "context" + "jiacrontab/models" + "jiacrontab/pkg/crontab" + "jiacrontab/pkg/finder" + "jiacrontab/pkg/proto" + "jiacrontab/pkg/rpc" + "sync/atomic" + + "github.com/iwannay/log" + + "jiacrontab/pkg/util" + "sync" + "time" + + "fmt" + "strings" +) + +// Jiacrontabd scheduling center +type Jiacrontabd struct { + crontab *crontab.Crontab + // All jobs added + jobs map[uint]*JobEntry + tmpJobs map[string]*JobEntry + dep *dependencies + daemon *Daemon + heartbeatPeriod time.Duration + mux sync.RWMutex + startTime time.Time + cfg atomic.Value + wg util.WaitGroupWrapper +} + +// New return a Jiacrontabd instance +func New(opt *Config) *Jiacrontabd { + j := &Jiacrontabd{ + jobs: make(map[uint]*JobEntry), + tmpJobs: make(map[string]*JobEntry), + + heartbeatPeriod: 5 * time.Second, + crontab: crontab.New(), + } + j.swapOpts(opt) + j.dep = newDependencies(j) + j.daemon = newDaemon(100, j) + + return j +} + +func (j *Jiacrontabd) getOpts() *Config { + return j.cfg.Load().(*Config) +} + +func (j *Jiacrontabd) swapOpts(opts *Config) { + j.cfg.Store(opts) +} + +func (j *Jiacrontabd) addTmpJob(job *JobEntry) { + j.mux.Lock() + j.tmpJobs[job.uniqueID] = job + j.mux.Unlock() +} + +func (j *Jiacrontabd) removeTmpJob(job *JobEntry) { + j.mux.Lock() + delete(j.tmpJobs, job.uniqueID) + j.mux.Unlock() +} + +func (j *Jiacrontabd) addJob(job *crontab.Job, updateLastExecTime bool) error { + j.mux.Lock() + if v, ok := j.jobs[job.ID]; ok { + v.job = job + } else { + var crontabJob models.CrontabJob + err := models.DB().First(&crontabJob, "id=?", job.ID).Error + if err != nil { + log.Error(err) + j.mux.Unlock() + return nil + } + if len(crontabJob.WorkIp) > 0 && !checkIpInWhiteList(strings.Join(crontabJob.WorkIp, ",")) { + if err := models.DB().Model(&models.CrontabJob{}).Where("id=?", job.ID). + Updates(map[string]interface{}{ + "status": models.StatusJobStop, + "next_exec_time": time.Time{}, + "last_exit_status": "IP受限制", + }).Error; err != nil { + log.Error(err) + } + j.mux.Unlock() + return nil + } + j.jobs[job.ID] = newJobEntry(job, j) + } + j.mux.Unlock() + + if err := j.crontab.AddJob(job); err != nil { + log.Error("NextExecutionTime:", err, " timeArgs:", job) + return fmt.Errorf("时间格式错误: %v - %s", err, job.Format()) + } + data := map[string]interface{}{ + "next_exec_time": job.GetNextExecTime(), + "status": models.StatusJobTiming, + } + + if updateLastExecTime { + data["last_exec_time"] = time.Now() + } + + if err := models.DB().Model(&models.CrontabJob{}).Where("id=?", job.ID). + Updates(data).Error; err != nil { + log.Error(err) + return err + } + return nil +} + +func (j *Jiacrontabd) execTask(job *crontab.Job) { + + j.mux.RLock() + if task, ok := j.jobs[job.ID]; ok { + j.mux.RUnlock() + task.exec() + return + } + log.Warnf("not found jobID(%d)", job.ID) + j.mux.RUnlock() + +} + +func (j *Jiacrontabd) killTask(jobID uint) { + var jobs []*JobEntry + j.mux.RLock() + if job, ok := j.jobs[jobID]; ok { + jobs = append(jobs, job) + } + + for _, v := range j.tmpJobs { + if v.detail.ID == jobID { + jobs = append(jobs, v) + } + } + j.mux.RUnlock() + + for _, v := range jobs { + v.kill() + } +} + +func (j *Jiacrontabd) run() { + j.dep.run() + j.daemon.run() + j.wg.Wrap(j.crontab.QueueScanWorker) + + for v := range j.crontab.Ready() { + v := v.Value.(*crontab.Job) + j.execTask(v) + } +} + +// SetDependDone 依赖执行完毕时设置相关状态 +// 目标网络不是本机时返回false +func (j *Jiacrontabd) SetDependDone(task *depEntry) bool { + + var ( + ok bool + job *JobEntry + ) + + if task.dest != j.getOpts().BoardcastAddr { + return false + } + + isAllDone := true + + j.mux.Lock() + if job, ok = j.tmpJobs[task.jobUniqueID]; !ok { + job, ok = j.jobs[task.jobID] + } + j.mux.Unlock() + if ok { + + var logContent []byte + var curTaskEntry *process + + for _, p := range job.processes { + if int(p.id) == task.processID { + curTaskEntry = p + for _, dep := range p.deps { + + if dep.id == task.id { + dep.dest = task.dest + dep.from = task.from + dep.logContent = task.logContent + dep.err = task.err + dep.done = true + } + + if dep.done == false { + isAllDone = false + } else { + logContent = append(logContent, dep.logContent...) + } + // 同步模式上一个依赖结束才会触发下一个 + if dep.id == task.id && task.err == nil && p.jobEntry.detail.IsSync { + if err := j.dispatchDependSync(p.ctx, p.deps, dep.id); err != nil { + task.err = err + } + } + + } + } + } + + if curTaskEntry == nil { + log.Infof("cannot find task entry %s %s", task.name, task.commands) + return true + } + + // 如果依赖任务执行出错直接通知主任务停止 + if task.err != nil { + isAllDone = true + curTaskEntry.err = task.err + log.Infof("depend %s %s exec failed, %s, try to stop master task", task.name, task.commands, task.err) + } + + if isAllDone { + curTaskEntry.ready <- struct{}{} + curTaskEntry.jobEntry.logContent = append(curTaskEntry.jobEntry.logContent, logContent...) + } + + } else { + log.Infof("cannot find task handler %s %s", task.name, task.commands) + } + + return true + +} + +// 同步模式根据depEntryID确定位置实现任务的依次调度 +func (j *Jiacrontabd) dispatchDependSync(ctx context.Context, deps []*depEntry, depEntryID string) error { + flag := true + cfg := j.getOpts() + if len(deps) > 0 { + flag = false + for _, v := range deps { + // 根据flag实现调度下一个依赖任务 + if flag || depEntryID == "" { + // 检测目标服务器为本机时直接执行脚本 + if v.dest == cfg.BoardcastAddr { + j.dep.add(v) + } else { + var reply bool + err := j.rpcCallCtx(ctx, "Srv.ExecDepend", []proto.DepJob{{ + ID: v.id, + Name: v.name, + Dest: v.dest, + From: v.from, + JobUniqueID: v.jobUniqueID, + JobID: v.jobID, + ProcessID: v.processID, + Commands: v.commands, + Timeout: v.timeout, + }}, &reply) + if !reply || err != nil { + return fmt.Errorf("Srv.ExecDepend error:%v server addr:%s", err, cfg.AdminAddr) + } + } + break + } + + if v.id == depEntryID { + flag = true + } + + } + } + return nil +} + +func (j *Jiacrontabd) dispatchDependAsync(ctx context.Context, deps []*depEntry) error { + var depJobs proto.DepJobs + cfg := j.getOpts() + for _, v := range deps { + // 检测目标服务器是本机直接执行脚本 + if v.dest == cfg.BoardcastAddr { + j.dep.add(v) + } else { + depJobs = append(depJobs, proto.DepJob{ + ID: v.id, + Name: v.name, + Dest: v.dest, + From: v.from, + ProcessID: v.processID, + JobID: v.jobID, + JobUniqueID: v.jobUniqueID, + Commands: v.commands, + Timeout: v.timeout, + }) + } + } + + if len(depJobs) > 0 { + var reply bool + if err := j.rpcCallCtx(ctx, "Srv.ExecDepend", depJobs, &reply); err != nil { + return fmt.Errorf("Srv.ExecDepend error:%v server addr: %s", err, cfg.AdminAddr) + + } + } + return nil +} + +func (j *Jiacrontabd) count() int { + j.mux.RLock() + num := len(j.jobs) + j.mux.RUnlock() + return num +} + +func (j *Jiacrontabd) deleteJob(jobID uint) { + j.mux.Lock() + delete(j.jobs, jobID) + j.mux.Unlock() +} + +func (j *Jiacrontabd) heartBeat() { + var ( + reply bool + cronJobs []struct { + Total uint + GroupID uint + Failed bool + Status models.JobStatus + } + daemonJobs []struct { + Total uint + GroupID uint + Status models.JobStatus + } + ok bool + nodes = make(map[uint]models.Node) + cfg = j.getOpts() + nodeName = cfg.NodeName + node models.Node + superGroupNode models.Node + ) + + if nodeName == "" { + nodeName = util.GetHostname() + } + + models.DB().Model(&models.CrontabJob{}).Select("id,group_id,status,failed,count(1) as total").Group("group_id,status,failed").Scan(&cronJobs) + models.DB().Model(&models.DaemonJob{}).Select("id,group_id,status,count(1) as total").Group("group_id,status").Scan(&daemonJobs) + + nodes[models.SuperGroup.ID] = models.Node{ + Addr: cfg.BoardcastAddr, + GroupID: models.SuperGroup.ID, + Name: nodeName, + } + + for _, job := range cronJobs { + superGroupNode = nodes[models.SuperGroup.ID] + if node, ok = nodes[job.GroupID]; !ok { + node = models.Node{ + Addr: cfg.BoardcastAddr, + GroupID: job.GroupID, + Name: nodeName, + } + } + + if job.Failed && (job.Status == models.StatusJobTiming || job.Status == models.StatusJobRunning) { + node.CrontabJobFailNum += job.Total + superGroupNode.CrontabJobFailNum += job.Total + } + + if job.Status == models.StatusJobUnaudited { + node.CrontabJobAuditNum += job.Total + superGroupNode.CrontabJobAuditNum += job.Total + } + + if job.Status == models.StatusJobTiming || job.Status == models.StatusJobRunning { + node.CrontabTaskNum += job.Total + superGroupNode.CrontabTaskNum += job.Total + } + + nodes[job.GroupID] = node + nodes[models.SuperGroup.ID] = superGroupNode + } + + for _, job := range daemonJobs { + superGroupNode = nodes[models.SuperGroup.ID] + if node, ok = nodes[job.GroupID]; !ok { + node = models.Node{ + Addr: cfg.BoardcastAddr, + GroupID: job.GroupID, + Name: nodeName, + } + } + if job.Status == models.StatusJobUnaudited { + node.DaemonJobAuditNum += job.Total + superGroupNode.DaemonJobAuditNum += job.Total + } + if job.Status == models.StatusJobRunning { + node.DaemonTaskNum += job.Total + superGroupNode.DaemonTaskNum += job.Total + } + nodes[job.GroupID] = node + nodes[models.SuperGroup.ID] = superGroupNode + } + + err := j.rpcCallCtx(context.TODO(), rpc.RegisterService, nodes, &reply) + + if err != nil { + log.Error("Srv.Register error:", err, ",server addr:", cfg.AdminAddr) + } + + time.AfterFunc(time.Duration(j.getOpts().ClientAliveInterval)*time.Second, j.heartBeat) +} + +func (j *Jiacrontabd) recovery() { + var crontabJobs []models.CrontabJob + var daemonJobs []models.DaemonJob + + // reset processNUm 0 + err := models.DB().Model(&models.CrontabJob{}).Where("process_num > ?", 0).Update("process_num", 0).Error + if err != nil { + log.Debug("crontab recovery: reset processNum failed -", err) + } + + err = models.DB().Find(&crontabJobs, "status IN (?)", []models.JobStatus{models.StatusJobTiming, models.StatusJobRunning}).Error + if err != nil { + log.Debug("crontab recovery:", err) + } + + for _, v := range crontabJobs { + j.addJob(&crontab.Job{ + ID: v.ID, + Second: v.TimeArgs.Second, + Minute: v.TimeArgs.Minute, + Hour: v.TimeArgs.Hour, + Day: v.TimeArgs.Day, + Month: v.TimeArgs.Month, + Weekday: v.TimeArgs.Weekday, + }, false) + } + + err = models.DB().Find(&daemonJobs, "status in (?)", []models.JobStatus{models.StatusJobOk}).Error + + if err != nil { + log.Debug("daemon recovery:", err) + } + + for _, v := range daemonJobs { + job := v + j.daemon.add(&daemonJob{ + job: &job, + }) + } + +} + +func (j *Jiacrontabd) init() { + cfg := j.getOpts() + if err := models.CreateDB(cfg.DriverName, cfg.DSN); err != nil { + panic(err) + } + models.DB().AutoMigrate(&models.CrontabJob{}, &models.DaemonJob{}) + j.startTime = time.Now() + if cfg.AutoCleanTaskLog { + go finder.SearchAndDeleteFileOnDisk(cfg.LogPath, 24*time.Hour*30, 1<<30) + } + j.recovery() +} + +func (j *Jiacrontabd) rpcCallCtx(ctx context.Context, serviceMethod string, args, reply interface{}) error { + return rpc.CallCtx(j.getOpts().AdminAddr, serviceMethod, ctx, args, reply) +} + +// Main main function +func (j *Jiacrontabd) Main() { + j.init() + j.heartBeat() + go j.run() + rpc.ListenAndServe(j.getOpts().ListenAddr, newCrontabJobSrv(j), newDaemonJobSrv(j), newSrv(j)) +} diff --git a/jiacrontabd/job.go b/jiacrontabd/job.go new file mode 100644 index 0000000..4bc0366 --- /dev/null +++ b/jiacrontabd/job.go @@ -0,0 +1,604 @@ +package jiacrontabd + +import ( + "context" + "encoding/json" + "fmt" + "jiacrontab/models" + "jiacrontab/pkg/crontab" + "jiacrontab/pkg/proto" + "jiacrontab/pkg/util" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/iwannay/log" +) + +const ( + exitError = "Error" + exitKilled = "Killed" + exitSuccess = "Success" + exitDependError = "Dependent job execution failed" + exitTimeout = "Timeout" +) + +type process struct { + id uint32 + deps []*depEntry + ctx context.Context + cancel context.CancelFunc + err error + startTime time.Time + endTime time.Time + ready chan struct{} + retryNum int + jobEntry *JobEntry +} + +func newProcess(id uint32, jobEntry *JobEntry) *process { + p := &process{ + id: id, + jobEntry: jobEntry, + startTime: time.Now(), + ready: make(chan struct{}), + } + + p.ctx, p.cancel = context.WithCancel(context.Background()) + + for _, v := range p.jobEntry.detail.DependJobs { + cmd := v.Command + if v.Code != "" { + cmd = append(cmd, v.Code) + } + p.deps = append(p.deps, &depEntry{ + jobID: p.jobEntry.detail.ID, + processID: int(id), + jobUniqueID: p.jobEntry.uniqueID, + id: v.ID, + from: v.From, + commands: cmd, + dest: v.Dest, + logPath: filepath.Join(p.jobEntry.jd.getOpts().LogPath, "depend_job", time.Now().Format("2006/01/02"), fmt.Sprintf("%d-%s.log", v.JobID, v.ID)), + done: false, + timeout: v.Timeout, + }) + } + + return p +} + +func (p *process) waitDepExecDone() bool { + + var err error + + if len(p.deps) == 0 { + return true + } + + if p.jobEntry.detail.IsSync { + // 同步 + err = p.jobEntry.jd.dispatchDependSync(p.ctx, p.deps, "") + } else { + // 并发模式 + err = p.jobEntry.jd.dispatchDependAsync(p.ctx, p.deps) + } + if err != nil { + prefix := fmt.Sprintf("[%s %s] ", time.Now().Format("2006-01-02 15:04:05"), p.jobEntry.jd.getOpts().BoardcastAddr) + p.jobEntry.logContent = append(p.jobEntry.logContent, []byte(prefix+"failed to exec depends\n")...) + return false + } + + c := time.NewTimer(3600 * time.Second) + defer c.Stop() + + for { + select { + case <-p.ctx.Done(): + log.Debugf("jobID:%d exec cancel", p.jobEntry.detail.ID) + return false + case <-c.C: + p.cancel() + log.Errorf("jobID:%d exec dep timeout!", p.jobEntry.detail.ID) + return false + case <-p.ready: + if p.err != nil { + log.Errorf("jobID:%d exec dep error(%s)", p.jobEntry.detail.ID, p.err) + return false + } + log.Debugf("jobID:%d exec all dep done.", p.jobEntry.detail.ID) + return true + } + } +} + +func (p *process) exec() error { + var ( + ok bool + err error + doneChan = make(chan struct{}, 1) + ) + + if ok = p.waitDepExecDone(); !ok { + p.jobEntry.handleDepError(p.startTime, p) + } else { + if p.jobEntry.detail.Timeout != 0 { + time.AfterFunc( + time.Duration(p.jobEntry.detail.Timeout)*time.Second, func() { + select { + case <-doneChan: + close(doneChan) + default: + log.Debug("timeout callback:", "jobID:", p.jobEntry.detail.ID) + p.jobEntry.timeoutTrigger(p) + } + }) + } + + arg := p.jobEntry.detail.Command + if p.jobEntry.detail.Code != "" { + arg = append(arg, p.jobEntry.detail.Code) + } + + myCmdUnit := cmdUint{ + args: [][]string{arg}, + ctx: p.ctx, + dir: p.jobEntry.detail.WorkDir, + user: p.jobEntry.detail.WorkUser, + env: p.jobEntry.detail.WorkEnv, + ip: p.jobEntry.detail.WorkIp, + content: p.jobEntry.logContent, + logDir: p.jobEntry.getLogDir(), + id: p.jobEntry.job.ID, + label: p.jobEntry.detail.Name, + killChildProcess: p.jobEntry.detail.KillChildProcess, + jd: p.jobEntry.jd, + } + + if p.jobEntry.once { + myCmdUnit.exportLog = true + } + p.err = myCmdUnit.launch() + p.jobEntry.logContent = myCmdUnit.content + doneChan <- struct{}{} + + if p.err != nil { + p.jobEntry.handleNotify(p) + } + } + + p.endTime = time.Now() + p.jobEntry.detail.LastCostTime = p.endTime.Sub(p.startTime).Seconds() + + log.Infof("%s exec cost %.3fs err(%v)", p.jobEntry.detail.Name, p.jobEntry.detail.LastCostTime, err) + return p.err +} + +type JobEntry struct { + job *crontab.Job + detail models.CrontabJob + processNum int32 + processes map[uint32]*process + pc int32 + wg util.WaitGroupWrapper + logContent []byte + jd *Jiacrontabd + IDChan chan uint32 + IDGenerator uint32 + mux sync.RWMutex + once bool // 只执行一次 + stop int32 // job stop status + uniqueID string +} + +func newJobEntry(job *crontab.Job, jd *Jiacrontabd) *JobEntry { + return &JobEntry{ + uniqueID: util.UUID(), + job: job, + IDChan: make(chan uint32, 10000), + processes: make(map[uint32]*process), + jd: jd, + } +} + +func (j *JobEntry) getLogPath() string { + return filepath.Join(j.jd.getOpts().LogPath, "crontab_task", time.Now().Format("2006/01/02"), fmt.Sprintf("%d.log", j.job.ID)) +} + +func (j *JobEntry) getLogDir() string { + return filepath.Join(j.jd.getOpts().LogPath, "crontab_task") +} + +func (j *JobEntry) setOnce(v bool) { + j.once = v +} + +func (j *JobEntry) takeID() uint32 { + for { + select { + case id := <-j.IDChan: + return id + default: + id := atomic.AddUint32(&j.IDGenerator, 1) + if id != 0 { + return id + } + } + } +} + +func (j *JobEntry) putID(id uint32) { + select { + case j.IDChan <- id: + default: + } +} + +func (j *JobEntry) writeLog() { + writeFile(j.getLogPath(), &j.logContent) +} + +func (j *JobEntry) handleDepError(startTime time.Time, p *process) { + cfg := j.jd.getOpts() + err := fmt.Errorf("%s %s exec depend job err(%v)", time.Now().Format(proto.DefaultTimeLayout), cfg.BoardcastAddr, p.err) + endTime := time.Now() + reply := true + + j.logContent = append(j.logContent, []byte(err.Error()+"\n")...) + j.detail.LastExitStatus = exitDependError + j.writeLog() + + if j.detail.ErrorMailNotify && len(j.detail.MailTo) != 0 { + if err := j.jd.rpcCallCtx(context.TODO(), "Srv.SendMail", proto.SendMail{ + MailTo: j.detail.MailTo, + Subject: cfg.BoardcastAddr + "提醒脚本依赖异常退出", + Content: fmt.Sprintf( + "任务名:%s
创建者:%s
开始时间:%s
耗时:%.4f
异常:%s", + j.detail.Name, j.detail.CreatedUsername, startTime.Format(proto.DefaultTimeLayout), endTime.Sub(startTime).Seconds(), err), + }, &reply); err != nil { + log.Error("Srv.SendMail error:", err, "server addr:", cfg.AdminAddr) + } + } + + // 钉钉webhook通知 + if j.detail.ErrorDingdingNotify && len(j.detail.DingdingTo) != 0 { + nodeAddr := cfg.BoardcastAddr + title := nodeAddr + "告警:脚本依赖异常退出" + notifyContent := fmt.Sprintf("> ###### 来自jiacrontabd: %s 的依赖异常退出报警:\n> ##### 任务id:%d\n> ##### 任务名称:%s\n> ##### timeout:%d\n> ##### 尝试次数:%d\n> ##### 异常:%s\n> ##### 报警时间:%s", nodeAddr, int(j.detail.ID), j.detail.Name, int64(j.detail.Timeout), p.retryNum, err, time.Now().Format("2006-01-02 15:04:05")) + notifyBody := fmt.Sprintf( + `{ + "msgtype": "markdown", + "markdown": { + "title": "%s", + "text": "%s" + } + }`, title, notifyContent) + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.ApiPost", proto.ApiPost{ + Urls: j.detail.DingdingTo, + Data: notifyBody, + }, &reply); err != nil { + log.Error("Srv.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + } +} + +func (j *JobEntry) handleNotify(p *process) { + + var ( + err error + reply bool + cfg = j.jd.getOpts() + ) + + if p.err == nil { + return + } + + if j.detail.ErrorMailNotify { + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.SendMail", proto.SendMail{ + MailTo: j.detail.MailTo, + Subject: cfg.BoardcastAddr + "提醒脚本异常退出", + Content: fmt.Sprintf( + "任务名:%s
创建者:%s
开始时间:%s
异常:%s
重试次数:%d", + j.detail.Name, j.detail.CreatedUsername, + p.startTime.Format(proto.DefaultTimeLayout), p.err.Error(), p.retryNum), + }, &reply); err != nil { + log.Error("Srv.SendMail error:", err, "server addr:", cfg.AdminAddr) + } + } + + if j.detail.ErrorAPINotify { + postData, err := json.Marshal(proto.CrontabApiNotifyBody{ + NodeAddr: cfg.BoardcastAddr, + JobName: j.detail.Name, + JobID: int(j.detail.ID), + CreateUsername: j.detail.CreatedUsername, + CreatedAt: j.detail.CreatedAt, + Timeout: int64(j.detail.Timeout), + Type: "error", + RetryNum: p.retryNum, + }) + if err != nil { + log.Error("json.Marshal error:", err) + return + } + + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.ApiPost", proto.ApiPost{ + Urls: j.detail.APITo, + Data: string(postData), + }, &reply); err != nil { + log.Error("Srv.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + + } + + // 钉钉webhook通知 + if j.detail.ErrorDingdingNotify { + nodeAddr := cfg.BoardcastAddr + title := nodeAddr + "告警:脚本异常退出" + notifyContent := fmt.Sprintf("> ###### 来自jiacrontabd: %s 的脚本异常退出报警:\n> ##### 任务id:%d\n> ##### 任务名称:%s\n> ##### timeout:%d\n> ##### 尝试次数:%d\n> ##### 报警时间:%s", nodeAddr, int(j.detail.ID), j.detail.Name, int64(j.detail.Timeout), p.retryNum, time.Now().Format("2006-01-02 15:04:05")) + notifyBody := fmt.Sprintf( + `{ + "msgtype": "markdown", + "markdown": { + "title": "%s", + "text": "%s" + } + }`, title, notifyContent) + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.ApiPost", proto.ApiPost{ + Urls: j.detail.DingdingTo, + Data: notifyBody, + }, &reply); err != nil { + log.Error("Srv.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + } +} + +func (j *JobEntry) timeoutTrigger(p *process) { + + var ( + err error + reply bool + cfg = j.jd.getOpts() + ) + + for _, e := range j.detail.TimeoutTrigger { + switch e { + case proto.TimeoutTrigger_CallApi: + j.detail.LastExitStatus = exitTimeout + postData, err := json.Marshal(proto.CrontabApiNotifyBody{ + NodeAddr: cfg.BoardcastAddr, + JobName: j.detail.Name, + JobID: int(j.detail.ID), + CreateUsername: j.detail.CreatedUsername, + CreatedAt: j.detail.CreatedAt, + Timeout: int64(j.detail.Timeout), + Type: "timeout", + RetryNum: p.retryNum, + }) + if err != nil { + log.Error("json.Marshal error:", err) + } + + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.ApiPost err:", proto.ApiPost{ + Urls: j.detail.APITo, + Data: string(postData), + }, &reply); err != nil { + log.Error("Srv.ApiPost err:", err, "server addr:", cfg.AdminAddr) + } + + case proto.TimeoutTrigger_SendEmail: + j.detail.LastExitStatus = exitTimeout + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.SendMail", proto.SendMail{ + MailTo: j.detail.MailTo, + Subject: cfg.BoardcastAddr + "提醒脚本执行超时", + Content: fmt.Sprintf( + "任务名:%s
创建者:%v
开始时间:%s
超时:%ds
重试次数:%d", + j.detail.Name, j.detail.CreatedUsername, p.startTime.Format(proto.DefaultTimeLayout), + j.detail.Timeout, p.retryNum), + }, &reply); err != nil { + log.Error("Srv.SendMail error:", err, "server addr:", cfg.AdminAddr) + } + case proto.TimeoutTrigger_Kill: + j.detail.LastExitStatus = exitTimeout + p.cancel() + case proto.TimeoutTrigger_DingdingWebhook: + j.detail.LastExitStatus = exitTimeout + + nodeAddr := cfg.BoardcastAddr + title := nodeAddr + "告警:脚本执行超时" + notifyContent := fmt.Sprintf("> ###### 来自jiacrontabd: %s 的脚本执行超时报警:\n> ##### 任务id:%d\n> ##### 任务名称:%s\n> ##### timeout:%d\n> ##### 尝试次数:%d\n> ##### 报警时间:%s", nodeAddr, int(j.detail.ID), j.detail.Name, int64(j.detail.Timeout), p.retryNum, time.Now().Format("2006-01-02 15:04:05")) + notifyBody := fmt.Sprintf( + `{ + "msgtype": "markdown", + "markdown": { + "title": "%s", + "text": "%s" + } + }`, title, notifyContent) + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.ApiPost", proto.ApiPost{ + Urls: j.detail.DingdingTo, + Data: notifyBody, + }, &reply); err != nil { + log.Error("Srv.ApiPost error:", err, "server addr:", cfg.AdminAddr) + } + default: + log.Error("invalid timeoutTrigger", e) + } + } +} + +// GetLog return log data +func (j *JobEntry) GetLog() []byte { + return j.logContent +} + +func (j *JobEntry) exec() { + + if atomic.LoadInt32(&j.stop) == 1 { + return + } + + exec := func() { + var err error + now := time.Now() + if j.once { + err = models.DB().Take(&j.detail, "id=?", j.job.ID).Error + atomic.StoreInt32(&j.processNum, int32(j.detail.ProcessNum)) + } else { + err = models.DB().Take(&j.detail, "id=? and status in(?)", + j.job.ID, []models.JobStatus{models.StatusJobTiming, models.StatusJobRunning}).Error + } + + if err != nil { + j.jd.deleteJob(j.detail.ID) + log.Warnf("jobID:%d JobEntry.exec:%v", j.detail.ID, err) + return + } + + if !j.once { + // 忽略一秒内重复执行的job + if j.detail.LastExecTime.Truncate(time.Second).Equal(now.Truncate(time.Second)) { + log.Infof("ignore repeat job %s", j.detail.Name) + return + } + // 数据库中记录的执行时刻等于计时器中的时刻和现在的时刻才允许执行 + execTime := j.detail.NextExecTime.Truncate(time.Second) + if !(execTime.Equal(j.job.GetNextExecTime().Truncate(time.Second)) && execTime.Equal(now.Truncate(time.Second))) { + log.Errorf("%s(%d) JobEntry.exec time error(%s not equal %s)", + j.detail.Name, j.detail.ID, execTime, now) + j.jd.addJob(&crontab.Job{ + ID: j.detail.ID, + Second: j.detail.TimeArgs.Second, + Minute: j.detail.TimeArgs.Minute, + Hour: j.detail.TimeArgs.Hour, + Day: j.detail.TimeArgs.Day, + Month: j.detail.TimeArgs.Month, + Weekday: j.detail.TimeArgs.Weekday, + }, false) + return + } + j.jd.addJob(j.job, true) + } + + if atomic.LoadInt32(&j.processNum) >= int32(j.detail.MaxConcurrent) && j.detail.MaxConcurrent != 0 { + j.logContent = []byte("不得超过job最大并发数量\n") + return + } + + if atomic.LoadInt32(&j.processNum) == 0 { + j.logContent = nil + } + + atomic.AddInt32(&j.processNum, 1) + + id := j.takeID() + startTime := time.Now() + var endTime time.Time + defer func() { + endTime = time.Now() + atomic.AddInt32(&j.processNum, -1) + j.updateJob(models.StatusJobTiming, startTime, endTime, err) + }() + + j.updateJob(models.StatusJobRunning, startTime, endTime, err) + + p := newProcess(id, j) + + j.mux.Lock() + j.processes[id] = p + j.mux.Unlock() + + defer func() { + j.mux.Lock() + delete(j.processes, id) + j.mux.Unlock() + j.putID(id) + }() + + for i := 0; i <= j.detail.RetryNum; i++ { + + if atomic.LoadInt32(&j.stop) == 1 { + return + } + + log.Debug("jobID:", j.detail.ID, "retryNum:", i) + + p.retryNum = i + + // 执行脚本 + if err = p.exec(); err == nil || j.once { + break + } + } + } + + if j.once { + exec() + return + } + + j.wg.Wrap(exec) +} + +func (j *JobEntry) updateJob(status models.JobStatus, startTime, endTime time.Time, err error) { + data := map[string]interface{}{ + "status": status, + "process_num": atomic.LoadInt32(&j.processNum), + "last_exit_status": "", + "failed": false, + } + + if endTime.After(startTime) { + data["last_cost_time"] = endTime.Sub(startTime).Seconds() + } + + var errMsg string + if err != nil { + errMsg = err.Error() + data["last_exit_status"] = errMsg + data["failed"] = true + } + + if j.once { + delete(data, "status") + delete(data, "last_exit_status") + } + + if status == models.StatusJobTiming { + if err = j.jd.rpcCallCtx(context.TODO(), "Srv.PushJobLog", models.JobHistory{ + JobType: models.JobTypeCrontab, + JobID: j.detail.ID, + Addr: j.jd.getOpts().BoardcastAddr, + JobName: j.detail.Name, + StartTime: startTime, + EndTime: endTime, + ExitMsg: errMsg, + }, nil); err != nil { + log.Error("rpc call Srv.PushJobLog failed:", err) + } + } + + models.DB().Model(&j.detail).Updates(data) +} + +func (j *JobEntry) kill() { + j.exit() + j.waitDone() +} + +func (j *JobEntry) waitDone() []byte { + j.wg.Wait() + atomic.StoreInt32(&j.stop, 0) + return j.logContent +} + +func (j *JobEntry) exit() { + atomic.StoreInt32(&j.stop, 1) + j.mux.Lock() + for _, v := range j.processes { + v.cancel() + } + j.mux.Unlock() +} diff --git a/jiacrontabd/srv.go b/jiacrontabd/srv.go new file mode 100644 index 0000000..3708c9a --- /dev/null +++ b/jiacrontabd/srv.go @@ -0,0 +1,571 @@ +package jiacrontabd + +import ( + "errors" + "fmt" + "jiacrontab/models" + "jiacrontab/pkg/crontab" + "jiacrontab/pkg/file" + "jiacrontab/pkg/finder" + "jiacrontab/pkg/proto" + "jiacrontab/pkg/util" + "os" + "path/filepath" + "strings" + "time" + + "github.com/iwannay/log" +) + +type Srv struct { + jd *Jiacrontabd +} + +func newSrv(jd *Jiacrontabd) *Srv { + return &Srv{ + jd: jd, + } +} + +func (s *Srv) Ping(args proto.EmptyArgs, reply *proto.EmptyReply) error { + return nil +} + +func (s *Srv) SystemInfo(args proto.EmptyArgs, reply *map[string]interface{}) error { + *reply = util.SystemInfo(s.jd.startTime) + (*reply)["job日志文件大小"] = file.FileSize(file.DirSize(s.jd.getOpts().LogPath)) + return nil +} + +func (s *Srv) CleanLogFiles(args proto.CleanNodeLog, reply *proto.CleanNodeLogRet) error { + dir := s.jd.getOpts().LogPath + + var t time.Time + if args.Unit == "month" { + t = time.Now().AddDate(0, -args.Offset, 0) + } else if args.Unit == "day" { + t = time.Now().AddDate(0, 0, -args.Offset) + } + + total, size, err := file.Remove(dir, t) + reply.Total = total + reply.Size = file.FileSize(size) + return err +} + +type CrontabJob struct { + jd *Jiacrontabd +} + +func newCrontabJobSrv(jd *Jiacrontabd) *CrontabJob { + return &CrontabJob{ + jd: jd, + } +} + +func (j *CrontabJob) List(args proto.QueryJobArgs, reply *proto.QueryCrontabJobRet) error { + model := models.DB().Model(&models.CrontabJob{}) + if args.SearchTxt != "" { + txt := "%" + args.SearchTxt + "%" + model = model.Where("(name like ? or command like ? or code like ?)", txt, txt, txt) + } + + if args.GroupID == models.SuperGroup.ID { + } else if args.Root { + model = model.Where("group_id=?", args.GroupID) + } else { + model = model.Where("created_user_id=? and group_id=?", args.UserID, args.GroupID) + } + err := model.Count(&reply.Total).Error + if err != nil { + return err + } + + reply.Page = args.Page + reply.Pagesize = args.Pagesize + + return model.Order(fmt.Sprintf("created_user_id=%d desc, id desc", args.UserID)).Offset((args.Page - 1) * args.Pagesize).Limit(args.Pagesize).Find(&reply.List).Error +} + +func (j *CrontabJob) Audit(args proto.AuditJobArgs, reply *[]models.CrontabJob) error { + model := models.DB().Model(&models.CrontabJob{}) + + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else { + model = model.Where("id in (?) and group_id=?", args.JobIDs, args.GroupID) + } + + defer model.Find(reply) + return model.Where("status=?", models.StatusJobUnaudited).Update("status", models.StatusJobOk).Error +} + +func (j *CrontabJob) Edit(args proto.EditCrontabJobArgs, reply *models.CrontabJob) error { + + var ( + model = models.DB() + ) + + if args.Job.MaxConcurrent == 0 { + args.Job.MaxConcurrent = 1 + } + + if args.Job.ID == 0 { + model = models.DB().Save(&args.Job) + } else { + // we should kill the job + j.jd.killTask(args.Job.ID) + + j.jd.mux.Lock() + delete(j.jd.jobs, args.Job.ID) + j.jd.mux.Unlock() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id=?", args.Job.ID) + } else if args.Root { + model = model.Where("id=? and group_id=?", args.Job.ID, args.Job.GroupID) + } else { + model = model.Where("id=? and created_user_id=? and group_id=?", args.Job.ID, args.Job.CreatedUserID, args.Job.GroupID) + } + args.Job.NextExecTime = time.Time{} + model = model.Omit( + "updated_at", "created_at", "deleted_at", + "created_user_id", "created_username", + "last_cost_time", "last_exec_time", "group_id", + "last_exit_status", "process_num", + ).Save(&args.Job) + } + *reply = args.Job + return model.Error +} +func (j *CrontabJob) Get(args proto.GetJobArgs, reply *models.CrontabJob) error { + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id=?", args.JobID) + } else if args.Root { + model = model.Where("id=? and group_id=?", args.JobID, args.GroupID) + } else { + model = model.Where("id=? and created_user_id=? and group_id=?", args.JobID, args.UserID, args.GroupID) + } + return model.Find(reply).Error +} + +func (j *CrontabJob) Start(args proto.ActionJobsArgs, jobs *[]models.CrontabJob) error { + + model := models.DB() + + if len(args.JobIDs) == 0 { + return errors.New("empty ids") + } + + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?) and status in (?)", + args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}) + } else if args.Root { + model = model.Where("id in (?) and status in (?) and group_id=?", + args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and status in (?) and group_id=?", + args.UserID, args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}, args.GroupID) + } + + ret := model.Find(jobs) + if ret.Error != nil { + return ret.Error + } + + for _, v := range *jobs { + err := j.jd.addJob(&crontab.Job{ + ID: v.ID, + Second: v.TimeArgs.Second, + Minute: v.TimeArgs.Minute, + Hour: v.TimeArgs.Hour, + Day: v.TimeArgs.Day, + Month: v.TimeArgs.Month, + Weekday: v.TimeArgs.Weekday, + }, false) + if err != nil { + return err + } + } + + return nil +} + +func (j *CrontabJob) Stop(args proto.ActionJobsArgs, jobs *[]models.CrontabJob) error { + model := models.DB().Model(&models.CrontabJob{}) + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?) and status in (?)", args.JobIDs, []models.JobStatus{models.StatusJobTiming, models.StatusJobRunning}) + } else if args.Root { + model = model.Where("id in (?) and status in (?) and group_id=?", + args.JobIDs, []models.JobStatus{models.StatusJobTiming, models.StatusJobRunning}, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and status in (?) and group_id=?", + args.UserID, args.JobIDs, []models.JobStatus{models.StatusJobTiming, models.StatusJobRunning}, args.GroupID) + } + + for _, jobID := range args.JobIDs { + j.jd.killTask(jobID) + } + + return model.Updates(map[string]interface{}{ + "status": models.StatusJobStop, + "next_exec_time": time.Time{}, + }).Find(jobs).Error +} + +func (j *CrontabJob) Delete(args proto.ActionJobsArgs, job *[]models.CrontabJob) error { + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else if args.Root { + model = model.Where("id in (?) and group_id=?", args.JobIDs, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and group_id=?", + args.UserID, args.JobIDs, args.GroupID) + } + return model.Find(job).Delete(&models.CrontabJob{}).Error +} + +func (j *CrontabJob) Kill(args proto.ActionJobsArgs, job *[]models.CrontabJob) error { + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else if args.Root { + model = model.Where("id in (?) and group_id=?", args.JobIDs, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and group_id=?", + args.UserID, args.JobIDs, args.GroupID) + } + + err := model.Take(job).Error + if err != nil { + return err + } + + for _, jobID := range args.JobIDs { + j.jd.killTask(jobID) + } + return nil +} + +func (j *CrontabJob) Execs(args proto.ActionJobsArgs, reply *[]models.CrontabJob) error { + + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else if args.Root { + model = model.Where("id in (?) and group_id=?", args.JobIDs, args.GroupID) + } else { + model = model.Where("created_user_id=? and id in (?) and group_id=?", args.UserID, args.JobIDs, args.GroupID) + } + + var jobs []models.CrontabJob + if err := model.Find(&jobs).Error; err != nil { + return err + } + + for _, v := range jobs { + *reply = append(*reply, v) + go func(v models.CrontabJob) { + ins := newJobEntry(&crontab.Job{ + ID: v.ID, + Value: v, + }, j.jd) + ins.setOnce(true) + j.jd.addTmpJob(ins) + defer j.jd.removeTmpJob(ins) + ins.once = true + ins.exec() + }(v) + } + return nil +} + +func (j *CrontabJob) Exec(args proto.GetJobArgs, reply *proto.ExecCrontabJobReply) error { + + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id=?", args.JobID) + } else if args.Root { + model = model.Where("id=? and group_id=?", args.JobID, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id=? and group_id=?", args.UserID, args.JobID, args.GroupID) + } + + err := model.Take(&reply.Job).Error + + if err == nil { + ins := newJobEntry(&crontab.Job{ + ID: reply.Job.ID, + Value: reply.Job, + }, j.jd) + ins.setOnce(true) + j.jd.addTmpJob(ins) + defer j.jd.removeTmpJob(ins) + ins.once = true + ins.exec() + reply.Content = ins.GetLog() + } else { + reply.Content = []byte(err.Error()) + } + return err +} + +func (j *CrontabJob) Log(args proto.SearchLog, reply *proto.SearchLogResult) error { + fd := finder.NewFinder(func(info os.FileInfo) bool { + basename := filepath.Base(info.Name()) + arr := strings.Split(basename, ".") + if len(arr) != 2 { + return false + } + + if arr[1] == "log" && arr[0] == fmt.Sprint(args.JobID) { + return true + } + return false + }) + + if args.Date == "" { + args.Date = time.Now().Format("2006/01/02") + } + if args.IsTail { + fd.SetTail(true) + } + + rootpath := filepath.Join(j.jd.getOpts().LogPath, "crontab_task", args.Date) + err := fd.Search(rootpath, args.Pattern, &reply.Content, args.Offset, args.Pagesize) + reply.Offset = fd.Offset() + reply.FileSize = fd.FileSize() + return err + +} + +// SetDependDone 依赖执行完毕时设置相关状态 +func (j *CrontabJob) SetDependDone(args proto.DepJob, reply *bool) error { + *reply = j.jd.SetDependDone(&depEntry{ + jobID: args.JobID, + processID: args.ProcessID, + jobUniqueID: args.JobUniqueID, + id: args.ID, + dest: args.Dest, + from: args.From, + done: true, + logContent: args.LogContent, + err: args.Err, + }) + return nil +} + +// ExecDepend 执行依赖 +func (j *CrontabJob) ExecDepend(args proto.DepJob, reply *bool) error { + j.jd.dep.add(&depEntry{ + jobUniqueID: args.JobUniqueID, + processID: args.ProcessID, + jobID: args.JobID, + id: args.ID, + dest: args.Dest, + from: args.From, + name: args.Name, + commands: args.Commands, + }) + *reply = true + log.Infof("job %s %v add to execution queue ", args.Name, args.Commands) + return nil +} + +func (j *CrontabJob) Ping(args *proto.EmptyArgs, reply *proto.EmptyReply) error { + return nil +} + +type DaemonJob struct { + jd *Jiacrontabd +} + +func newDaemonJobSrv(jd *Jiacrontabd) *DaemonJob { + return &DaemonJob{ + jd: jd, + } +} + +func (j *DaemonJob) List(args proto.QueryJobArgs, reply *proto.QueryDaemonJobRet) error { + + model := models.DB().Model(&models.DaemonJob{}) + if args.SearchTxt != "" { + txt := "%" + args.SearchTxt + "%" + model = model.Where("(name like ? or command like ? or code like ?)", + txt, txt, txt) + } + + if args.GroupID == models.SuperGroup.ID { + } else if args.Root { + model = model.Where("group_id=?", args.GroupID) + } else { + model = model.Where("created_user_id=? and group_id=?", args.UserID, args.GroupID) + } + + err := model.Count(&reply.Total).Error + if err != nil { + return err + } + + reply.Page = args.Page + reply.Pagesize = args.Pagesize + + return model.Order(fmt.Sprintf("created_user_id=%d desc, id desc", args.UserID)).Offset((args.Page - 1) * args.Pagesize).Limit(args.Pagesize).Find(&reply.List).Error +} + +func (j *DaemonJob) Edit(args proto.EditDaemonJobArgs, job *models.DaemonJob) error { + + model := models.DB() + if args.Job.ID == 0 { + model = models.DB().Create(&args.Job) + } else { + j.jd.daemon.lock.Lock() + delete(j.jd.daemon.taskMap, args.Job.ID) + j.jd.daemon.lock.Unlock() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id=?", args.Job.ID) + } else if args.Root { + model = model.Where("id=? and group_id=?", args.Job.ID, args.GroupID) + } else { + model = model.Where("id=? and created_user_id=? and group_id=?", args.Job.ID, args.Job.CreatedUserID, args.GroupID) + } + model = model.Omit( + "updated_at", "created_at", "deleted_at", "group_id", + "created_user_id", "created_username", "start_at").Save(&args.Job) + } + + *job = args.Job + return model.Error +} + +func (j *DaemonJob) Start(args proto.ActionJobsArgs, jobs *[]models.DaemonJob) error { + + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?) and status in (?)", + args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}) + } else if args.Root { + model = model.Where("id in (?) and status in (?) and group_id=?", + args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and status in (?) and group_id=?", + args.UserID, args.JobIDs, []models.JobStatus{models.StatusJobOk, models.StatusJobStop}, args.GroupID) + } + + ret := model.Find(&jobs) + if ret.Error != nil { + return ret.Error + } + + for _, v := range *jobs { + job := v + j.jd.daemon.add(&daemonJob{ + job: &job, + }) + } + + return nil +} + +func (j *DaemonJob) Stop(args proto.ActionJobsArgs, jobs *[]models.DaemonJob) error { + + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?) and status in (?)", + args.JobIDs, []models.JobStatus{models.StatusJobRunning, models.StatusJobTiming}) + } else if args.Root { + model = model.Where("id in (?) and status in (?) and group_id=?", + args.JobIDs, []models.JobStatus{models.StatusJobRunning, models.StatusJobTiming}, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in (?) and status in (?) and group_id=?", + args.UserID, args.JobIDs, []models.JobStatus{models.StatusJobRunning, models.StatusJobTiming}, args.GroupID) + } + + if err := model.Find(jobs).Error; err != nil { + return err + } + args.JobIDs = nil + for _, job := range *jobs { + args.JobIDs = append(args.JobIDs, job.ID) + j.jd.daemon.PopJob(job.ID) + } + + return model.Model(&models.DaemonJob{}).Update("status", models.StatusJobStop).Error +} + +func (j *DaemonJob) Delete(args proto.ActionJobsArgs, jobs *[]models.DaemonJob) error { + + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else if args.Root { + model = model.Where("id in (?) and group_id=?", + args.JobIDs, args.GroupID) + } else { + model = model.Where("created_user_id = ? and id in(?) and group_id=?", + args.UserID, args.JobIDs, args.GroupID) + } + + if err := model.Find(jobs).Error; err != nil { + return err + } + for _, job := range *jobs { + j.jd.daemon.PopJob(job.ID) + } + return model.Delete(&models.DaemonJob{}).Error +} + +func (j *DaemonJob) Get(args proto.GetJobArgs, job *models.DaemonJob) error { + model := models.DB() + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id=?", args.JobID) + } else if args.Root { + model = model.Where("id=? and group_id=?", args.JobID, args.GroupID) + } else { + model = model.Where("id=? and group_id=? and created_user_id=?", args.JobID, args.GroupID, args.UserID) + } + return model.Take(job).Error +} + +func (j *DaemonJob) Log(args proto.SearchLog, reply *proto.SearchLogResult) error { + + fd := finder.NewFinder(func(info os.FileInfo) bool { + basename := filepath.Base(info.Name()) + arr := strings.Split(basename, ".") + if len(arr) != 2 { + return false + } + if arr[1] == "log" && arr[0] == fmt.Sprint(args.JobID) { + return true + } + return false + }) + + if args.Date == "" { + args.Date = time.Now().Format("2006/01/02") + } + + if args.IsTail { + fd.SetTail(true) + } + + rootpath := filepath.Join(j.jd.getOpts().LogPath, "daemon_job", args.Date) + err := fd.Search(rootpath, args.Pattern, &reply.Content, args.Offset, args.Pagesize) + reply.Offset = fd.Offset() + reply.FileSize = fd.FileSize() + return err + +} + +func (j *DaemonJob) Audit(args proto.AuditJobArgs, jobs *[]models.DaemonJob) error { + model := models.DB().Model(&models.DaemonJob{}) + if args.GroupID == models.SuperGroup.ID { + model = model.Where("id in (?)", args.JobIDs) + } else if args.Root { + model = model.Where("id in (?) and group_id=?", args.JobIDs, args.GroupID) + } else { + model = model.Where("id in (?) and group_id=? and created_user_id=?", args.JobIDs, args.GroupID, args.UserID) + } + return model.Where("status=?", models.StatusJobUnaudited).Find(jobs).Update("status", models.StatusJobOk).Error +} diff --git a/jiacrontabd/util.go b/jiacrontabd/util.go new file mode 100644 index 0000000..d9216a7 --- /dev/null +++ b/jiacrontabd/util.go @@ -0,0 +1,83 @@ +package jiacrontabd + +import ( + "jiacrontab/pkg/util" + "os" + + "github.com/iwannay/log" + + "container/list" + "net" + "strconv" + "strings" +) + +func writeFile(fPath string, content *[]byte) { + f, err := util.TryOpen(fPath, os.O_APPEND|os.O_CREATE|os.O_RDWR) + if err != nil { + log.Errorf("writeLog: %v", err) + return + } + defer f.Close() + f.Write(*content) +} + +func GetIntranetIpList() *list.List { + ipList := list.New() + addrs, err := net.InterfaceAddrs() + + if err != nil { + return ipList + } + + for _, address := range addrs { + // 检查ip地址判断是否回环地址 + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + ipList.PushBack(ipnet.IP.String()) + } + + } + } + return ipList +} + +func isIpBelong(ip, cidr string) bool { + ipAddr := strings.Split(ip, `.`) + if len(ipAddr) < 4 { + return false + } + if ip == cidr { + return true + } + cidrArr := strings.Split(cidr, `/`) + if len(cidrArr) < 2 { + return false + } + var tmp = make([]string, 0) + for key, value := range strings.Split(`255.255.255.0`, `.`) { + iint, _ := strconv.Atoi(value) + + iint2, _ := strconv.Atoi(ipAddr[key]) + + tmp = append(tmp, strconv.Itoa(iint&iint2)) + } + return strings.Join(tmp, `.`) == cidrArr[0] +} + +func checkIpInWhiteList(whiteIpStr string) bool { + myIps := GetIntranetIpList() + whiteIpList := strings.Split(whiteIpStr, `,`) + if len(whiteIpList) == 0 { + return true + } + for item := myIps.Front(); nil != item; item = item.Next() { + for i := range whiteIpList { + isBelong := isIpBelong(item.Value.(string), whiteIpList[i]) + if isBelong { + return true + } + } + } + return false +} diff --git a/models/crontab.go b/models/crontab.go new file mode 100644 index 0000000..cd4d947 --- /dev/null +++ b/models/crontab.go @@ -0,0 +1,222 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "jiacrontab/pkg/util" + "time" + + "gorm.io/gorm" +) + +// JobStatus 任务状态 +type JobStatus int + +const ( + // StatusJobUnaudited 未审核 + StatusJobUnaudited JobStatus = 0 + // StatusJobOk 等待调度 + StatusJobOk JobStatus = 1 + // StatusJobTiming 定时中 + StatusJobTiming JobStatus = 2 + // StatusJobRunning 执行中 + StatusJobRunning JobStatus = 3 + // StatusJobStop 已停止 + StatusJobStop JobStatus = 4 +) + +type CrontabJob struct { + gorm.Model + Name string `json:"name" gorm:"index;not null"` + GroupID uint `json:"groupID" grom:"index"` + Command StringSlice `json:"command" gorm:"type:varchar(1000)"` + Code string `json:"code" gorm:"type:TEXT"` + DependJobs DependJobs `json:"dependJobs" gorm:"type:TEXT"` + LastCostTime float64 `json:"lastCostTime"` + LastExecTime time.Time `json:"lastExecTime"` + NextExecTime time.Time `json:"nextExecTime"` + Failed bool `json:"failed"` + LastExitStatus string `json:"lastExitStatus" grom:"index"` + CreatedUserID uint `json:"createdUserId"` + CreatedUsername string `json:"createdUsername"` + UpdatedUserID uint `json:"updatedUserID"` + UpdatedUsername string `json:"updatedUsername"` + WorkUser string `json:"workUser"` + WorkIp StringSlice `json:"workIp" gorm:"type:varchar(1000)"` + WorkEnv StringSlice `json:"workEnv" gorm:"type:varchar(1000)"` + WorkDir string `json:"workDir"` + KillChildProcess bool `json:"killChildProcess"` + Timeout int `json:"timeout"` + ProcessNum int `json:"processNum"` + ErrorMailNotify bool `json:"errorMailNotify"` + ErrorAPINotify bool `json:"errorAPINotify"` + ErrorDingdingNotify bool `json:"errorDingdingNotify"` + RetryNum int `json:"retryNum"` + Status JobStatus `json:"status"` + IsSync bool `json:"isSync"` // 脚本是否同步执行 + MailTo StringSlice `json:"mailTo" gorm:"type:varchar(1000)"` + APITo StringSlice `json:"APITo" gorm:"type:varchar(1000)"` + DingdingTo StringSlice `json:"DingdingTo" gorm:"type:varchar(1000)"` + MaxConcurrent uint `json:"maxConcurrent"` // 脚本最大并发量 + TimeoutTrigger StringSlice `json:"timeoutTrigger" gorm:"type:varchar(20)"` + TimeArgs TimeArgs `json:"timeArgs" gorm:"type:TEXT"` +} + +type StringSlice []string + +func (s *StringSlice) Scan(v interface{}) error { + + switch val := v.(type) { + case string: + return json.Unmarshal([]byte(val), s) + case []byte: + return json.Unmarshal(val, s) + default: + return errors.New("not support") + } +} + +func (s StringSlice) MarshalJSON() ([]byte, error) { + if s == nil { + s = make(StringSlice, 0) + } + return json.Marshal([]string(s)) +} + +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + s = make(StringSlice, 0) + } + bts, err := json.Marshal(s) + return string(bts), err +} + +type DependJobs []DependJob + +func (d *DependJobs) Scan(v interface{}) error { + switch val := v.(type) { + case string: + return json.Unmarshal([]byte(val), d) + case []byte: + return json.Unmarshal(val, d) + default: + return errors.New("not support") + } + +} + +func (d DependJobs) Value() (driver.Value, error) { + + if d == nil { + d = make(DependJobs, 0) + } + + for k, _ := range d { + d[k].ID = util.UUID() + } + + bts, err := json.Marshal(d) + return string(bts), err +} + +func (d DependJobs) MarshalJSON() ([]byte, error) { + if d == nil { + d = make(DependJobs, 0) + } + type m DependJobs + return json.Marshal(m(d)) +} + +type TimeArgs struct { + Weekday string `json:"weekday"` + Month string `json:"month"` + Day string `json:"day"` + Hour string `json:"hour"` + Minute string `json:"minute"` + Second string `json:"second"` +} + +func (c *TimeArgs) Scan(v interface{}) error { + switch val := v.(type) { + case string: + return json.Unmarshal([]byte(val), c) + case []byte: + return json.Unmarshal(val, c) + default: + return errors.New("not support") + } + +} + +func (c TimeArgs) Value() (driver.Value, error) { + bts, err := json.Marshal(c) + return string(bts), err +} + +type DependJob struct { + Dest string `json:"dest"` + From string `json:"from"` + JobID uint `json:"jobID"` + ID string `json:"id"` + WorkUser string `json:"user"` + WorkDir string `json:"workDir"` + Command []string `json:"command"` + Code string `json:"code"` + Timeout int64 `json:"timeout"` +} + +type PipeComamnds [][]string + +func (p *PipeComamnds) Scan(v interface{}) error { + switch val := v.(type) { + case string: + return json.Unmarshal([]byte(val), p) + case []byte: + return json.Unmarshal(val, p) + default: + return errors.New("not support") + } + +} + +func (p PipeComamnds) Value() (driver.Value, error) { + if p == nil { + p = make(PipeComamnds, 0) + } + bts, err := json.Marshal(p) + return string(bts), err +} + +func (d PipeComamnds) MarshalJSON() ([]byte, error) { + if d == nil { + d = make(PipeComamnds, 0) + } + type m PipeComamnds + return json.Marshal(m(d)) +} + +type CrontabArgs struct { + Weekday string + Month string + Day string + Hour string + Minute string +} + +func (c *CrontabArgs) Scan(v interface{}) error { + switch val := v.(type) { + case string: + return json.Unmarshal([]byte(val), c) + case []byte: + return json.Unmarshal(val, c) + default: + return errors.New("not support") + } + +} + +func (c CrontabArgs) Value() (driver.Value, error) { + bts, err := json.Marshal(c) + return string(bts), err +} diff --git a/models/crontab_test.go b/models/crontab_test.go new file mode 100644 index 0000000..ae815bf --- /dev/null +++ b/models/crontab_test.go @@ -0,0 +1,9 @@ +package models + +import ( + "testing" +) + +func TestStringSlice_Value(t *testing.T) { + +} diff --git a/models/daemon.go b/models/daemon.go new file mode 100644 index 0000000..ad05ed8 --- /dev/null +++ b/models/daemon.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type DaemonJob struct { + gorm.Model + Name string `json:"name" gorm:"index;not null"` + GroupID uint `json:"groupID" grom:"index"` + Command StringSlice `json:"command" gorm:"type:varchar(1000)"` + Code string `json:"code" gorm:"type:TEXT"` + ErrorMailNotify bool `json:"errorMailNotify"` + ErrorAPINotify bool `json:"errorAPINotify"` + ErrorDingdingNotify bool `json:"errorDingdingNotify"` + Status JobStatus `json:"status"` + MailTo StringSlice `json:"mailTo" gorm:"type:varchar(1000)"` + APITo StringSlice `json:"APITo" gorm:"type:varchar(1000)"` + DingdingTo StringSlice `json:"DingdingTo" gorm:"type:varchar(1000)"` + FailRestart bool `json:"failRestart"` + RetryNum int `json:"retryNum"` + StartAt time.Time `json:"startAt"` + WorkUser string `json:"workUser"` + WorkIp StringSlice `json:"workIp" gorm:"type:varchar(1000)"` + WorkEnv StringSlice `json:"workEnv" gorm:"type:varchar(1000)"` + WorkDir string `json:"workDir"` + CreatedUserID uint `json:"createdUserId"` + CreatedUsername string `json:"createdUsername"` + UpdatedUserID uint `json:"updatedUserID"` + UpdatedUsername string `json:"updatedUsername"` +} diff --git a/models/db.go b/models/db.go new file mode 100644 index 0000000..a5eba2c --- /dev/null +++ b/models/db.go @@ -0,0 +1,115 @@ +package models + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/iwannay/log" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// D alias DB +type D = gorm.DB + +var ( + db *D + debugMode bool +) + +func CreateDB(dialect string, dsn string) (err error) { + switch dialect { + case "sqlite3": + return createSqlite(dsn) + case "mysql": + db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{ + PrepareStmt: true, + DisableForeignKeyConstraintWhenMigrating: true, + }) + return + case "postgres": + db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + PrepareStmt: true, + DisableForeignKeyConstraintWhenMigrating: true, + }) + return + } + return fmt.Errorf("unknow database type %s", dialect) +} + +func createSqlite(dsn string) error { + var err error + if dsn == "" { + return errors.New("sqlite:db file cannot empty") + } + + dbDir := filepath.Dir(filepath.Clean(dsn)) + err = os.MkdirAll(dbDir, 0755) + if err != nil { + return fmt.Errorf("sqlite: makedir failed %s", err) + } + db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err == nil { + d, err := db.DB() + if err != nil { + panic(err) + } + d.SetMaxOpenConns(1) + } + return err +} + +func DB() *D { + if db == nil { + panic("you must call CreateDb first") + } + if debugMode { + return db.Debug() + } + return db +} + +func Transactions(fn func(tx *gorm.DB) error) error { + if fn == nil { + return errors.New("fn is nil") + } + tx := DB().Begin() + defer func() { + if err := recover(); err != nil { + DB().Rollback() + } + }() + + if fn(tx) != nil { + tx.Rollback() + } + return tx.Commit().Error +} + +func InitModel(driverName string, dsn string, debug bool) error { + if driverName == "" || dsn == "" { + return errors.New("driverName and dsn cannot empty") + } + + if err := CreateDB(driverName, dsn); err != nil { + return err + } + debugMode = debug + AutoMigrate() + return nil +} + +func AutoMigrate() { + if err := DB().AutoMigrate(&SysSetting{}, &Node{}, &Group{}, &User{}, &Event{}, &JobHistory{}); err != nil { + log.Fatal(err) + } + if err := DB().FirstOrCreate(&SuperGroup).Error; err != nil { + log.Fatal(err) + } +} diff --git a/models/event.go b/models/event.go new file mode 100644 index 0000000..9dc6e32 --- /dev/null +++ b/models/event.go @@ -0,0 +1,29 @@ +package models + +import ( + "github.com/iwannay/log" + + "gorm.io/gorm" +) + +type EventSourceName string +type EventSourceUsername string + +type Event struct { + gorm.Model + GroupID uint `json:"groupID" gorm:"index"` + Username string `json:"username"` + UserID uint `json:"userID" gorm:"index"` + EventDesc string `json:"eventDesc"` + TargetName string `json:"targetName"` + SourceUsername string `json:"sourceUsername"` + SourceName string `json:"sourceName" gorm:"index;size:500"` + Content string `json:"content"` +} + +func (e *Event) Pub() { + err := DB().Model(e).Create(e).Error + if err != nil { + log.Error("Event.Pub", err) + } +} diff --git a/models/group.go b/models/group.go new file mode 100644 index 0000000..d19930f --- /dev/null +++ b/models/group.go @@ -0,0 +1,24 @@ +package models + +import ( + "gorm.io/gorm" +) + +var SuperGroup Group + +type Group struct { + gorm.Model + Name string `json:"name" gorm:"not null;uniqueIndex;size:500"` +} + +func (g *Group) Save() error { + if g.ID == 0 { + return DB().Create(g).Error + } + return DB().Save(g).Error +} + +func init() { + SuperGroup.ID = 1 + SuperGroup.Name = "超级管理员" +} diff --git a/models/history.go b/models/history.go new file mode 100644 index 0000000..2dfd79b --- /dev/null +++ b/models/history.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/iwannay/log" + "gorm.io/gorm" +) + +const ( + JobTypeCrontab JobType = 0 + JobTypeDaemon JobType = 1 +) + +type JobType uint8 + +type JobHistory struct { + gorm.Model + JobType JobType `json:"jobType"` // 0:定时任务,1:常驻任务 + JobID uint `json:"jobID"` + JobName string `json:"jobName"` + Addr string `json:"addr" gorm:"index"` + ExitMsg string `json:"exitMsg"` + StartTime time.Time `json:"StartTime"` + EndTime time.Time `json:"endTime"` +} + +func PushJobHistory(job *JobHistory) { + err := DB().Create(job).Error + if err != nil { + log.Error("PushJobHistory failed:", err) + } +} diff --git a/models/node.go b/models/node.go new file mode 100644 index 0000000..b5926b4 --- /dev/null +++ b/models/node.go @@ -0,0 +1,96 @@ +package models + +import ( + "errors" + + "gorm.io/gorm" +) + +type Node struct { + gorm.Model + Name string `json:"name" gorm:"not null"` + DaemonTaskNum uint `json:"daemonTaskNum"` + Disabled bool `json:"disabled"` // 通信失败时Disabled会被设置为true + CrontabTaskNum uint `json:"crontabTaskNum"` + GroupID uint `json:"groupID" gorm:"not null;uniqueIndex:uni_group_addr" ` + CrontabJobAuditNum uint `json:"crontabJobAuditNum"` + DaemonJobAuditNum uint `json:"daemonJobAuditNum"` + CrontabJobFailNum uint `json:"crontabJobFailNum"` + Addr string `json:"addr" gorm:"not null;uniqueIndex:uni_group_addr;size:100"` + Group Group `json:"group"` +} + +func (n *Node) VerifyUserGroup(userID, groupID uint, addr string) bool { + var user User + + if groupID == SuperGroup.ID { + return true + } + + if DB().Take(&user, "id=? and group_id=?", userID, groupID).Error != nil { + return false + } + + return n.Exists(groupID, addr) +} + +func (n *Node) Delete(groupID uint, addr string) error { + var ret *gorm.DB + DB().Take(n, "group_id=? and addr=?", groupID, addr) + if groupID == SuperGroup.ID { + // 超级管理员分组采用软删除 + ret = DB().Delete(n, "group_id=? and addr=?", groupID, addr) + } else { + ret = DB().Unscoped().Delete(n, "group_id=? and addr=?", groupID, addr) + } + + if ret.Error != nil { + return ret.Error + } + + if ret.RowsAffected == 0 { + return errors.New("Delete failed") + } + return nil +} + +func (n *Node) Rename(groupID uint, addr string) error { + return DB().Model(n).Where("group_id=? and addr=?", groupID, addr).Updates(n).Error +} + +// GroupNode 为节点分组,复制groupID=1分组中node至目标分组 +func (n *Node) GroupNode(addr string, targetGroupID uint, targetNodeName, targetGroupName string) error { + + // 新建分组 + if targetGroupID == 0 { + group := &Group{ + Name: targetGroupName, + } + if err := DB().Save(group).Error; err != nil { + return err + } + targetGroupID = group.ID + } + + err := DB().Preload("Group").Where("group_id=? and addr=?", SuperGroup.ID, addr).Take(n).Error + if err != nil { + return err + } + + if targetNodeName == "" { + targetNodeName = n.Name + } + + return DB().Save(&Node{ + Addr: addr, + GroupID: targetGroupID, + Name: targetNodeName, + }).Error +} + +func (n *Node) Exists(groupID uint, addr string) bool { + if DB().Take(n, "group_id=? and addr=?", groupID, addr).Error != nil { + return false + } + return true +} diff --git a/models/setting.go b/models/setting.go new file mode 100644 index 0000000..2928d37 --- /dev/null +++ b/models/setting.go @@ -0,0 +1,13 @@ +package models + +import ( + "encoding/json" + + "gorm.io/gorm" +) + +type SysSetting struct { + gorm.Model + Class int `json:"class"` // 设置分类,1 Ldap配置 + Content json.RawMessage `json:"content" gorm:"column:content; type:json"` // 配置内容 +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..a08878f --- /dev/null +++ b/models/user.go @@ -0,0 +1,116 @@ +package models + +import ( + "crypto/md5" + "errors" + "fmt" + "jiacrontab/pkg/util" + "time" + + "github.com/iwannay/log" + + "gorm.io/gorm" +) + +type User struct { + gorm.Model + Username string `json:"username" gorm:"not null;uniqueIndex;size:500"` + Passwd string `json:"-"` + Salt string `json:"-"` + Avatar string `json:"avatar"` + Version int64 `json:"version"` + Gender string `json:"gender"` + GroupID uint `json:"groupID" grom:"index"` + Root bool `json:"root"` + Mail string `json:"mail"` + Group Group `json:"group"` +} + +func (u *User) getSalt() string { + var ( + seed = "1234567890!@#$%^&*()ABCDEFGHIJK" + salt [10]byte + ) + for k := range salt { + salt[k] = seed[util.RandIntn(len(seed))] + } + + return string(salt[0:10]) +} + +// Verify 验证用户 +func (u *User) Verify(username, passwd string) bool { + ret := DB().Take(u, "username=?", username) + + if ret.Error != nil { + log.Error("user.Verify:", ret.Error) + return false + } + + bts := md5.Sum([]byte(fmt.Sprint(passwd, u.Salt))) + return fmt.Sprintf("%x", bts) == u.Passwd +} + +// Verify 验证用户 +func (u *User) VerifyByUserId(id uint, passwd string) bool { + ret := DB().Take(u, "id=?", id) + + if ret.Error != nil { + log.Error("user.Verify:", ret.Error) + return false + } + + bts := md5.Sum([]byte(fmt.Sprint(passwd, u.Salt))) + return fmt.Sprintf("%x", bts) == u.Passwd +} + +func (u *User) setPasswd() { + if u.Passwd == "" { + return + } + u.Salt = u.getSalt() + bts := md5.Sum([]byte(fmt.Sprint(u.Passwd, u.Salt))) + u.Passwd = fmt.Sprintf("%x", bts) +} + +func (u *User) Create() error { + u.setPasswd() + u.Version = time.Now().Unix() + return DB().Create(u).Error +} + +func (u User) Update() error { + u.setPasswd() + u.Version = time.Now().Unix() + if u.ID == 0 && u.Username != "" { + return DB().Where("username=?", u.Username).Updates(u).Error + } + return DB().Model(&u).Updates(u).Error +} + +func (u *User) Delete() error { + if err := DB().Take(u, "id=?", u.ID).Error; err != nil { + return err + } + return DB().Delete(u).Error +} + +func (u *User) SetGroup(group *Group) error { + + if u.GroupID != 0 { + if err := DB().Take(group, "id=?", u.GroupID).Error; err != nil { + return fmt.Errorf("查询分组失败:%s", err) + } + } + if u.ID == 1 { + return errors.New("系统用户不允许修改") + } + + defer DB().Take(u, "id=?", u.ID) + + return DB().Model(u).Where("id=?", u.ID).Updates(map[string]interface{}{ + "group_id": u.GroupID, + "version": time.Now().Unix(), + "root": u.Root, + }).Error +} diff --git a/pkg/base/stat.go b/pkg/base/stat.go new file mode 100644 index 0000000..2de0b99 --- /dev/null +++ b/pkg/base/stat.go @@ -0,0 +1,278 @@ +package base + +import ( + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + minuteTimeLayout = "200601021504" + dateTimeLayout = "2006-01-02 15:04:05" + defaultReserveMinutes = 60 + defaultCheckTimeMinutes = 10 +) + +// Stat 应用内统计 +var Stat *stat + +type ( + stat struct { + ServerStartTime time.Time + EnableDetailRequestData bool + TotalRequestCount uint64 + + IntervalRequestData *Storage + DetailRequestURLData *Storage + TotalErrorCount uint64 + IntervalErrorData *Storage + DetailErrorPageData *Storage + DetailErrorData *Storage + DetailHTTPCodeData *Storage + + dataChanRequest chan *RequestInfo + dataChanError chan *ErrorInfo + dataChanHTTPCode chan *HttpCodeInfo + TotalConcurrentCount int64 + + infoPool *pool + } + + pool struct { + requestInfo sync.Pool + errorInfo sync.Pool + httpCodeInfo sync.Pool + } + + // RequestInfo 请求url信息 + RequestInfo struct { + URL string + Code int + Num uint64 + } + + ErrorInfo struct { + URL string + ErrMsg string + Num uint64 + } + + HttpCodeInfo struct { + URL string + Code int + Num uint64 + } +) + +func (s *stat) QueryIntervalRequstData(key string) uint64 { + val, _ := s.IntervalRequestData.GetUint64(key) + return val +} + +func (s *stat) QueryIntervalErrorData(key string) uint64 { + val, _ := s.IntervalErrorData.GetUint64(key) + return val +} + +func (s *stat) AddRequestCount(page string, code int, num uint64) uint64 { + + if !strings.HasPrefix(page, "/debug") { + atomic.AddUint64(&s.TotalRequestCount, num) + s.addRequestData(page, code, num) + s.addHTTPCodeData(page, code, num) + } + atomic.AddInt64(&s.TotalConcurrentCount, -1) + return atomic.LoadUint64(&s.TotalRequestCount) +} + +func (s *stat) AddConcurrentCount() { + atomic.AddInt64(&s.TotalConcurrentCount, 1) +} + +func (s *stat) AddErrorCount(page string, err error, num uint64) uint64 { + atomic.AddUint64(&s.TotalErrorCount, num) + s.addErrorData(page, err, num) + return atomic.LoadUint64(&s.TotalErrorCount) +} + +func (s *stat) addRequestData(page string, code int, num uint64) { + info := s.infoPool.requestInfo.Get().(*RequestInfo) + info.URL = page + info.Code = code + info.Num = num + s.dataChanRequest <- info +} + +func (s *stat) addErrorData(page string, err error, num uint64) { + info := s.infoPool.errorInfo.Get().(*ErrorInfo) + info.URL = page + info.ErrMsg = err.Error() + info.Num = num + s.dataChanError <- info +} + +func (s *stat) addHTTPCodeData(page string, code int, num uint64) { + info := s.infoPool.httpCodeInfo.Get().(*HttpCodeInfo) + info.URL = page + info.Code = code + info.Num = num + s.dataChanHTTPCode <- info +} + +func (s *stat) handleInfo() { + for { + select { + case info := <-s.dataChanRequest: + { + if s.EnableDetailRequestData { + if info.Code != http.StatusNotFound { + key := strings.ToLower(info.URL) + val, _ := s.DetailRequestURLData.GetUint64(key) + s.DetailRequestURLData.Store(key, val+info.Num) + } + } + + key := time.Now().Format(minuteTimeLayout) + val, _ := s.IntervalRequestData.GetUint64(key) + s.IntervalRequestData.Store(key, val+info.Num) + + s.infoPool.requestInfo.Put(info) + } + case info := <-s.dataChanError: + { + key := strings.ToLower(info.URL) + val, _ := s.DetailErrorPageData.GetUint64(key) + s.DetailErrorPageData.Store(key, val+info.Num) + + key = info.ErrMsg + + val, _ = s.DetailErrorData.GetUint64(key) + + s.DetailErrorData.Store(key, val+info.Num) + + key = time.Now().Format(minuteTimeLayout) + val, _ = s.IntervalErrorData.GetUint64(key) + s.IntervalErrorData.Store(key, val+info.Num) + + s.infoPool.errorInfo.Put(info) + + } + + case info := <-s.dataChanHTTPCode: + { + key := strconv.Itoa(info.Code) + val, _ := s.DetailHTTPCodeData.GetUint64(key) + s.DetailHTTPCodeData.Store(key, val+info.Num) + + s.infoPool.httpCodeInfo.Put(info) + } + } + } +} + +func (s *stat) Collect() map[string]interface{} { + var dataMap = make(map[string]interface{}) + dataMap["ServerStartTime"] = s.ServerStartTime.Format(dateTimeLayout) + dataMap["TotalRequestCount"] = atomic.LoadUint64(&s.TotalRequestCount) + dataMap["TotalConcurrentCount"] = atomic.LoadInt64(&s.TotalConcurrentCount) + dataMap["TotalErrorCount"] = s.TotalErrorCount + dataMap["IntervalRequestData"] = s.IntervalRequestData.All() + dataMap["DetailRequestUrlData"] = s.DetailRequestURLData.All() + dataMap["IntervalErrorData"] = s.IntervalErrorData.All() + dataMap["DetailErrorPageData"] = s.DetailErrorPageData.All() + dataMap["DetailErrorData"] = s.DetailErrorData.All() + dataMap["DetailHttpCodeData"] = s.DetailHTTPCodeData.All() + return dataMap +} + +func (s *stat) gc() { + var needRemoveKey []string + now, _ := time.Parse(minuteTimeLayout, time.Now().Format(minuteTimeLayout)) + + if s.IntervalRequestData.Len() > defaultReserveMinutes { + s.IntervalRequestData.Range(func(key, val interface{}) bool { + keyString := key.(string) + if t, err := time.Parse(minuteTimeLayout, keyString); err != nil { + needRemoveKey = append(needRemoveKey, keyString) + } else { + if now.Sub(t) > (defaultReserveMinutes * time.Minute) { + needRemoveKey = append(needRemoveKey, keyString) + } + } + return true + }) + } + + for _, v := range needRemoveKey { + s.IntervalRequestData.Delete(v) + } + + needRemoveKey = []string{} + if s.IntervalErrorData.Len() > defaultReserveMinutes { + s.IntervalErrorData.Range(func(key, val interface{}) bool { + keyString := key.(string) + if t, err := time.Parse(minuteTimeLayout, keyString); err != nil { + needRemoveKey = append(needRemoveKey, keyString) + } else { + if now.Sub(t) > defaultReserveMinutes*time.Minute { + needRemoveKey = append(needRemoveKey, keyString) + } + } + return true + }) + + } + + for _, v := range needRemoveKey { + s.IntervalErrorData.Delete(v) + } + + time.AfterFunc(time.Duration(defaultCheckTimeMinutes)*time.Minute, s.gc) + +} + +func init() { + Stat = &stat{ + // 服务启动时间 + ServerStartTime: time.Now(), + // 单位时间内请求数据 - 分钟为单位 + IntervalRequestData: NewStorage(), + // 明细请求页面数据 - 以不带参数的访问url为key + DetailRequestURLData: NewStorage(), + // 单位时间内异常次数 - 按分钟为单位 + IntervalErrorData: NewStorage(), + // 明细异常页面数据 - 以不带参数的访问url为key + DetailErrorPageData: NewStorage(), + // 单位时间内异常次数 - 按分钟为单位 + DetailErrorData: NewStorage(), + // 明细Http状态码数据 - 以HttpCode为key,例如200、500等 + DetailHTTPCodeData: NewStorage(), + dataChanRequest: make(chan *RequestInfo, 1000), + dataChanError: make(chan *ErrorInfo, 1000), + dataChanHTTPCode: make(chan *HttpCodeInfo, 1000), + EnableDetailRequestData: true, //是否启用详细请求数据统计, 当url较多时,导致内存占用过大 + infoPool: &pool{ + requestInfo: sync.Pool{ + New: func() interface{} { + return &RequestInfo{} + }, + }, + errorInfo: sync.Pool{ + New: func() interface{} { + return &ErrorInfo{} + }, + }, + httpCodeInfo: sync.Pool{ + New: func() interface{} { + return &HttpCodeInfo{} + }, + }, + }, + } + + go Stat.handleInfo() + go time.AfterFunc(time.Duration(defaultCheckTimeMinutes)*time.Minute, Stat.gc) +} diff --git a/pkg/base/storage.go b/pkg/base/storage.go new file mode 100644 index 0000000..10d58c7 --- /dev/null +++ b/pkg/base/storage.go @@ -0,0 +1,53 @@ +package base + +import ( + "sync" +) + +type ( + Storage struct { + sync.Map + } +) + +func NewStorage() *Storage { + return &Storage{} +} + +func (s *Storage) All() map[string]interface{} { + data := make(map[string]interface{}) + s.Range(func(key, value interface{}) bool { + + data[key.(string)] = value + return true + }) + return data +} + +func (s *Storage) Exists(key interface{}) bool { + + _, ok := s.Load(key) + + return ok +} + +func (s *Storage) GetUint64(key interface{}) (uint64, bool) { + val, ok := s.Load(key) + if !ok { + return 0, false + } + + ret, ok := val.(uint64) + return ret, ok +} + +func (s *Storage) Len() uint { + var count uint + s.Range(func(key, val interface{}) bool { + count++ + return true + }) + + return count + +} diff --git a/pkg/crontab/crontab.go b/pkg/crontab/crontab.go new file mode 100644 index 0000000..fa7ba84 --- /dev/null +++ b/pkg/crontab/crontab.go @@ -0,0 +1,88 @@ +package crontab + +import ( + "container/heap" + "errors" + "jiacrontab/pkg/pqueue" + "sync" + "time" +) + +// Task 任务 +type Task = pqueue.Item + +type Crontab struct { + pq pqueue.PriorityQueue + mux sync.RWMutex + ready chan *Task +} + +func New() *Crontab { + return &Crontab{ + pq: pqueue.New(10000), + ready: make(chan *Task, 10000), + } +} + +// AddJob 添加未经处理的job +func (c *Crontab) AddJob(j *Job) error { + nt, err := j.NextExecutionTime(time.Now()) + if err != nil { + return errors.New("Invalid execution time") + } + c.mux.Lock() + heap.Push(&c.pq, &Task{ + Priority: nt.UnixNano(), + Value: j, + }) + c.mux.Unlock() + return nil +} + +// AddJob 添加延时任务 +func (c *Crontab) AddTask(t *Task) { + c.mux.Lock() + heap.Push(&c.pq, t) + c.mux.Unlock() +} + +func (c *Crontab) Len() int { + c.mux.RLock() + len := len(c.pq) + c.mux.RUnlock() + return len +} + +func (c *Crontab) GetAllTask() []*Task { + c.mux.Lock() + list := c.pq + c.mux.Unlock() + return list +} + +func (c *Crontab) Ready() <-chan *Task { + return c.ready +} + +func (c *Crontab) QueueScanWorker() { + refreshTicker := time.NewTicker(20 * time.Millisecond) + for { + select { + case <-refreshTicker.C: + if len(c.pq) == 0 { + continue + } + start: + c.mux.Lock() + now := time.Now().UnixNano() + job, _ := c.pq.PeekAndShift(now) + c.mux.Unlock() + if job == nil { + continue + } + c.ready <- job + goto start + + } + } +} diff --git a/pkg/crontab/crontab_test.go b/pkg/crontab/crontab_test.go new file mode 100644 index 0000000..a977c5e --- /dev/null +++ b/pkg/crontab/crontab_test.go @@ -0,0 +1,57 @@ +package crontab + +import ( + "encoding/json" + "fmt" + "testing" + "time" +) + +func Test_crontab_Ready(t *testing.T) { + var timeLayout = "2006-01-02 15:04:05" + c := New() + now := time.Now().Add(6 * time.Second) + c.AddTask(&Task{ + Value: "test1" + now.Format(timeLayout), + Priority: now.UnixNano(), + }) + + now = time.Now().Add(1 * time.Second) + + c.AddTask(&Task{ + Value: "test2" + now.Format(timeLayout), + Priority: now.UnixNano(), + }) + now = time.Now().Add(3 * time.Second) + + c.AddTask(&Task{ + Value: "test3" + now.Format(timeLayout), + Priority: now.UnixNano(), + }) + + now = time.Now().Add(4 * time.Second) + c.AddTask(&Task{ + Value: "test4" + now.Format(timeLayout), + Priority: now.UnixNano(), + }) + + now = time.Now().Add(3 * time.Second) + c.AddTask(&Task{ + Value: "test5" + now.Format(timeLayout), + Priority: now.UnixNano(), + }) + + bts, _ := json.MarshalIndent(c.GetAllTask(), "", "") + fmt.Println(string(bts)) + + go c.QueueScanWorker() + + go func() { + for v := range c.Ready() { + bts, _ := json.MarshalIndent(v, "", "") + fmt.Println(string(bts)) + } + }() + + time.Sleep(10 * time.Second) +} diff --git a/pkg/crontab/job.go b/pkg/crontab/job.go new file mode 100644 index 0000000..bcb4bfd --- /dev/null +++ b/pkg/crontab/job.go @@ -0,0 +1,213 @@ +// package crontab 实现定时调度 +// 借鉴https://github.com/robfig/cron +// 部分实现添加注释 +// 向https://github.com/robfig/cron项目致敬 +package crontab + +import ( + "errors" + "fmt" + "jiacrontab/pkg/util" + "time" +) + +const ( + starBit = 1 << 63 +) + +type bounds struct { + min, max uint + names map[string]uint +} + +// The bounds for each field. +var ( + seconds = bounds{0, 59, nil} + minutes = bounds{0, 59, nil} + hours = bounds{0, 23, nil} + dom = bounds{1, 31, nil} + months = bounds{1, 12, map[string]uint{ + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + }} + dow = bounds{0, 6, map[string]uint{ + "sun": 0, + "mon": 1, + "tue": 2, + "wed": 3, + "thu": 4, + "fri": 5, + "sat": 6, + }} +) + +type Job struct { + Second string + Minute string + Hour string + Day string + Weekday string + Month string + + ID uint + now time.Time + lastExecutionTime time.Time + nextExecutionTime time.Time + + second, minute, hour, dom, month, dow uint64 + + Value interface{} +} + +func (j *Job) Format() string { + return fmt.Sprintf("second: %s minute: %s hour: %s day: %s weekday: %s month: %s", + j.Second, j.Minute, j.Hour, j.Day, j.Weekday, j.Month) +} +func (j *Job) GetNextExecTime() time.Time { + return j.nextExecutionTime +} + +func (j *Job) GetLastExecTime() time.Time { + return j.lastExecutionTime +} + +// parse 解析定时规则 +// 根据规则生成符和条件的日期 +// 例如:*/2 如果位于分位,则生成0,2,4,6....58 +// 生成的日期逐条的被映射到uint64数值中 +// min |= 1<<2 +func (j *Job) parse() error { + var err error + field := func(field string, r bounds) uint64 { + if err != nil { + return 0 + } + var bits uint64 + bits, err = getField(field, r) + return bits + } + j.second = field(j.Second, seconds) + j.minute = field(j.Minute, minutes) + j.hour = field(j.Hour, hours) + j.dom = field(j.Day, dom) + j.month = field(j.Month, months) + j.dow = field(j.Weekday, dow) + + return err + +} + +// NextExecTime 获得下次执行时间 +func (j *Job) NextExecutionTime(t time.Time) (time.Time, error) { + if err := j.parse(); err != nil { + return time.Time{}, err + } + + t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond) + added := false + defer func() { + j.lastExecutionTime, j.nextExecutionTime = j.nextExecutionTime, t + }() + + // 设置最大调度周期为5年 + yearLimit := t.Year() + 5 + +WRAP: + if t.Year() > yearLimit { + return time.Time{}, errors.New("Over 5 years") + } + + for 1< 0 + dowMatch bool = 1< 0 + ) + + if j.dom&starBit > 0 || j.dow&starBit > 0 { + return domMatch && dowMatch + } + return domMatch || dowMatch +} diff --git a/pkg/crontab/job_test.go b/pkg/crontab/job_test.go new file mode 100644 index 0000000..8debfc1 --- /dev/null +++ b/pkg/crontab/job_test.go @@ -0,0 +1,70 @@ +package crontab + +import ( + "jiacrontab/pkg/test" + "testing" + "time" +) + +func TestJob_NextExecutionTime(t *testing.T) { + timeLayout := "2006-01-02 15:04:05" + j := &Job{ + Second: "48", + Minute: "3", + Hour: "12", + Day: "25", + Weekday: "*", + Month: "1", + } + + tt, err := j.NextExecutionTime(time.Now()) + test.Nil(t, err) + test.Equal(t, "2020-01-25 12:03:48", tt.Format(timeLayout)) + + tt, err = j.NextExecutionTime(tt) + test.Nil(t, err) + test.Equal(t, "2021-01-25 12:03:48", tt.Format(timeLayout)) + + tt, err = j.NextExecutionTime(tt) + test.Equal(t, "2022-01-25 12:03:48", tt.Format(timeLayout)) + + j = &Job{ + Second: "58", + Minute: "*/4", + Hour: "12", + Day: "4", + Weekday: "*", + Month: "3", + } + tt, err = j.NextExecutionTime(time.Now()) + test.Nil(t, err) + test.Equal(t, "2020-03-04 12:00:58", tt.Format(timeLayout)) + + tt, err = j.NextExecutionTime(tt) + test.Nil(t, err) + test.Equal(t, "2020-03-04 12:04:58", tt.Format(timeLayout)) + + tt, err = j.NextExecutionTime(tt) + test.Nil(t, err) + test.Equal(t, "2020-03-04 12:08:58", tt.Format(timeLayout)) + + j = &Job{ + Second: "0", + Minute: "*", + Hour: "*", + Day: "*", + Weekday: "*", + Month: "*", + } + + tt, err = j.NextExecutionTime(time.Now()) + test.Nil(t, err) + t.Log(tt, j.GetLastExecTime()) + for i := 0; i < 1000; i++ { + tt, err = j.NextExecutionTime(tt) + test.Nil(t, err) + t.Log(tt, j.GetLastExecTime()) + } + + t.Log("end") +} diff --git a/pkg/crontab/parse.go b/pkg/crontab/parse.go new file mode 100644 index 0000000..a22e26d --- /dev/null +++ b/pkg/crontab/parse.go @@ -0,0 +1,128 @@ +package crontab + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +func getRange(expr string, r bounds) (uint64, error) { + var ( + start, end, step uint + rangeAndStep = strings.Split(expr, "/") + lowAndHigh = strings.Split(rangeAndStep[0], "-") + singleDigit = len(lowAndHigh) == 1 + err error + ) + + var extra uint64 + if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" { + start = r.min + end = r.max + extra = starBit + } else { + if lowAndHigh[0] == "L" { + return 0, nil + } + start, err = parseIntOrName(lowAndHigh[0], r.names) + if err != nil { + return 0, err + } + + switch len(lowAndHigh) { + case 1: + end = start + case 2: + end, err = parseIntOrName(lowAndHigh[1], r.names) + if err != nil { + return 0, err + } + default: + return 0, fmt.Errorf("Too many hyphens: %s", expr) + } + } + + switch len(rangeAndStep) { + case 1: + step = 1 + case 2: + step, err = mustParseInt(rangeAndStep[1]) + if err != nil { + return 0, err + } + + // Special handling: "N/step" means "N-max/step". + if singleDigit { + end = r.max + } + default: + return 0, fmt.Errorf("Too many slashes: %s", expr) + } + + if start < r.min { + return 0, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr) + } + if end > r.max { + return 0, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr) + } + if start > end { + return 0, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr) + } + if step == 0 { + return 0, fmt.Errorf("Step of range should be a positive number: %s", expr) + } + + return getBits(start, end, step) | extra, nil +} + +func parseIntOrName(expr string, names map[string]uint) (uint, error) { + if names != nil { + if namedInt, ok := names[strings.ToLower(expr)]; ok { + return namedInt, nil + } + } + return mustParseInt(expr) +} + +// mustParseInt parses the given expression as an int or returns an error. +func mustParseInt(expr string) (uint, error) { + num, err := strconv.Atoi(expr) + if err != nil { + return 0, fmt.Errorf("Failed to parse int from %s: %s", expr, err) + } + if num < 0 { + return 0, fmt.Errorf("Negative number (%d) not allowed: %s", num, expr) + } + + return uint(num), nil +} + +// getBits sets all bits in the range [min, max], modulo the given step size. +func getBits(min, max, step uint) uint64 { + var bits uint64 + + // If step is 1, use shifts. + if step == 1 { + return ^(math.MaxUint64 << (max + 1)) & (math.MaxUint64 << min) + } + + // Else, use a simple loop. + for i := min; i <= max; i += step { + bits |= 1 << i + } + return bits +} + +func getField(field string, r bounds) (uint64, error) { + var bits uint64 + ranges := strings.FieldsFunc(field, func(r rune) bool { return r == ',' }) + for _, expr := range ranges { + bit, err := getRange(expr, r) + if err != nil { + return bits, err + } + bits |= bit + } + return bits, nil +} diff --git a/pkg/file/file.go b/pkg/file/file.go new file mode 100644 index 0000000..f7913ea --- /dev/null +++ b/pkg/file/file.go @@ -0,0 +1,134 @@ +package file + +import ( + "errors" + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/iwannay/log" +) + +func Exist(path string) bool { + _, err := os.Stat(path) + return err == nil || os.IsExist(err) +} + +func GetCurrentDirectory() string { + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + log.Error(err) + return "" + } + return filepath.Clean(strings.Replace(dir, "\\", "/", -1)) + +} + +func IsTextFile(data []byte) bool { + if len(data) == 0 { + return true + } + return strings.Contains(http.DetectContentType(data), "text/") +} +func IsImageFile(data []byte) bool { + return strings.Contains(http.DetectContentType(data), "image/") +} +func IsPDFFile(data []byte) bool { + return strings.Contains(http.DetectContentType(data), "application/pdf") +} +func IsVideoFile(data []byte) bool { + return strings.Contains(http.DetectContentType(data), "video/") +} + +const ( + Byte = 1 + KByte = Byte * 1024 + MByte = KByte * 1024 + GByte = MByte * 1024 + TByte = GByte * 1024 + PByte = TByte * 1024 + EByte = PByte * 1024 +) + +var bytesSizeTable = map[string]uint64{ + "b": Byte, + "kb": KByte, + "mb": MByte, + "gb": GByte, + "tb": TByte, + "pb": PByte, + "eb": EByte, +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} +func humanateBytes(s uint64, base float64, sizes []string) string { + if s < 10 { + return fmt.Sprintf("%d B", s) + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := float64(s) / math.Pow(base, math.Floor(e)) + f := "%.0f" + if val < 10 { + f = "%.1f" + } + return fmt.Sprintf(f+" %s", val, suffix) +} +func FileSize(s int64) string { + sizes := []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} + return humanateBytes(uint64(s), 1024, sizes) +} + +func CreateFile(path string) (*os.File, error) { + err := os.MkdirAll(filepath.Dir(path), os.ModePerm) + if err != nil { + return nil, err + } + return os.Create(path) +} + +func DirSize(dir string) int64 { + var total int64 + filepath.Walk(dir, func(fpath string, info os.FileInfo, err error) error { + if info == nil { + return nil + } + if !info.IsDir() { + total += info.Size() + } + return nil + }) + return total +} + +func Remove(dir string, t time.Time) (total int64, size int64, err error) { + err = filepath.Walk(dir, func(fpath string, info os.FileInfo, err error) error { + if info == nil { + return errors.New(fpath + " not exists") + } + if !info.IsDir() { + if info.ModTime().Before(t) { + total++ + size += info.Size() + err = os.Remove(fpath) + } + } else { + // 删除空目录 + err = os.Remove(fpath) + if err == nil { + total++ + log.Println("delete ", fpath) + } + err = nil + } + + return err + }) + return +} diff --git a/pkg/finder/finder.go b/pkg/finder/finder.go new file mode 100644 index 0000000..bb45f1e --- /dev/null +++ b/pkg/finder/finder.go @@ -0,0 +1,220 @@ +package finder + +import ( + "bufio" + "bytes" + "errors" + "jiacrontab/pkg/file" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "time" +) + +type matchDataChunk struct { + modifyTime time.Time + matchData []byte +} + +type DataQueue []matchDataChunk + +func (d DataQueue) Swap(i, j int) { + d[i], d[j] = d[j], d[i] +} +func (d DataQueue) Less(i, j int) bool { + return d[i].modifyTime.Unix() < d[j].modifyTime.Unix() +} +func (d DataQueue) Len() int { + return len(d) +} + +type Finder struct { + matchDataQueue DataQueue + curr int32 + regexp *regexp.Regexp + pagesize int + errors []error + patternAll bool + filter func(os.FileInfo) bool + isTail bool + offset int64 + fileSize int64 +} + +func NewFinder(filter func(os.FileInfo) bool) *Finder { + return &Finder{ + filter: filter, + } +} + +func (fd *Finder) SetTail(flag bool) { + fd.isTail = flag +} + +func (fd *Finder) Offset() int64 { + return fd.offset +} + +func (fd *Finder) HumanateFileSize() string { + return file.FileSize(fd.fileSize) +} + +func (fd *Finder) FileSize() int64 { + return fd.fileSize +} + +func (fd *Finder) find(fpath string, modifyTime time.Time) error { + + var matchData []byte + var reader *bufio.Reader + + f, err := os.Open(fpath) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + fd.fileSize = info.Size() + + if fd.fileSize < fd.offset { + return errors.New("out of file") + } + + if fd.isTail { + if fd.offset < 0 { + fd.offset = fd.fileSize + } + f.Seek(fd.offset, 0) + reader = bufio.NewReader(NewTailReader(f, fd.offset)) + } else { + f.Seek(fd.offset, 0) + reader = bufio.NewReader(f) + } + + for { + + bts, _ := reader.ReadBytes('\n') + + if len(bts) == 0 { + break + } + + if fd.isTail { + fd.offset -= int64(len(bts)) + } else { + fd.offset += int64(len(bts)) + } + + if fd.isTail { + if fd.offset == 0 { + bts = append(bts, '\n') + } + invert(bts) + } + + if fd.patternAll || fd.regexp.Match(bts) { + matchData = append(matchData, bts...) + fd.curr++ + } + + if fd.curr >= int32(fd.pagesize) { + break + } + + if fd.offset <= 0 { + break + } + + } + + if len(matchData) > 0 { + fd.matchDataQueue = append(fd.matchDataQueue, matchDataChunk{ + modifyTime: modifyTime, + matchData: bytes.TrimLeft(bytes.TrimRight(matchData, "\n"), "\n"), + }) + } + return nil +} + +func (fd *Finder) walkFunc(fpath string, info os.FileInfo, err error) error { + if !info.IsDir() { + if fd.filter != nil && fd.filter(info) { + err := fd.find(fpath, info.ModTime()) + if err != nil { + fd.errors = append(fd.errors, err) + } + } + + } + + return nil +} + +func (fd *Finder) Search(root string, expr string, data *[]byte, offset int64, pagesize int) error { + var err error + fd.pagesize = pagesize + fd.offset = offset + + if expr == "" { + fd.patternAll = true + } + + if !file.Exist(root) { + return errors.New(root + " not exist") + } + + fd.regexp, err = regexp.Compile(expr) + if err != nil { + return err + } + filepath.Walk(root, fd.walkFunc) + + sort.Stable(fd.matchDataQueue) + for _, v := range fd.matchDataQueue { + *data = append(*data, v.matchData...) + } + return nil +} + +func (fd *Finder) GetErrors() []error { + return fd.errors +} + +func SearchAndDeleteFileOnDisk(dir string, d time.Duration, size int64) { + t := time.NewTicker(1 * time.Minute) + for { + select { + case <-t.C: + filepath.Walk(dir, func(fpath string, info os.FileInfo, err error) error { + if info == nil { + return errors.New(fpath + "not exists") + } + if !info.IsDir() { + if time.Now().Sub(info.ModTime()) > d { + err = os.Remove(fpath) + + } + + if info.Size() > size && size != 0 { + err = os.Remove(fpath) + + } + } else { + // 删除空目录 + err := os.Remove(fpath) + if err == nil { + log.Println("delete ", fpath) + } + } + return err + }) + } + } +} diff --git a/pkg/finder/reader.go b/pkg/finder/reader.go new file mode 100644 index 0000000..65eb24f --- /dev/null +++ b/pkg/finder/reader.go @@ -0,0 +1,53 @@ +package finder + +import ( + "io" + "os" +) + +type TailReader struct { + f *os.File + curr int64 + isEOF bool +} + +func (t *TailReader) Read(b []byte) (n int, err error) { + if t.isEOF { + return 0, io.EOF + } + + off := t.curr - int64(len(b)) + if off < 0 { + off = 0 + n, err = t.f.ReadAt(b[0:t.curr], off) + } else { + t.curr = off + n, err = t.f.ReadAt(b, off) + } + + if err != nil && err != io.EOF { + return n, err + } + + invert(b[0:n]) + + if off == 0 { + t.isEOF = true + } + + return +} + +func NewTailReader(f *os.File, offset int64) io.Reader { + return &TailReader{ + f: f, + curr: offset, + } + +} + +func invert(b []byte) { + for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 { + b[i], b[j] = b[j], b[i] + } +} diff --git a/pkg/kproc/proc.go b/pkg/kproc/proc.go new file mode 100644 index 0000000..4acd60f --- /dev/null +++ b/pkg/kproc/proc.go @@ -0,0 +1,38 @@ +package kproc + +import ( + "context" + "jiacrontab/pkg/file" + "os/exec" +) + +type KCmd struct { + ctx context.Context + *exec.Cmd + isKillChildProcess bool + done chan struct{} +} + +// SetEnv 设置环境变量 +func (k *KCmd) SetEnv(env []string) { + if len(env) == 0 { + return + } + k.Cmd.Env = env +} + +// SetDir 设置工作目录 +func (k *KCmd) SetDir(dir string) { + if dir == "" { + return + } + if file.Exist(dir) == false { + return + } + k.Cmd.Dir = dir +} + +// SetExitKillChildProcess 设置主进程退出时是否kill子进程,默认kill +func (k *KCmd) SetExitKillChildProcess(ok bool) { + k.isKillChildProcess = ok +} diff --git a/pkg/kproc/proc_posix.go b/pkg/kproc/proc_posix.go new file mode 100644 index 0000000..fb2e0f0 --- /dev/null +++ b/pkg/kproc/proc_posix.go @@ -0,0 +1,78 @@ +// +build !windows + +package kproc + +import ( + "context" + "os" + "os/exec" + "os/user" + "strconv" + "syscall" + + "github.com/iwannay/log" +) + +func CommandContext(ctx context.Context, name string, arg ...string) *KCmd { + cmd := exec.CommandContext(ctx, name, arg...) + cmd.SysProcAttr = &syscall.SysProcAttr{} + cmd.SysProcAttr.Setsid = true + return &KCmd{ + ctx: ctx, + Cmd: cmd, + isKillChildProcess: true, + done: make(chan struct{}), + } +} + +// SetUser 设置执行用户要保证root权限 +func (k *KCmd) SetUser(username string) { + if username == "" { + return + } + u, err := user.Lookup(username) + if err != nil { + log.Error("setUser error:", err) + return + } + + log.Infof("KCmd set uid=%s,gid=%s", u.Uid, u.Gid) + k.SysProcAttr = &syscall.SysProcAttr{} + uid, _ := strconv.Atoi(u.Uid) + gid, _ := strconv.Atoi(u.Gid) + k.SysProcAttr.Credential = &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)} + +} + +func (k *KCmd) KillAll() { + + select { + case k.done <- struct{}{}: + default: + } + + if k.Process == nil { + return + } + + if k.isKillChildProcess == false { + return + } + + group, err := os.FindProcess(-k.Process.Pid) + if err == nil { + group.Signal(syscall.SIGKILL) + } +} + +func (k *KCmd) Wait() error { + defer k.KillAll() + go func() { + select { + case <-k.ctx.Done(): + k.KillAll() + case <-k.done: + } + }() + return k.Cmd.Wait() +} diff --git a/pkg/kproc/proc_windows.go b/pkg/kproc/proc_windows.go new file mode 100644 index 0000000..0da8bd8 --- /dev/null +++ b/pkg/kproc/proc_windows.go @@ -0,0 +1,51 @@ +package kproc + +import ( + "context" + "fmt" + "os/exec" +) + +func CommandContext(ctx context.Context, name string, arg ...string) *KCmd { + cmd := exec.CommandContext(ctx, name, arg...) + return &KCmd{ + Cmd: cmd, + ctx: ctx, + isKillChildProcess: true, + done: make(chan struct{}), + } +} + +func (k *KCmd) SetUser(username string) { + // TODO:windows切换用户 +} + +func (k *KCmd) KillAll() { + select { + case k.done <- struct{}{}: + default: + } + if k.Process == nil { + return + } + + if k.isKillChildProcess == false { + return + } + + c := exec.Command("taskkill", "/t", "/f", "/pid", fmt.Sprint(k.Process.Pid)) + c.Stdout = k.Cmd.Stdout + c.Stderr = k.Cmd.Stderr +} + +func (k *KCmd) Wait() error { + defer k.KillAll() + go func() { + select { + case <-k.ctx.Done(): + k.KillAll() + case <-k.done: + } + }() + return k.Cmd.Wait() +} diff --git a/pkg/mailer/login.go b/pkg/mailer/login.go new file mode 100644 index 0000000..99bef22 --- /dev/null +++ b/pkg/mailer/login.go @@ -0,0 +1,31 @@ +package mailer + +import ( + "fmt" + "net/smtp" +) + +type loginAuth struct { + username, password string +} + +// SMTP AUTH LOGIN Auth Handler +func LoginAuth(username, password string) smtp.Auth { + return &loginAuth{username, password} +} +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, fmt.Errorf("unknwon fromServer: %s", string(fromServer)) + } + } + return nil, nil +} diff --git a/pkg/mailer/mail.go b/pkg/mailer/mail.go new file mode 100644 index 0000000..549b398 --- /dev/null +++ b/pkg/mailer/mail.go @@ -0,0 +1,204 @@ +package mailer + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/smtp" + "os" + "strings" + "time" + + "github.com/iwannay/log" + + "gopkg.in/gomail.v2" +) + +var ( + MailConfig *Mailer + mailQueue chan *Message +) + +type Mailer struct { + QueueLength int + SubjectPrefix string + Host string + From string + FromEmail string + User, Passwd string + DisableHelo bool + HeloHostname string + SkipVerify bool + UseCertificate bool + CertFile, KeyFile string + UsePlainText bool + HookMode bool +} + +type Message struct { + *gomail.Message + Info string + confirmChan chan struct{} +} + +func NewMessage(to []string, subject, htmlBody string) *Message { + return NewMessageFrom(to, MailConfig.From, subject, htmlBody) +} + +func NewMessageFrom(to []string, from, subject, htmlBody string) *Message { + log.Printf("QueueLength (%d) NewMessage (htmlBody) \n%s\n", len(mailQueue), htmlBody) + msg := gomail.NewMessage() + msg.SetHeader("From", from) + msg.SetHeader("To", to...) + msg.SetHeader("Subject", subject) + msg.SetDateHeader("Date", time.Now()) + contentType := "text/html" + + msg.SetBody(contentType, htmlBody) + return &Message{ + Message: msg, + confirmChan: make(chan struct{}), + } +} + +type Sender struct { +} + +func (s *Sender) Send(from string, to []string, msg io.WriterTo) error { + host, port, err := net.SplitHostPort(MailConfig.Host) + if err != nil { + return err + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: MailConfig.SkipVerify, + ServerName: host, + } + if MailConfig.UseCertificate { + cert, err := tls.LoadX509KeyPair(MailConfig.CertFile, MailConfig.KeyFile) + if err != nil { + return err + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, port), 3*time.Second) + if err != nil { + return err + } + defer conn.Close() + isSecureConn := false + if strings.HasSuffix(port, "465") { + conn = tls.Client(conn, tlsConfig) + isSecureConn = true + } + client, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("NewClient: %v", err) + } + + if MailConfig.DisableHelo { + hostname := MailConfig.HeloHostname + if len(hostname) == 0 { + hostname, err = os.Hostname() + if err != nil { + return err + } + } + + if err = client.Hello(hostname); err != nil { + return fmt.Errorf("Hello:%v", err) + } + } + + hasStartTLS, _ := client.Extension("STARTTLS") + if !isSecureConn && hasStartTLS { + if err = client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("StartTLS:%v", err) + } + } + + canAuth, options := client.Extension("AUTH") + + if canAuth && len(MailConfig.User) > 0 { + var auth smtp.Auth + if strings.Contains(options, "CRAM-MD5") { + auth = smtp.CRAMMD5Auth(MailConfig.User, MailConfig.Passwd) + } else if strings.Contains(options, "PLAIN") { + auth = smtp.PlainAuth("", MailConfig.User, MailConfig.Passwd, host) + } else if strings.Contains(options, "LOGIN") { + // Patch for AUTH LOGIN + auth = LoginAuth(MailConfig.User, MailConfig.Passwd) + } + if auth != nil { + if err = client.Auth(auth); err != nil { + return fmt.Errorf("Auth: %v", err) + } + } + } + + if err = client.Mail(from); err != nil { + return fmt.Errorf("Mail: %v", err) + } + for _, rec := range to { + if err = client.Rcpt(rec); err != nil { + return fmt.Errorf("Rcpt: %v", err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("Data: %v", err) + } else if _, err = msg.WriteTo(w); err != nil { + return fmt.Errorf("WriteTo: %v", err) + } else if err = w.Close(); err != nil { + return fmt.Errorf("Close: %v", err) + } + return client.Quit() +} + +func processMailQueue() { + sender := &Sender{} + for { + select { + case msg := <-mailQueue: + if err := gomail.Send(sender, msg.Message); err != nil { + log.Errorf("Fail to send emails %s: %s - %v\n", msg.GetHeader("To"), msg.Info, err) + } else { + log.Infof("E-mails sent %s: %s\n", msg.GetHeader("To"), msg.Info) + } + msg.confirmChan <- struct{}{} + } + } +} + +func InitMailer(m *Mailer) { + MailConfig = m + if MailConfig == nil || mailQueue != nil { + return + } + + mailQueue = make(chan *Message, MailConfig.QueueLength) + go processMailQueue() +} + +func Send(msg *Message) { + mailQueue <- msg + if MailConfig.HookMode { + <-msg.confirmChan + return + } + go func() { + <-msg.confirmChan + }() +} + +func SendMail(to []string, subject, content string) error { + if MailConfig == nil { + return errors.New("update mail config must restart service") + } + msg := NewMessage(to, subject, content) + Send(msg) + return nil +} diff --git a/pkg/pprof/pprof.go b/pkg/pprof/pprof.go new file mode 100644 index 0000000..0dd148e --- /dev/null +++ b/pkg/pprof/pprof.go @@ -0,0 +1,75 @@ +package pprof + +import ( + "jiacrontab/pkg/file" + "path/filepath" + "runtime" + "runtime/pprof" + "time" + + "github.com/iwannay/log" +) + +func ListenPprof() { + go listenSignal() +} + +func cpuprofile() { + path := filepath.Join("pprof", "cpuprofile") + log.Debugf("profile save in %s", path) + + f, err := file.CreateFile(path) + if err != nil { + log.Error("could not create CPU profile: ", err) + return + } + + defer f.Close() + + if err := pprof.StartCPUProfile(f); err != nil { + log.Error("could not start CPU profile: ", err) + } else { + time.Sleep(time.Minute) + } + defer pprof.StopCPUProfile() +} + +func memprofile() { + path := filepath.Join("pprof", "memprofile") + log.Debugf("profile save in %s", path) + f, err := file.CreateFile(path) + if err != nil { + log.Error("could not create memory profile: ", err) + return + } + + defer f.Close() + + runtime.GC() // get up-to-date statistics + + if err := pprof.WriteHeapProfile(f); err != nil { + log.Error("could not write memory profile: ", err) + } +} + +func profile() { + names := []string{ + "goroutine", + "heap", + "allocs", + "threadcreate", + "block", + "mutex", + } + for _, name := range names { + path := filepath.Join("pprof", name) + log.Debugf("profile save in %s", path) + f, err := file.CreateFile(path) + if err != nil { + log.Error(err) + continue + } + pprof.Lookup(name).WriteTo(f, 0) + } + +} diff --git a/pkg/pprof/pprof_posix.go b/pkg/pprof/pprof_posix.go new file mode 100644 index 0000000..37c71d0 --- /dev/null +++ b/pkg/pprof/pprof_posix.go @@ -0,0 +1,20 @@ +// +build !windows + +package pprof + +import ( + "os" + "os/signal" + "syscall" +) + +func listenSignal() { + signChan := make(chan os.Signal, 1) + signal.Notify(signChan, syscall.SIGUSR1) + for { + <-signChan + profile() + memprofile() + cpuprofile() + } +} diff --git a/pkg/pprof/pprof_windows.go b/pkg/pprof/pprof_windows.go new file mode 100644 index 0000000..3ea06b7 --- /dev/null +++ b/pkg/pprof/pprof_windows.go @@ -0,0 +1,4 @@ +package pprof + +func listenSignal() { +} diff --git a/pkg/pqueue/pqueue.go b/pkg/pqueue/pqueue.go new file mode 100644 index 0000000..9bdf665 --- /dev/null +++ b/pkg/pqueue/pqueue.go @@ -0,0 +1,89 @@ +// Package pqueue jiacrontab中使用的优先队列 +// 参考nsq的实现 +// 做了注释和少量调整 +package pqueue + +import ( + "container/heap" +) + +type Item struct { + Value interface{} + Priority int64 + Index int +} + +// PriorityQueue 最小堆实现的优先队列 +type PriorityQueue []*Item + +// New 创建 +func New(capacity int) PriorityQueue { + return make(PriorityQueue, 0, capacity) +} + +// Len 队列长队 +func (pq PriorityQueue) Len() int { + return len(pq) +} + +// Less 比较相邻两个原素优先级 +func (pq PriorityQueue) Less(i, j int) bool { + return pq[i].Priority < pq[j].Priority +} + +// Swap 交换相邻原素 +func (pq PriorityQueue) Swap(i, j int) { + pq[i], pq[j] = pq[j], pq[i] + pq[i].Index = i + pq[j].Index = j +} + +// Push 添加新的item +func (pq *PriorityQueue) Push(x interface{}) { + n := len(*pq) + c := cap(*pq) + if n+1 > c { + npq := make(PriorityQueue, n, c*2) + copy(npq, *pq) + *pq = npq + } + *pq = (*pq)[0 : n+1] + item := x.(*Item) + item.Index = n + (*pq)[n] = item +} + +func (pq *PriorityQueue) update(item *Item, value string, priority int64) { + item.Value = value + item.Priority = priority + heap.Fix(pq, item.Index) +} + +// Pop 弹出队列末端原素 +func (pq *PriorityQueue) Pop() interface{} { + n := len(*pq) + c := cap(*pq) + if n < (c/2) && c > 25 { + npq := make(PriorityQueue, n, c/2) + copy(npq, *pq) + *pq = npq + } + item := (*pq)[n-1] + item.Index = -1 + *pq = (*pq)[0 : n-1] + return item +} + +// PeekAndShift 根据比较max并弹出原素 +func (pq *PriorityQueue) PeekAndShift(max int64) (*Item, int64) { + if pq.Len() == 0 { + return nil, 0 + } + + item := (*pq)[0] + if item.Priority > max { + return nil, item.Priority - max + } + heap.Remove(pq, 0) + return item, 0 +} diff --git a/pkg/pqueue/pqueue_test.go b/pkg/pqueue/pqueue_test.go new file mode 100644 index 0000000..90ba32f --- /dev/null +++ b/pkg/pqueue/pqueue_test.go @@ -0,0 +1,79 @@ +package pqueue + +import ( + "container/heap" + "math/rand" + "path/filepath" + "reflect" + "runtime" + "sort" + "testing" +) + +func equal(t *testing.T, act, exp interface{}) { + if !reflect.DeepEqual(exp, act) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\texp: %#v\n\n\tgot: %#v\033[39m\n\n", + filepath.Base(file), line, exp, act) + t.FailNow() + } +} + +func TestPriorityQueue(t *testing.T) { + c := 100 + pq := New(c) + + for i := 0; i < c+1; i++ { + heap.Push(&pq, &Item{Value: i, Priority: int64(i)}) + } + equal(t, pq.Len(), c+1) + equal(t, cap(pq), c*2) + + for i := 0; i < c+1; i++ { + item := heap.Pop(&pq) + equal(t, item.(*Item).Value.(int), i) + } + equal(t, cap(pq), c/4) +} + +func TestUnsortedInsert(t *testing.T) { + c := 100 + pq := New(c) + ints := make([]int, 0, c) + + for i := 0; i < c; i++ { + v := rand.Int() + ints = append(ints, v) + heap.Push(&pq, &Item{Value: i, Priority: int64(v)}) + } + equal(t, pq.Len(), c) + equal(t, cap(pq), c) + + sort.Sort(sort.IntSlice(ints)) + + for i := 0; i < c; i++ { + item, _ := pq.PeekAndShift(int64(ints[len(ints)-1])) + equal(t, item.Priority, int64(ints[i])) + } +} + +func TestRemove(t *testing.T) { + c := 100 + pq := New(c) + + for i := 0; i < c; i++ { + v := rand.Int() + heap.Push(&pq, &Item{Value: "test", Priority: int64(v)}) + } + + for i := 0; i < 10; i++ { + heap.Remove(&pq, rand.Intn((c-1)-i)) + } + + lastPriority := heap.Pop(&pq).(*Item).Priority + for i := 0; i < (c - 10 - 1); i++ { + item := heap.Pop(&pq) + equal(t, lastPriority < item.(*Item).Priority, true) + lastPriority = item.(*Item).Priority + } +} diff --git a/pkg/proto/apicode.go b/pkg/proto/apicode.go new file mode 100644 index 0000000..4a834fa --- /dev/null +++ b/pkg/proto/apicode.go @@ -0,0 +1,18 @@ +package proto + +const ( + Code_Success = 0 + Code_FailedAuth = 5001 + Code_Error = 5002 + Code_NotFound = 5004 + Code_NotAllowed = 5005 + Code_JWTError = 5006 + Code_RPCError = 5007 + Code_ParamsError = 5008 + Code_DBError = 5009 +) + +const ( + Msg_NotAllowed = "permission not allowed" + Msg_JWTError = "parse jwt token failed" +) diff --git a/pkg/proto/args.go b/pkg/proto/args.go new file mode 100644 index 0000000..0b84718 --- /dev/null +++ b/pkg/proto/args.go @@ -0,0 +1,66 @@ +package proto + +import ( + "jiacrontab/models" +) + +type SearchLog struct { + JobID uint + GroupID uint + UserID uint + Root bool + IsTail bool + Offset int64 + Pagesize int + Date string + Pattern string +} + +type CleanNodeLog struct { + Unit string + Offset int +} + +type CleanNodeLogRet struct { + Total int64 `json:"total"` + Size string `json:"size"` +} + +type SearchLogResult struct { + Content []byte + Offset int64 + FileSize int64 +} + +type SendMail struct { + MailTo []string + Subject string + Content string +} + +type ApiPost struct { + Urls []string + Data string +} + +type ExecCrontabJobReply struct { + Job models.CrontabJob + Content []byte +} + +type ActionJobsArgs struct { + UserID uint + Root bool + GroupID uint + JobIDs []uint +} + +type GetJobArgs struct { + UserID uint + GroupID uint + Root bool + JobID uint +} +type EmptyArgs struct{} + +type EmptyReply struct{} diff --git a/pkg/proto/const.go b/pkg/proto/const.go new file mode 100644 index 0000000..0d4ad13 --- /dev/null +++ b/pkg/proto/const.go @@ -0,0 +1,10 @@ +package proto + +const ( + DefaultTimeLayout = "2006-01-02 15:04:05" + + TimeoutTrigger_CallApi = "CallApi" + TimeoutTrigger_SendEmail = "SendEmail" + TimeoutTrigger_Kill = "Kill" + TimeoutTrigger_DingdingWebhook = "DingdingWebhook" +) diff --git a/pkg/proto/crontab.go b/pkg/proto/crontab.go new file mode 100644 index 0000000..175dcbf --- /dev/null +++ b/pkg/proto/crontab.go @@ -0,0 +1,70 @@ +package proto + +import ( + "jiacrontab/models" + "time" +) + +type DepJobs []DepJob +type DepJob struct { + Name string + Dest string + From string + ProcessID int // 当前主任务进程id + ID string // 依赖任务id + JobID uint // 主任务id + JobUniqueID string // 主任务唯一标志 + Commands []string + Timeout int64 + Err error + LogContent []byte +} + +type QueryJobArgs struct { + SearchTxt string + Root bool + GroupID uint + UserID uint + Page, Pagesize int +} + +type QueryCrontabJobRet struct { + Total int64 + Page int + GroupID uint + Pagesize int + List []models.CrontabJob +} + +type QueryDaemonJobRet struct { + Total int64 + GroupID int + Page int + Pagesize int + List []models.DaemonJob +} + +type AuditJobArgs struct { + GroupID uint + Root bool + UserID uint + JobIDs []uint +} + +type CrontabApiNotifyBody struct { + NodeAddr string + JobName string + JobID int + CreateUsername string + CreatedAt time.Time + Timeout int64 + Type string + RetryNum int +} + +type EditCrontabJobArgs struct { + Job models.CrontabJob + UserID uint + GroupID uint + Root bool +} diff --git a/pkg/proto/daemon.go b/pkg/proto/daemon.go new file mode 100644 index 0000000..c02e9be --- /dev/null +++ b/pkg/proto/daemon.go @@ -0,0 +1,12 @@ +package proto + +import ( + "jiacrontab/models" +) + +type EditDaemonJobArgs struct { + Job models.DaemonJob + GroupID uint + UserID uint + Root bool +} diff --git a/pkg/proto/resp.go b/pkg/proto/resp.go new file mode 100644 index 0000000..4602e88 --- /dev/null +++ b/pkg/proto/resp.go @@ -0,0 +1,14 @@ +package proto + +const ( + SuccessRespCode = 0 + ErrorRespCode = -1 +) + +type Resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` + Sign string `json:"sign"` + Version string `json:"version"` +} diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go new file mode 100644 index 0000000..2545f77 --- /dev/null +++ b/pkg/rpc/client.go @@ -0,0 +1,111 @@ +package rpc + +import ( + "context" + "errors" + "jiacrontab/pkg/proto" + "net" + "net/rpc" + "time" + + "github.com/iwannay/log" +) + +const ( + diaTimeout = 5 * time.Second + callTimeout = 1 * time.Minute + pingDuration = 3 * time.Second +) + +var ( + ErrRpc = errors.New("rpc is not available") + ErrRpcTimeout = errors.New("rpc call timeout") + ErrRpcCancel = errors.New("rpc call cancel") + ErrShutdown = rpc.ErrShutdown +) + +type ClientOptions struct { + Network string + Addr string +} + +type Client struct { + *rpc.Client + options ClientOptions + quit chan struct{} + err error +} + +func Dial(options ClientOptions) (c *Client) { + c = &Client{} + c.options = options + c.dial() + c.quit = make(chan struct{}, 100) + return c +} + +func (c *Client) dial() (err error) { + conn, err := net.DialTimeout(c.options.Network, c.options.Addr, diaTimeout) + if err != nil { + return err + } + c.Client = rpc.NewClient(conn) + return nil +} + +func (c *Client) Call(serviceMethod string, ctx context.Context, args interface{}, reply interface{}) error { + if serviceMethod != PingService && serviceMethod != RegisterService { + log.Info("rpc call", c.options.Addr, serviceMethod) + } + + if c.Client == nil { + return ErrRpc + } + select { + case <-ctx.Done(): + return ErrRpcCancel + case call := <-c.Client.Go(serviceMethod, args, reply, make(chan *rpc.Call, 1)).Done: + return call.Error + case <-time.After(callTimeout): + return ErrRpcTimeout + } +} + +func (c *Client) Error() error { + return c.err +} + +func (c *Client) Close() { + c.quit <- struct{}{} +} + +func (c *Client) Ping(serviceMethod string) { + var ( + err error + ) + for { + select { + case <-c.quit: + goto closed + default: + } + if c.Client != nil && c.err == nil { + if err = c.Call(serviceMethod, context.TODO(), &proto.EmptyArgs{}, &proto.EmptyReply{}); err != nil { + c.err = err + c.Client.Close() + log.Infof("client.Call(%s, args, reply) error (%v) \n", serviceMethod, err) + } + } else { + if err = c.dial(); err == nil { + c.err = nil + log.Info("client reconnet ", c.options.Addr) + } + } + time.Sleep(pingDuration) + } +closed: + log.Info("rpc quited", c.options.Addr) + if c.Client != nil { + c.Client.Close() + } +} diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go new file mode 100644 index 0000000..e47711f --- /dev/null +++ b/pkg/rpc/client_test.go @@ -0,0 +1,66 @@ +package rpc + +import ( + "jiacrontab/pkg/proto" + "log" + "net/http" + _ "net/http/pprof" + "sync" + "testing" + "time" +) + +type Logic struct { +} + +func (l *Logic) Ping(args *proto.EmptyArgs, reply *proto.EmptyReply) error { + return nil +} + +func (p *Logic) Say(args string, reply *string) error { + + *reply = "hello boy" + time.Sleep(100 * time.Second) + return nil +} +func TestCall(t *testing.T) { + done := make(chan struct{}) + go func() { + + done <- struct{}{} + + log.Println("start server") + err := listen(":6478", &Logic{}) + if err != nil { + t.Fatal("server error:", err) + } + }() + <-done + time.Sleep(5 * time.Second) + // 等待server启动 + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + + defer wg.Done() + var ret string + // var args string + err := Call(":6478", "Logic.Say", "", &ret) + if err != nil { + log.Println(i, "error:", err) + } + t.Log(i, ret) + }(i) + + } + + go func() { + t.Log("listen :6060") + t.Log(http.ListenAndServe(":6060", nil)) + }() + + wg.Wait() + log.Println("end") + time.Sleep(2 * time.Minute) +} diff --git a/pkg/rpc/clients.go b/pkg/rpc/clients.go new file mode 100644 index 0000000..15f089b --- /dev/null +++ b/pkg/rpc/clients.go @@ -0,0 +1,86 @@ +package rpc + +import ( + "context" + "net/rpc" + "sync" + + "github.com/iwannay/log" +) + +var ( + defaultClients *clients + PingService = "Srv.Ping" + RegisterService = "Srv.Register" +) + +type clients struct { + lock sync.RWMutex + clients map[string]*Client +} + +func (c *clients) get(addr string) *Client { + var ( + cli *Client + ok bool + op ClientOptions + ) + + c.lock.Lock() + defer c.lock.Unlock() + if cli, ok = c.clients[addr]; ok { + return cli + } + op.Network = "tcp4" + op.Addr = addr + cli = Dial(op) + c.clients[addr] = cli + go cli.Ping(PingService) + + return cli +} + +func (c *clients) del(addr string) { + c.lock.Lock() + defer c.lock.Unlock() + if cli, ok := c.clients[addr]; ok { + cli.Close() + } + delete(c.clients, addr) +} + +func Call(addr string, serviceMethod string, args interface{}, reply interface{}) error { + err := defaultClients.get(addr).Call(serviceMethod, context.TODO(), args, reply) + if err == rpc.ErrShutdown { + log.Debug("rpc remove", addr) + Del(addr) + } + return err +} + +func CallCtx(addr string, serviceMethod string, ctx context.Context, args interface{}, reply interface{}) error { + err := defaultClients.get(addr).Call(serviceMethod, ctx, args, reply) + if err == rpc.ErrShutdown { + log.Debug("rpc remove", addr) + Del(addr) + } + return err +} + +func Del(addr string) { + if defaultClients != nil { + defaultClients.del(addr) + } +} + +func DelNode(addr string) { + if defaultClients != nil { + defaultClients.del(addr) + } +} + +func init() { + defaultClients = &clients{ + clients: make(map[string]*Client), + } +} diff --git a/pkg/rpc/server.go b/pkg/rpc/server.go new file mode 100644 index 0000000..cca4d8a --- /dev/null +++ b/pkg/rpc/server.go @@ -0,0 +1,41 @@ +package rpc + +import ( + "github.com/iwannay/log" + "net" + "net/rpc" +) + +// listen Start rpc server +func listen(addr string, srcvr ...interface{}) error { + var err error + for _, v := range srcvr { + if err = rpc.Register(v); err != nil { + return err + } + } + + l, err := net.Listen("tcp4", addr) + if err != nil { + return err + } + defer func() { + log.Info("listen rpc", addr, "close") + if err := l.Close(); err != nil { + log.Infof("listen.Close() error(%v)", err) + } + }() + + rpc.Accept(l) + return nil +} + +// ListenAndServe run rpc server +func ListenAndServe(addr string, srcvr ...interface{}) { + log.Info("rpc server listen:", addr) + err := listen(addr, srcvr...) + if err != nil { + panic(err) + } + +} diff --git a/pkg/test/assertions.go b/pkg/test/assertions.go new file mode 100644 index 0000000..330dfec --- /dev/null +++ b/pkg/test/assertions.go @@ -0,0 +1,58 @@ +package test + +import ( + "path/filepath" + "reflect" + "runtime" + "testing" +) + +func Equal(t *testing.T, expected, actual interface{}) { + if !reflect.DeepEqual(expected, actual) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\t %#v (expected)\n\n\t!= %#v (actual)\033[39m\n\n", + filepath.Base(file), line, expected, actual) + t.FailNow() + } +} + +func NotEqual(t *testing.T, expected, actual interface{}) { + if reflect.DeepEqual(expected, actual) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\tnexp: %#v\n\n\tgot: %#v\033[39m\n\n", + filepath.Base(file), line, expected, actual) + t.FailNow() + } +} + +func Nil(t *testing.T, object interface{}) { + if !isNil(object) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\t (expected)\n\n\t!= %#v (actual)\033[39m\n\n", + filepath.Base(file), line, object) + t.FailNow() + } +} + +func NotNil(t *testing.T, object interface{}) { + if isNil(object) { + _, file, line, _ := runtime.Caller(1) + t.Logf("\033[31m%s:%d:\n\n\tExpected value not to be \033[39m\n\n", + filepath.Base(file), line) + t.FailNow() + } +} + +func isNil(object interface{}) bool { + if object == nil { + return true + } + + value := reflect.ValueOf(object) + kind := value.Kind() + if kind >= reflect.Chan && kind <= reflect.Slice && value.IsNil() { + return true + } + + return false +} diff --git a/pkg/test/fakes.go b/pkg/test/fakes.go new file mode 100644 index 0000000..28dcfd0 --- /dev/null +++ b/pkg/test/fakes.go @@ -0,0 +1,45 @@ +package test + +import ( + "net" + "time" +) + +type FakeNetConn struct { + ReadFunc func([]byte) (int, error) + WriteFunc func([]byte) (int, error) + CloseFunc func() error + LocalAddrFunc func() net.Addr + RemoteAddrFunc func() net.Addr + SetDeadlineFunc func(time.Time) error + SetReadDeadlineFunc func(time.Time) error + SetWriteDeadlineFunc func(time.Time) error +} + +func (f FakeNetConn) Read(b []byte) (int, error) { return f.ReadFunc(b) } +func (f FakeNetConn) Write(b []byte) (int, error) { return f.WriteFunc(b) } +func (f FakeNetConn) Close() error { return f.CloseFunc() } +func (f FakeNetConn) LocalAddr() net.Addr { return f.LocalAddrFunc() } +func (f FakeNetConn) RemoteAddr() net.Addr { return f.RemoteAddrFunc() } +func (f FakeNetConn) SetDeadline(t time.Time) error { return f.SetDeadlineFunc(t) } +func (f FakeNetConn) SetReadDeadline(t time.Time) error { return f.SetReadDeadlineFunc(t) } +func (f FakeNetConn) SetWriteDeadline(t time.Time) error { return f.SetWriteDeadlineFunc(t) } + +type fakeNetAddr struct{} + +func (fakeNetAddr) Network() string { return "" } +func (fakeNetAddr) String() string { return "" } + +func NewFakeNetConn() FakeNetConn { + netAddr := fakeNetAddr{} + return FakeNetConn{ + ReadFunc: func(b []byte) (int, error) { return 0, nil }, + WriteFunc: func(b []byte) (int, error) { return len(b), nil }, + CloseFunc: func() error { return nil }, + LocalAddrFunc: func() net.Addr { return netAddr }, + RemoteAddrFunc: func() net.Addr { return netAddr }, + SetDeadlineFunc: func(time.Time) error { return nil }, + SetWriteDeadlineFunc: func(time.Time) error { return nil }, + SetReadDeadlineFunc: func(time.Time) error { return nil }, + } +} diff --git a/pkg/test/logger.go b/pkg/test/logger.go new file mode 100644 index 0000000..c64a615 --- /dev/null +++ b/pkg/test/logger.go @@ -0,0 +1,22 @@ +package test + +type Logger interface { + Output(maxdepth int, s string) error +} + +type tbLog interface { + Log(...interface{}) +} + +type testLogger struct { + tbLog +} + +func (tl *testLogger) Output(maxdepth int, s string) error { + tl.Log(s) + return nil +} + +func NewTestLogger(tbl tbLog) Logger { + return &testLogger{tbl} +} diff --git a/pkg/util/arr.go b/pkg/util/arr.go new file mode 100644 index 0000000..e2abc9e --- /dev/null +++ b/pkg/util/arr.go @@ -0,0 +1,10 @@ +package util + +func FilterEmptyEle(in []string) (out []string) { + for _, v := range in { + if v != "" { + out = append(out, v) + } + } + return +} diff --git a/pkg/util/fn.go b/pkg/util/fn.go new file mode 100644 index 0000000..943b666 --- /dev/null +++ b/pkg/util/fn.go @@ -0,0 +1,173 @@ +package util + +import ( + "jiacrontab/pkg/file" + "reflect" + "strconv" + + "github.com/gofrs/uuid" + + "fmt" + "io/ioutil" + "math/rand" + + "github.com/iwannay/log" + + "flag" + "os" + "path/filepath" + "runtime" + "time" +) + +func RandIntn(end int) int { + return rand.Intn(end) +} + +func CurrentTime(t int64) string { + if t == 0 { + return "0" + } + return time.Unix(t, 0).Format("2006-01-02 15:04:05") +} + +func SystemInfo(startTime time.Time) map[string]interface{} { + var afterLastGC string + goNum := runtime.NumGoroutine() + cpuNum := runtime.NumCPU() + mstat := &runtime.MemStats{} + runtime.ReadMemStats(mstat) + costTime := int(time.Since(startTime).Seconds()) + + if mstat.LastGC != 0 { + afterLastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(mstat.LastGC))/1000/1000/1000) + } else { + afterLastGC = "0" + } + + return map[string]interface{}{ + "服务运行时间": fmt.Sprintf("%d天%d小时%d分%d秒", costTime/(3600*24), costTime%(3600*24)/3600, costTime%3600/60, costTime%(60)), + "goroutine数量": goNum, + "cpu核心数": cpuNum, + + "当前内存使用量": file.FileSize(int64(mstat.Alloc)), + "所有被分配的内存": file.FileSize(int64(mstat.TotalAlloc)), + "内存占用量": file.FileSize(int64(mstat.Sys)), + "指针查找次数": mstat.Lookups, + "内存分配次数": mstat.Mallocs, + "内存释放次数": mstat.Frees, + "距离上次GC时间": afterLastGC, + + // "当前 Heap 内存使用量": file.FileSize(int64(mstat.HeapAlloc)), + // "Heap 内存占用量": file.FileSize(int64(mstat.HeapSys)), + // "Heap 内存空闲量": file.FileSize(int64(mstat.HeapIdle)), + // "正在使用的 Heap 内存": file.FileSize(int64(mstat.HeapInuse)), + // "被释放的 Heap 内存": file.FileSize(int64(mstat.HeapReleased)), + // "Heap 对象数量": mstat.HeapObjects, + + "下次GC内存回收量": file.FileSize(int64(mstat.NextGC)), + "GC暂停时间总量": fmt.Sprintf("%.3fs", float64(mstat.PauseTotalNs)/1000/1000/1000), + "上次GC暂停时间": fmt.Sprintf("%.3fs", float64(mstat.PauseNs[(mstat.NumGC+255)%256])/1000/1000/1000), + } +} + +func TryOpen(path string, flag int) (*os.File, error) { + fabs, err := filepath.Abs(path) + if err != nil { + log.Errorf("TryOpen:", err) + return nil, err + } + + f, err := os.OpenFile(fabs, flag, 0644) + if os.IsNotExist(err) { + err = os.MkdirAll(filepath.Dir(fabs), 0755) + if err != nil { + return nil, err + } + return os.OpenFile(fabs, flag, 0644) + } + return f, err +} + +func CatFile(filepath string, limit int64, content *string) (isPath bool, err error) { + f, err := os.Open(filepath) + + if err != nil { + return false, err + } + fi, err := f.Stat() + if err != nil { + return false, err + } + + if fi.Size() > limit { + *content = filepath + return true, nil + } + data, err := ioutil.ReadAll(f) + if err != nil { + return false, err + } + *content = string(data) + return false, nil +} + +func ParseInt(i string) int { + v, _ := strconv.Atoi(i) + return v +} + +func ParseInt64(i string) int64 { + v, _ := strconv.Atoi(i) + return int64(v) +} + +func InArray(val interface{}, arr interface{}) bool { + t := reflect.TypeOf(arr) + v := reflect.ValueOf(arr) + + if t.Kind() == reflect.Slice { + for i := 0; i < v.Len(); i++ { + if v.Index(i).Interface() == val { + return true + } + } + } + + return false +} + +func UUID() string { + + uu, err := uuid.NewGen().NewV1() + + if err != nil { + log.Error(err) + return fmt.Sprint(time.Now().UnixNano()) + } + + return uu.String() +} + +func GetHostname() string { + hostname, err := os.Hostname() + if err != nil { + log.Error("GetHostname:", err) + } + return hostname +} + +func HasFlagName(fs *flag.FlagSet, s string) bool { + var found bool + fs.Visit(func(flag *flag.Flag) { + if flag.Name == s { + found = true + } + }) + return found + +} + +func init() { + rand.Seed(time.Now().UnixNano()) +} diff --git a/pkg/util/ip.go b/pkg/util/ip.go new file mode 100644 index 0000000..2423abd --- /dev/null +++ b/pkg/util/ip.go @@ -0,0 +1,30 @@ +package util + +import ( + "net" + "strings" +) + +// InternalIP return internal ip. +func InternalIP() string { + inters, err := net.Interfaces() + if err != nil { + return "" + } + for _, inter := range inters { + if inter.Flags&net.FlagUp != 0 && !strings.HasPrefix(inter.Name, "lo") { + addrs, err := inter.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + } + } + } + return "" +} diff --git a/pkg/util/time.go b/pkg/util/time.go new file mode 100644 index 0000000..d6238f6 --- /dev/null +++ b/pkg/util/time.go @@ -0,0 +1,18 @@ +package util + +func CountDaysOfMonth(year int, month int) (days int) { + if month != 2 { + if month == 4 || month == 6 || month == 9 || month == 11 { + days = 30 + } else { + days = 31 + } + } else { + if ((year%4) == 0 && (year%100) != 0) || (year%400) == 0 { + days = 29 + } else { + days = 28 + } + } + return +} diff --git a/pkg/util/wait_group_wrapper.go b/pkg/util/wait_group_wrapper.go new file mode 100644 index 0000000..29f6b2e --- /dev/null +++ b/pkg/util/wait_group_wrapper.go @@ -0,0 +1,17 @@ +package util + +import ( + "sync" +) + +type WaitGroupWrapper struct { + sync.WaitGroup +} + +func (w *WaitGroupWrapper) Wrap(cb func()) { + w.Add(1) + go func() { + cb() + w.Done() + }() +} diff --git a/pkg/version/ver.go b/pkg/version/ver.go new file mode 100644 index 0000000..74695c5 --- /dev/null +++ b/pkg/version/ver.go @@ -0,0 +1,12 @@ +package version + +import ( + "fmt" + "runtime" +) + +var Binary string + +func String(app string) string { + return fmt.Sprintf("%s v%s (built w/%s)", app, Binary, runtime.Version()) +} diff --git a/qq.png b/qq.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1d31b9d512a2ed91cba18edaea4debc311df91 GIT binary patch literal 42533 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

F^WrM#Q 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℘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 d`+q#(uQctH>l4jQig5zq