This commit is contained in:
jiazhizhong
2022-03-10 17:09:03 +08:00
commit 1279635d7f
97 changed files with 10632 additions and 0 deletions

10
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -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.

47
.gitignore vendored Normal file
View File

@@ -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

33
.travis.yml Normal file
View File

@@ -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)

45
CHANGELOG.md Normal file
View File

@@ -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. 修复依赖任务自定义代码不执行

24
Dockerfile Normal file
View File

@@ -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 []

72
LICENSE Normal file
View File

@@ -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.

84
Makefile Normal file
View File

@@ -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

163
README.md Normal file
View File

@@ -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.支持节点分组
### 架构
<img src="https://raw.githubusercontent.com/iwannay/static_dir/master/jiacrontab_arch.png" width="50%"/>
### 说明
jiacrontab 由 jiacrontab_adminjiacrontabd 两部分构成,两者完全独立通过 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.安装 gitgolang(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 &
```
<font color="red" size="3">浏览器访问 host:port (eg: localhost:20000) 即可访问管理后台</font>
#### 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. 自定义错误日志
程序中自定义输出的信息,需要在输出信息后面加入换行
### 截图
<img src="https://raw.githubusercontent.com/iwannay/static_dir/master/jiacrontab_preview_2.0.0_1.png" width="50%"/><img src="https://raw.githubusercontent.com/iwannay/static_dir/master/jiacrontab_preview_2.0.0_2.png" width="50%" />
### 演示地址
[2.0.0版本演示地址](http://jiacrontab-spa.iwannay.cn/) 账号test 密码123456
### 商务合作
<img src="https://raw.githubusercontent.com/iwannay/static_dir/master/1555767089.jpg" width="250" alt="合作"/>
### 赞助
本项目花费了作者大量时间,如果你觉的该项目对你有用,或者你希望该项目有更好的发展,欢迎赞助。
<img src="https://raw.githubusercontent.com/iwannay/jiacrontab/master/admire.jpg" width="250" alt="赞助"/><img src="https://raw.githubusercontent.com/iwannay/jiacrontab/master/qq.png" width="250" alt="qq群"/>

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2.3.0

BIN
admire.jpg Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,44 @@
[app]
http_listen_addr = 0.0.0.0:20000
rpc_listen_addr = :20003
app_name = jiacrontab
; http 返回签名
signing_key = `WERRTT1234$@#@@$`
log_level = warn
; 客户端最大心跳时间
max_client_alive_interval = 30
[jwt]
; jwt 签名
signing_key = eyJhbGciOiJIUzI1
expires = 3600
name = token
[mail]
enabled = true
host = smtp.163.com:25
user = jiacrontab@163.com
skip_verify = true
passwd = xxxxxx
from = jiacrontab@163.com
use_certificate = false
[ldap]
; 支持: ldap://, ldaps://, ldapi://.
addr = ladp://localhost:1234
disabled_anonymous_query = false
bind_passwd= 123456
bind_userdn = "cn=admin,dc=jdevops,dc=com"
basedn = "dc=jdevops,dc=com"
user_field = uid
[database]
; jiacrontab_admin目前支持的数据库包括sqlite3,mysql,pg
; 注意: mysql,pg 等数据库需要手动建立jiacrontab库
; driver_name = postgres
; dsn = postgres://jiacrontab:123456@localhost:5432/jiacrontab?sslmode=disable
; driver_name = mysql
; dsn = root:12345678@(localhost:3306)/jiacrontab?charset=utf8&parseTime=True&loc=Local
driver_name = sqlite3
dsn = data/jiacrontab_admin.db?cache=shared

View File

@@ -0,0 +1,88 @@
package main
import (
"fmt"
admin "jiacrontab/jiacrontab_admin"
"jiacrontab/pkg/pprof"
"os"
"flag"
"jiacrontab/pkg/version"
"jiacrontab/pkg/util"
"github.com/iwannay/log"
)
var (
debug bool
cfgPath string
logLevel string
user string
resetpwd bool
pwd string
)
func parseFlag(opt *admin.Config) *flag.FlagSet {
flagSet := flag.NewFlagSet("jiacrontab_admin", flag.ExitOnError)
// app options
flagSet.Bool("version", false, "打印版本信息")
flagSet.Bool("help", false, "帮助信息")
flagSet.StringVar(&logLevel, "log_level", "warn", "日志级别(debug|info|warn|error)")
flagSet.BoolVar(&debug, "debug", false, "开启debug模式")
flagSet.StringVar(&cfgPath, "config", "./jiacrontab_admin.ini", "配置文件路径")
flagSet.BoolVar(&resetpwd, "resetpwd", false, "重置密码")
flagSet.StringVar(&pwd, "pwd", "", "重置密码时的新密码")
flagSet.StringVar(&user, "user", "", "重置密码时的用户名")
// jwt options
flagSet.Parse(os.Args[1:])
if flagSet.Lookup("version").Value.(flag.Getter).Get().(bool) {
fmt.Println(version.String("jiacrontab_admin"))
os.Exit(0)
}
if flagSet.Lookup("help").Value.(flag.Getter).Get().(bool) {
flagSet.Usage()
os.Exit(0)
}
opt.CfgPath = cfgPath
opt.Resolve()
if util.HasFlagName(flagSet, "debug") {
opt.App.Debug = debug
}
if util.HasFlagName(flagSet, "log_level") {
opt.App.LogLevel = logLevel
}
if debug {
log.JSON("debug config:", opt)
}
return flagSet
}
func main() {
cfg := admin.NewConfig()
parseFlag(cfg)
log.SetLevel(map[string]int{
"debug": 0,
"info": 1,
"warn": 2,
"error": 3,
}[cfg.App.LogLevel])
pprof.ListenPprof()
admin := admin.New(cfg)
if resetpwd {
if err := admin.ResetPwd(user, pwd); err != nil {
fmt.Printf("failed reset passwrod (%s)\n", err)
} else {
fmt.Printf("reset password success!\n")
}
os.Exit(0)
}
admin.Main()
}

View File

@@ -0,0 +1,22 @@
[jiacrontabd]
; 任务日志页面显示冗余信息,比如时间、脚本名称
verbose_job_log = false
; 本机rpc监听地址
listen_addr = :20002
; 当前节点的广播地址,admin通过该地址与当前节点通信,默认取当前节点ip
; boardcast_addr = localhost:20001
; admin 地址
admin_addr = jiacrontab_admin:20003
; 自动清理大于一个月或者单文件体积大于1G的日志文件
auto_clean_task_log = true
; 节点名默认取节点hostname
; node_name = node1
log_level = warn
log_path = ./logs
user_agent = jiacrontabd
; jiacrontabd目前仅支持sqlite3
driver_name = sqlite3
dsn = data/jiacrontabd.db?cache=shared
; 心跳上报周期(s)
client_alive_interval = 10

77
app/jiacrontabd/main.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"flag"
"fmt"
"jiacrontab/jiacrontabd"
"jiacrontab/pkg/pprof"
"jiacrontab/pkg/util"
"jiacrontab/pkg/version"
"os"
"github.com/iwannay/log"
)
func parseFlag(opt *jiacrontabd.Config) *flag.FlagSet {
var (
debug bool
cfgPath string
logLevel string
boardcastAddr string
)
flagSet := flag.NewFlagSet("jiacrontabd", flag.ExitOnError)
flagSet.Bool("version", false, "打印版本信息")
flagSet.Bool("help", false, "帮助信息")
flagSet.StringVar(&logLevel, "log_level", "warn", "日志级别(debug|info|warn|error)")
flagSet.BoolVar(&debug, "debug", false, "开启debug模式")
flagSet.StringVar(&boardcastAddr, "boardcast_addr", "", fmt.Sprintf("广播地址(default: %s:20001)", util.InternalIP()))
flagSet.StringVar(&cfgPath, "config", "./jiacrontabd.ini", "配置文件路径")
flagSet.Parse(os.Args[1:])
if flagSet.Lookup("version").Value.(flag.Getter).Get().(bool) {
fmt.Println(version.String("jiacrontab_admin"))
os.Exit(0)
}
if flagSet.Lookup("help").Value.(flag.Getter).Get().(bool) {
flagSet.Usage()
os.Exit(0)
}
opt.CfgPath = cfgPath
opt.Resolve()
// TODO can be better
if util.HasFlagName(flagSet, "log_level") {
opt.LogLevel = logLevel
}
if util.HasFlagName(flagSet, "debug") {
opt.Debug = debug
}
if util.HasFlagName(flagSet, "boardcast_addr") {
opt.BoardcastAddr = boardcastAddr
}
if debug {
log.JSON("debug config:", opt)
}
return flagSet
}
func main() {
cfg := jiacrontabd.NewConfig()
parseFlag(cfg)
log.SetLevel(map[string]int{
"debug": 0,
"info": 1,
"warn": 2,
"error": 3,
}[cfg.LogLevel])
pprof.ListenPprof()
jiad := jiacrontabd.New(cfg)
jiad.Main()
}

View File

@@ -0,0 +1,21 @@
[Unit]
Description=jiacrontab_admin service
After=network.target
[Install]
WantedBy=multi-user.target
[Service]
Type=simple
User=root
Group=root
ProtectSystem=full
WorkingDirectory=/opt/jiacrontab/jiacrontab_admin
ExecStart=/opt/jiacrontab/jiacrontab_admin/jiacrontab_admin
KillMode=process
KillSignal=SIGTERM
SendSIGKILL=no
Restart=on-abort
RestartSec=5s
UMask=007

262
deployment/jiacrontabctl Normal file
View File

@@ -0,0 +1,262 @@
#!/bin/bash
# -----------------------------------------------------------------
# The initial version was released by Vipkwd
# -----------------------------------------------------------------
# 应用场景本脚本需配合jiacrontab v2.2.0+ 版本使用
# https://jiacrontab.iwannay.cn/download/jiacrontab-v2.2.0-linux-amd64.zip
#
# 部署方法1、解压上述zip二进制包; 2、将本脚本放入解压后的目录终呈如下结构
#
# [root@vipkwd jiacrontab]# pwd
# /data/wwwroot/jiacrontab
# [root@vipkwd jiacrontab]# ll
# 总用量 16
# 1033992 -rwx--x--x 1 root root 7.4K 8月 14 21:30 jiacrontabctl
# 1034047 drwxr-xr-x 3 root www 4.0K 8月 11 08:27 jiacrontab_admin
# 1033995 drwxr-xr-x 4 root www 4.0K 8月 11 14:24 jiacrontabd
# [root@vipkwd_com jiacrontab]#
# -----------------------------------------------------------------
# -----配置项 start-----
# 提供进程搜索关键字(即: "jiacrontab_admin"与“jiacrontabd” 在公共字符串部分用于shell grep 过滤)
APP_KEYWORDS=jiacrontab
# 提供web前端服务的脚本文件(目录)名(对应: ./jiacrontab_admin/jiacrontab_admin)
ADMIN_SCRIPT_NAME=jiacrontab_admin
# 任务调度服务脚本文件(目录)名(对应: ./jiacrontabd/jiacrontabd)
BD_SCRIPT_NAME=jiacrontabd
#手动指定项目PATH(默认空,自动定位 pwd 指令目录)
DEPLOY_PATH=
# -----配置项 end-----
FORMATER_LINE="------------------------------------------------------------------------------------------------------"
ECHO_PREFIX=" -- "
STIME_LOGFILE=".stime.log"
# 绝对定位工作目录
PROJECT_ROOT=${DEPLOY_PATH:=`pwd`}
if [ ! -d $PROJECT_ROOT ];then
echo -e "[\033[31mError\033[0m] DEPLOY_PATH \033[33m${DEPLOY_PATH}\033[0m is not a directory!"
exit 1
fi
ai=0
apps=("${ADMIN_SCRIPT_NAME}" "${BD_SCRIPT_NAME}")
for app in ${apps[*]}
do
path=${PROJECT_ROOT}/${app}
if [ ! -d $path ];then
echo -e "[\033[31mError\033[0m] Project path: \033[33m${path}\033[0m is not a directory!"
ai=$(($ai+1))
fi
done
apps=""
[[ $ai -gt 0 ]] && exit 1
ADMIN_CLI_IS_RUNNING=0
BD_CLI_IS_RUNNING=0
function _timeNow(){
nowtime=`date "+%Y-%m-%d %H:%M:%S"`
}
function _runApp(){
if [ "${1}" == "0" ];then
path=${PROJECT_ROOT}/${2}
if [ ! -d $path ];then
echo -e "[\033[31mError\033[0m] \033[33m${path}\033[0m is not directorySkiped!"
return 0
fi
if [ ! -f "${path}/${2}" ];then
echo -e "[\033[31mError\033[0m] Service script \033[33m${path}/${2}\033[0m is not existSkiped!"
return 0
fi
cd $path
`nohup ./${2} &> ${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

View File

@@ -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

37
go.mod Normal file
View File

@@ -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
)

491
go.sum Normal file
View File

@@ -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=

1
jiacrontab_admin/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
bindata_gzip.go

92
jiacrontab_admin/admin.go Normal file
View File

@@ -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))
}

172
jiacrontab_admin/app.go Normal file
View File

@@ -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)
}

216
jiacrontab_admin/config.go Normal file
View File

@@ -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("邮箱服务未开启"))
}

32
jiacrontab_admin/const.go Normal file
View File

@@ -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动态"
)

266
jiacrontab_admin/crontab.go Normal file
View File

@@ -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)
}

253
jiacrontab_admin/ctx.go Normal file
View File

@@ -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()
}

223
jiacrontab_admin/daemon.go Normal file
View File

@@ -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,
})
}

21
jiacrontab_admin/debug.go Normal file
View File

@@ -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())
}

66
jiacrontab_admin/group.go Normal file
View File

@@ -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)
}

124
jiacrontab_admin/ldap.go Normal file
View File

@@ -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
}

168
jiacrontab_admin/node.go Normal file
View File

@@ -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<?", currentTime).Updates(map[string]interface{}{
"disabled": true,
})
model := models.DB()
if reqBody.SearchTxt != "" {
txt := "%" + reqBody.SearchTxt + "%"
model = model.Where("(name like ? or addr like ?)", txt, txt)
}
switch reqBody.QueryStatus {
case 1:
err = model.Preload("Group").Where("group_id=? and disabled=?",
reqBody.QueryGroupID, false).Offset((reqBody.Page - 1) * reqBody.Pagesize).
Order("id desc").Limit(reqBody.Pagesize).Find(&nodeList).Error
model.Model(&models.Node{}).Where("group_id=? and disabled=?",
reqBody.QueryGroupID, false).Count(&count)
case 2:
err = model.Preload("Group").Where("group_id=? and disabled=?",
reqBody.QueryGroupID, true).Offset((reqBody.Page - 1) * reqBody.Pagesize).Order("id desc").Limit(reqBody.Pagesize).Find(&nodeList).Error
model.Model(&models.Node{}).Where("group_id=? and disabled=?", reqBody.QueryGroupID, true).Count(&count)
default:
err = model.Preload("Group").Where("group_id=?",
reqBody.QueryGroupID).Offset((reqBody.Page - 1) * reqBody.Pagesize).Order("id desc").Limit(reqBody.Pagesize).Find(&nodeList).Error
model.Model(&models.Node{}).Where("group_id=?",
reqBody.QueryGroupID).Count(&count)
}
if err != nil {
ctx.respBasicError(err)
return
}
ctx.respSucc("", map[string]interface{}{
"list": nodeList,
"total": count,
"page": reqBody.Page,
"pagesize": reqBody.Pagesize,
})
}
// DeleteNode 删除分组内节点
// 仅超级管理员有权限
func DeleteNode(ctx *myctx) {
var (
err error
reqBody DeleteNodeReqParams
node models.Node
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
}
// 普通用户不允许删除节点
if !(ctx.isSuper() || (ctx.isRoot() && ctx.claims.GroupID == reqBody.GroupID)) {
ctx.respNotAllowed()
return
}
if err = node.Delete(reqBody.GroupID, reqBody.Addr); err != nil {
ctx.respDBError(err)
return
}
ctx.pubEvent(node.Addr, event_DelNodeDesc, models.EventSourceName(reqBody.Addr), reqBody)
ctx.respSucc("", nil)
}
// GroupNode 超级管理员为node分组
// 分组不存在时自动创建分组
// copy超级管理员分组中的节点到新的分组
func GroupNode(ctx *myctx) {
var (
err error
reqBody GroupNodeReqParams
node models.Node
)
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if err = node.GroupNode(reqBody.Addr, reqBody.TargetGroupID,
reqBody.TargetNodeName, reqBody.TargetGroupName); err != nil {
ctx.respBasicError(err)
return
}
ctx.pubEvent(node.Group.Name, event_GroupNode, models.EventSourceName(reqBody.Addr), reqBody)
ctx.respSucc("", nil)
}
// DeleteNode 删除分组内节点
// 仅超级管理员有权限
func CleanNodeLog(ctx *myctx) {
var (
err error
reqBody CleanNodeLogReqParams
cleanRet proto.CleanNodeLogRet
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
// 普通用户不允许删除节点
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
if err = rpcCall(reqBody.Addr, "Srv.CleanLogFiles", proto.CleanNodeLog{
Unit: reqBody.Unit,
Offset: reqBody.Offset,
}, &cleanRet); err != nil {
ctx.respRPCError(err)
return
}
ctx.pubEvent(fmt.Sprintf("%d %s", reqBody.Offset, reqBody.Unit), event_CleanNodeLog, models.EventSourceName(reqBody.Addr), reqBody)
ctx.respSucc("", cleanRet)
}

456
jiacrontab_admin/params.go Normal file
View File

@@ -0,0 +1,456 @@
package admin
import (
"errors"
"fmt"
"jiacrontab/models"
"jiacrontab/pkg/proto"
"jiacrontab/pkg/util"
"strings"
)
var (
paramsError = errors.New("参数错误")
)
type Parameter interface {
Verify(*myctx) error
}
type JobReqParams struct {
JobID uint `json:"jobID" rule:"required,请填写jobID"`
Addr string `json:"addr" rule:"required,请填写addr"`
}
func (p *JobReqParams) Verify(*myctx) error {
if p.JobID == 0 || p.Addr == "" {
return paramsError
}
return nil
}
type JobsReqParams struct {
JobIDs []uint `json:"jobIDs" `
Addr string `json:"addr"`
}
func (p *JobsReqParams) Verify(ctx *myctx) error {
if len(p.JobIDs) == 0 || p.Addr == "" {
return paramsError
}
return nil
}
type EditJobReqParams struct {
JobID uint `json:"jobID"`
Addr string `json:"addr" rule:"required,请填写addr"`
IsSync bool `json:"isSync"`
Name string `json:"name" rule:"required,请填写name"`
Command []string `json:"command" rule:"required,请填写name"`
Code string `json:"code"`
Timeout int `json:"timeout"`
MaxConcurrent uint `json:"maxConcurrent"`
ErrorMailNotify bool `json:"errorMailNotify"`
ErrorAPINotify bool `json:"errorAPINotify"`
ErrorDingdingNotify bool `json:"errorDingdingNotify"`
MailTo []string `json:"mailTo"`
APITo []string `json:"APITo"`
DingdingTo []string `json:"DingdingTo"`
RetryNum int `json:"retryNum"`
WorkDir string `json:"workDir"`
WorkUser string `json:"workUser"`
WorkEnv []string `json:"workEnv"`
WorkIp []string `json:"workIp"`
KillChildProcess bool `json:"killChildProcess"`
DependJobs models.DependJobs `json:"dependJobs"`
Month string `json:"month"`
Weekday string `json:"weekday"`
Day string `json:"day"`
Hour string `json:"hour"`
Minute string `json:"minute"`
Second string `json:"second"`
TimeoutTrigger []string `json:"timeoutTrigger"`
}
func (p *EditJobReqParams) Verify(ctx *myctx) error {
ts := map[string]bool{
proto.TimeoutTrigger_CallApi: true,
proto.TimeoutTrigger_SendEmail: true,
proto.TimeoutTrigger_Kill: true,
proto.TimeoutTrigger_DingdingWebhook: true,
}
for _, v := range p.TimeoutTrigger {
if !ts[v] {
return fmt.Errorf("%s:%v", v, paramsError)
}
}
p.Command = util.FilterEmptyEle(p.Command)
p.MailTo = util.FilterEmptyEle(p.MailTo)
p.APITo = util.FilterEmptyEle(p.APITo)
p.DingdingTo = util.FilterEmptyEle(p.DingdingTo)
p.WorkEnv = util.FilterEmptyEle(p.WorkEnv)
p.WorkIp = util.FilterEmptyEle(p.WorkIp)
if p.Month == "" {
p.Month = "*"
}
if p.Weekday == "" {
p.Weekday = "*"
}
if p.Day == "" {
p.Day = "*"
}
if p.Hour == "" {
p.Hour = "*"
}
if p.Minute == "" {
p.Minute = "*"
}
if p.Second == "" {
p.Second = "*"
}
return nil
}
type GetLogReqParams struct {
Addr string `json:"addr"`
JobID uint `json:"jobID"`
Date string `json:"date"`
Pattern string `json:"pattern"`
IsTail bool `json:"isTail"`
Offset int64 `json:"offset"`
Pagesize int `json:"pagesize"`
}
func (p *GetLogReqParams) Verify(ctx *myctx) error {
if p.Pagesize <= 0 {
p.Pagesize = 50
}
return nil
}
type DeleteNodeReqParams struct {
Addr string `json:"addr" rule:"required,请填写addr"`
GroupID uint `json:"groupID"`
}
func (p *DeleteNodeReqParams) Verify(ctx *myctx) error {
return nil
}
type CleanNodeLogReqParams struct {
Unit string `json:"unit" rule:"required,请填写时间单位"`
Offset int `json:"offset"`
Addr string `json:"addr" rule:"required,请填写addr"`
}
func (p *CleanNodeLogReqParams) Verify(ctx *myctx) error {
if p.Unit != "day" && p.Unit != "month" {
return errors.New("不支持的时间单位")
}
return nil
}
type SendTestMailReqParams struct {
MailTo string `json:"mailTo" rule:"required,请填写mailTo"`
}
func (p *SendTestMailReqParams) Verify(ctx *myctx) error {
return nil
}
type SystemInfoReqParams struct {
Addr string `json:"addr" rule:"required,请填写addr"`
}
func (p *SystemInfoReqParams) Verify(ctx *myctx) error {
return nil
}
type GetJobListReqParams struct {
Addr string `json:"addr" rule:"required,请填写addr"`
SearchTxt string `json:"searchTxt"`
PageReqParams
}
func (p *GetJobListReqParams) Verify(ctx *myctx) error {
if p.Page <= 1 {
p.Page = 1
}
if p.Pagesize <= 0 {
p.Pagesize = 50
}
return nil
}
type GetGroupListReqParams struct {
SearchTxt string `json:"searchTxt"`
PageReqParams
}
func (p *GetGroupListReqParams) Verify(ctx *myctx) error {
if p.Page <= 1 {
p.Page = 1
}
if p.Pagesize <= 0 {
p.Pagesize = 50
}
return nil
}
type ActionTaskReqParams struct {
Action string `json:"action" rule:"required,请填写action"`
Addr string `json:"addr" rule:"required,请填写addr"`
JobIDs []uint `json:"jobIDs" rule:"required,请填写jobIDs"`
}
func (p *ActionTaskReqParams) Verify(ctx *myctx) error {
if len(p.JobIDs) == 0 {
return paramsError
}
return nil
}
type EditDaemonJobReqParams struct {
Addr string `json:"addr" rule:"required,请填写addr"`
JobID uint `json:"jobID"`
Name string `json:"name" rule:"required,请填写name"`
MailTo []string `json:"mailTo"`
APITo []string `json:"APITo"`
DingdingTo []string `json:"DingdingTo"`
Command []string `json:"command" rule:"required,请填写command"`
Code string `json:"code"`
WorkUser string `json:"workUser"`
WorkIp []string `json:"workIp"`
WorkEnv []string `json:"workEnv"`
WorkDir string `json:"workDir"`
FailRestart bool `json:"failRestart"`
RetryNum int `json:"retryNum"`
ErrorMailNotify bool `json:"errorMailNotify"`
ErrorAPINotify bool `json:"errorAPINotify"`
ErrorDingdingNotify bool `json:"errorDingdingNotify"`
}
func (p *EditDaemonJobReqParams) Verify(ctx *myctx) error {
p.MailTo = util.FilterEmptyEle(p.MailTo)
p.APITo = util.FilterEmptyEle(p.APITo)
p.Command = util.FilterEmptyEle(p.Command)
p.WorkEnv = util.FilterEmptyEle(p.WorkEnv)
p.WorkIp = util.FilterEmptyEle(p.WorkIp)
return nil
}
type GetJobReqParams struct {
JobID uint `json:"jobID" rule:"required,请填写jobID"`
Addr string `json:"addr" rule:"required,请填写addr"`
}
func (p *GetJobReqParams) Verify(ctx *myctx) error {
return nil
}
type UserReqParams struct {
Username string `json:"username" rule:"required,请输入用户名"`
Passwd string `json:"passwd,omitempty" rule:"required,请输入密码"`
GroupID uint `json:"groupID"`
GroupName string `json:"groupName"`
Avatar string `json:"avatar"`
Root bool `json:"root"`
Mail string `json:"mail"`
}
func (p *UserReqParams) Verify(ctx *myctx) error {
return nil
}
type InitAppReqParams struct {
Username string `json:"username" rule:"required,请输入用户名"`
Passwd string `json:"passwd" rule:"required,请输入密码"`
Avatar string `json:"avatar"`
Mail string `json:"mail"`
}
func (p *InitAppReqParams) Verify(ctx *myctx) error {
return nil
}
type EditUserReqParams struct {
UserID uint `json:"userID" rule:"required,缺少userID"`
Username string `json:"username"`
Passwd string `json:"passwd"`
OldPwd string `json:"oldpwd"`
Avatar string `json:"avatar"`
Mail string `json:"mail"`
}
func (p *EditUserReqParams) Verify(ctx *myctx) error {
return nil
}
type DeleteUserReqParams struct {
UserID uint `json:"userID" rule:"required,缺少userID"`
}
func (p *DeleteUserReqParams) Verify(ctx *myctx) error {
return nil
}
type LoginReqParams struct {
Username string `json:"username" rule:"required,请输入用户名"`
Passwd string `json:"passwd" rule:"required,请输入密码"`
Remember bool `json:"remember"`
IsLdap bool `json:"is_ldap"`
}
func (p *LoginReqParams) Verify(ctx *myctx) error {
return nil
}
type PageReqParams struct {
Page int `json:"page"`
Pagesize int `json:"pagesize"`
}
type GetNodeListReqParams struct {
PageReqParams
SearchTxt string `json:"searchTxt"`
QueryGroupID uint `json:"queryGroupID"`
QueryStatus uint `json:"queryStatus"`
}
func (p *GetNodeListReqParams) Verify(ctx *myctx) error {
if p.Page == 0 {
p.Page = 1
}
if p.Pagesize <= 0 {
p.Pagesize = 50
}
return nil
}
type EditGroupReqParams struct {
GroupID uint `json:"groupID" rule:"required,请填写groupID"`
GroupName string `json:"groupName" rule:"required,请填写groupName"`
}
func (p *EditGroupReqParams) Verify(ctx *myctx) error {
return nil
}
type SetGroupReqParams struct {
TargetGroupID uint `json:"targetGroupID"`
TargetGroupName string `json:"targetGroupName"`
UserID uint `json:"userID" rule:"required,请填写用户ID"`
Root bool `json:"root"`
}
func (p *SetGroupReqParams) Verify(ctx *myctx) error {
return nil
}
type ReadMoreReqParams struct {
LastID int `json:"lastID"`
Pagesize int `json:"pagesize"`
Keywords string `json:"keywords"`
Orderby string `json:"orderby"`
}
func (p *ReadMoreReqParams) Verify(ctx *myctx) error {
if p.Pagesize == 0 {
p.Pagesize = 50
}
if p.Orderby == "" {
p.Orderby = "desc"
}
p.Keywords = strings.TrimSpace(p.Keywords)
return nil
}
type GroupNodeReqParams struct {
Addr string `json:"addr" rule:"required,请填写addr"`
TargetNodeName string `json:"targetNodeName"`
TargetGroupName string `json:"targetGroupName"`
TargetGroupID uint `json:"targetGroupID"`
}
func (p *GroupNodeReqParams) Verify(ctx *myctx) error {
return nil
}
type AuditJobReqParams struct {
JobsReqParams
JobType string `json:"jobType"`
}
func (p *AuditJobReqParams) Verify(ctx *myctx) error {
if p.Addr == "" {
return paramsError
}
jobTypeMap := map[string]bool{
"crontab": true,
"daemon": true,
}
if err := p.JobsReqParams.Verify(nil); err != nil {
return err
}
if !jobTypeMap[p.JobType] {
return paramsError
}
return nil
}
type GetUsersParams struct {
PageReqParams
SearchTxt string `json:"searchTxt"`
IsAll bool `json:"isAll"`
QueryGroupID uint `json:"queryGroupID"`
}
func (p *GetUsersParams) Verify(ctx *myctx) error {
if p.Page <= 1 {
p.Page = 1
}
if p.Pagesize <= 0 {
p.Pagesize = 50
}
return nil
}
type CleanLogParams struct {
IsEvent bool `json:"isEvent"`
Unit string `json:"unit" rule:"required,请填写时间单位"`
Offset int `json:"offset"`
}
func (c *CleanLogParams) Verify(ctx *myctx) error {
if c.Unit != "day" && c.Unit != "month" {
return errors.New("不支持的时间单位")
}
return nil
}

View File

@@ -0,0 +1,62 @@
package admin
import (
"fmt"
"jiacrontab/pkg/base"
"net/http"
"runtime"
"strconv"
"github.com/kataras/iris/v12"
)
func getRequestLogs(ctx *myctx) string {
var status, ip, method, path string
status = strconv.Itoa(ctx.GetStatusCode())
path = ctx.Path()
method = ctx.Method()
ip = ctx.RemoteAddr()
// the date should be logged by iris' Logger, so we skip them
return fmt.Sprintf("%v %s %s %s", status, path, method, ip)
}
func newRecover(adm *Admin) iris.Handler {
return func(c iris.Context) {
ctx := wrapCtx(c, adm)
base.Stat.AddConcurrentCount()
defer func() {
if err := recover(); err != nil {
base.Stat.AddErrorCount(ctx.RequestPath(true), fmt.Errorf("%v", err), 1)
if ctx.IsStopped() {
return
}
var stacktrace string
for i := 1; ; i++ {
_, f, l, got := runtime.Caller(i)
if !got {
break
}
stacktrace += fmt.Sprintf("%s:%d\n", f, l)
}
// when stack finishes
logMessage := fmt.Sprintf("Recovered from a route's Handler('%s')\n", ctx.HandlerName())
logMessage += fmt.Sprintf("At Request: %s\n", getRequestLogs(ctx))
logMessage += fmt.Sprintf("Trace: %s\n", err)
logMessage += fmt.Sprintf("\n%s", stacktrace)
ctx.Application().Logger().Warn(logMessage)
ctx.StatusCode(500)
ctx.respError(http.StatusInternalServerError, fmt.Sprint(err), nil)
ctx.StopExecution()
}
}()
base.Stat.AddRequestCount(ctx.RequestPath(true), ctx.GetStatusCode(), 1)
c.Next()
}
}

View File

@@ -0,0 +1,26 @@
package admin
import (
"jiacrontab/pkg/proto"
"jiacrontab/pkg/version"
)
func SystemInfo(ctx *myctx) {
var (
err error
info map[string]interface{}
reqBody SystemInfoReqParams
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respBasicError(err)
return
}
if err = rpcCall(reqBody.Addr, "Srv.SystemInfo", proto.EmptyArgs{}, &info); err != nil {
ctx.respRPCError(err)
return
}
info["version"] = version.String("jiacrontab")
ctx.respSucc("", info)
}

143
jiacrontab_admin/srv.go Normal file
View File

@@ -0,0 +1,143 @@
package admin
import (
"fmt"
"io"
"io/ioutil"
"jiacrontab/models"
"jiacrontab/pkg/mailer"
"jiacrontab/pkg/proto"
"net/http"
"strings"
"time"
"github.com/iwannay/log"
)
type Srv struct {
adm *Admin
}
func NewSrv(adm *Admin) *Srv {
return &Srv{
adm: adm,
}
}
func (s *Srv) Register(args map[uint]models.Node, reply *bool) error {
*reply = true
for _, node := range args {
ret := models.DB().Unscoped().Model(&models.Node{}).Where("addr=? and group_id=?", node.Addr, node.GroupID).Updates(map[string]interface{}{
"daemon_task_num": node.DaemonTaskNum,
"crontab_task_num": node.CrontabTaskNum,
"crontab_job_audit_num": node.CrontabJobAuditNum,
"daemon_job_audit_num": node.DaemonJobAuditNum,
"crontab_job_fail_num": node.CrontabJobFailNum,
})
if ret.Error != nil {
return ret.Error
}
if node.GroupID == models.SuperGroup.ID {
ret = models.DB().Unscoped().Model(&models.Node{}).Where("addr=?", node.Addr).Updates(map[string]interface{}{
"name": node.Name,
"deleted_at": nil,
"disabled": false,
})
if ret.Error != nil {
return ret.Error
}
}
if ret.RowsAffected == 0 && node.GroupID == models.SuperGroup.ID {
ret = models.DB().Create(&node)
}
if ret.Error != nil {
return ret.Error
}
}
return nil
}
func (s *Srv) ExecDepend(args proto.DepJobs, reply *bool) error {
log.Infof("Callee Srv.ExecDepend jobID:%d", args[0].JobID)
*reply = true
for _, v := range args {
if err := rpcCall(v.Dest, "CrontabJob.ExecDepend", v, &reply); err != nil {
*reply = false
return err
}
}
return nil
}
func (s *Srv) SetDependDone(args proto.DepJob, reply *bool) error {
log.Infof("Callee Srv.SetDependDone jobID:%d", args.JobID)
*reply = true
if err := rpcCall(args.Dest, "CrontabJob.SetDependDone", args, &reply); err != nil {
*reply = false
return err
}
return nil
}
func (s *Srv) SendMail(args proto.SendMail, reply *bool) error {
var (
err error
cfg = s.adm.getOpts()
)
if cfg.Mailer.Enabled {
err = mailer.SendMail(args.MailTo, args.Subject, args.Content)
}
*reply = true
return err
}
func (s *Srv) PushJobLog(args models.JobHistory, reply *bool) error {
models.PushJobHistory(&args)
*reply = true
return nil
}
func (s *Srv) ApiPost(args proto.ApiPost, reply *bool) error {
var (
err error
errs []error
)
for _, url := range args.Urls {
client := http.Client{
Timeout: time.Minute,
}
response, err := client.Post(url, "application/json", strings.NewReader(args.Data))
if err != nil {
errs = append(errs, err)
log.Errorf("post url %s fail: %s", url, err)
continue
}
defer response.Body.Close()
io.Copy(ioutil.Discard, response.Body)
}
for _, v := range errs {
if err != nil {
err = fmt.Errorf("%s\n%s", err, v)
} else {
err = v
}
}
*reply = true
return err
}
func (s *Srv) Ping(args *proto.EmptyArgs, reply *proto.EmptyReply) error {
return nil
}

View File

@@ -0,0 +1,76 @@
package admin
import (
"fmt"
"jiacrontab/models"
"time"
"gorm.io/gorm"
)
func LogInfo(ctx *myctx) {
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
var jobTotal int64
err := models.DB().Model(&models.JobHistory{}).Count(&jobTotal).Error
if err != nil {
ctx.respDBError(err)
}
var eventTotal int64
err = models.DB().Model(&models.Event{}).Count(&eventTotal).Error
if err != nil {
ctx.respDBError(err)
}
ctx.respSucc("", map[string]interface{}{
"event_total": eventTotal,
"job_total": jobTotal,
})
}
func CleanLog(ctx *myctx) {
var (
err error
reqBody CleanLogParams
isSuper = ctx.isSuper()
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if !isSuper {
ctx.respNotAllowed()
return
}
offset := time.Now()
if reqBody.Unit == "day" {
offset = offset.AddDate(0, 0, -reqBody.Offset)
}
if reqBody.Unit == "month" {
offset = offset.AddDate(0, -reqBody.Offset, 0)
}
var tx *gorm.DB
if reqBody.IsEvent {
tx = models.DB().Where("created_at<?", offset).Unscoped().Delete(&models.Event{})
} else {
tx = models.DB().Where("created_at<?", offset).Unscoped().Delete(&models.JobHistory{})
}
err = tx.Error
if err != nil {
ctx.respDBError(err)
return
}
if reqBody.IsEvent {
ctx.pubEvent(fmt.Sprintf("%d %s", reqBody.Offset, reqBody.Unit), event_CleanUserEvent, "", reqBody)
} else {
ctx.pubEvent(fmt.Sprintf("%d %s", reqBody.Offset, reqBody.Unit), event_CleanJobHistory, "", reqBody)
}
ctx.respSucc("清理成功", map[string]interface{}{
"total": tx.RowsAffected,
})
return
}

501
jiacrontab_admin/user.go Normal file
View File

@@ -0,0 +1,501 @@
package admin
import (
"database/sql"
"errors"
"fmt"
"jiacrontab/models"
"jiacrontab/pkg/proto"
"jiacrontab/pkg/util"
"jiacrontab/pkg/version"
"strings"
"time"
jwt "github.com/dgrijalva/jwt-go"
)
type CustomerClaims struct {
jwt.StandardClaims
Version int64
UserID uint
Mail string
Username string
GroupID uint
Root bool
}
// Login 用户登录
func Login(ctx *myctx) {
var (
err error
reqBody LoginReqParams
user models.User
customerClaims CustomerClaims
cfg = ctx.adm.getOpts()
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if reqBody.IsLdap {
luser, err := ctx.adm.ldap.Login(reqBody.Username, reqBody.Passwd)
if err != nil {
ctx.respAuthFailed(err)
return
}
user = *luser
} else {
if !user.Verify(reqBody.Username, reqBody.Passwd) {
ctx.respAuthFailed(errors.New("帐号或密码不正确"))
return
}
}
customerClaims.ExpiresAt = cfg.Jwt.Expires + time.Now().Unix()
customerClaims.Username = reqBody.Username
customerClaims.UserID = user.ID
customerClaims.Mail = user.Mail
customerClaims.GroupID = user.GroupID
customerClaims.Root = user.Root
customerClaims.Version = user.Version
if reqBody.Remember {
customerClaims.ExpiresAt = time.Now().Add(24 * 30 * time.Hour).Unix()
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, customerClaims).SignedString([]byte(cfg.Jwt.SigningKey))
if err != nil {
ctx.respAuthFailed(errors.New("无法生成访问凭证"))
return
}
ctx.respSucc("", map[string]interface{}{
"token": token,
"groupID": user.GroupID,
"root": user.Root,
"mail": user.Mail,
"username": user.Username,
"userID": user.ID,
})
}
func GetActivityList(ctx *myctx) {
var (
err error
reqBody ReadMoreReqParams
events []models.Event
isSuper bool
model = models.DB()
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if ctx.isSuper() {
isSuper = true
}
if reqBody.LastID == 0 {
if !isSuper {
model = model.Where("group_id=?", ctx.claims.GroupID)
}
} else {
if !isSuper {
model = model.Where("group_id=? and id<?", ctx.claims.GroupID, reqBody.LastID)
} else {
model = model.Where("id<?", reqBody.LastID)
}
}
if reqBody.Keywords != "" {
txt := "%" + reqBody.Keywords + "%"
model = model.Where(`(
username like ? or
event_desc like ? or
target_name like ? or
source_username like ? or
source_name like ? or
content like ?)`, txt, txt, txt, txt, txt, txt)
}
err = model.Order(fmt.Sprintf("created_at %s", reqBody.Orderby)).
Limit(reqBody.Pagesize).
Find(&events).Error
if err != nil && err != sql.ErrNoRows {
ctx.respDBError(err)
return
}
ctx.respSucc("", map[string]interface{}{
"list": events,
"pagesize": reqBody.Pagesize,
})
}
func GetJobHistory(ctx *myctx) {
var (
err error
reqBody ReadMoreReqParams
historys []models.JobHistory
addrs []string
model = models.DB()
isSuper = ctx.isSuper()
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if addrs, err = ctx.getGroupAddr(); err != nil {
ctx.respError(proto.Code_Error, err.Error(), err)
return
}
if reqBody.Keywords != "" {
txt := "%" + reqBody.Keywords + "%"
model = model.Where("(job_name like ? or addr like ? or exit_msg like ?)",
txt, txt, txt)
}
if reqBody.LastID == 0 {
if !isSuper {
model = model.Where("addr in (?)", addrs)
}
err = model.Order(fmt.Sprintf("created_at %s", reqBody.Orderby)).
Limit(reqBody.Pagesize).
Find(&historys).Error
} else {
if !isSuper {
model = model.Where("addr in (?) and id<?", addrs, reqBody.LastID)
} else {
model = model.Where("id<?", reqBody.LastID)
}
err = model.Where("addr in (?) and id<?", addrs, reqBody.LastID).
Order(fmt.Sprintf("created_at %s", reqBody.Orderby)).
Limit(reqBody.Pagesize).
Find(&historys).Error
}
if err != nil && err != sql.ErrNoRows {
ctx.respDBError(err)
return
}
ctx.respSucc("", map[string]interface{}{
"list": historys,
"pagesize": reqBody.Pagesize,
})
}
func AuditJob(ctx *myctx) {
var (
err error
reqBody AuditJobReqParams
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if !ctx.verifyNodePermission(reqBody.Addr) {
ctx.respNotAllowed()
return
}
if ctx.claims.GroupID != models.SuperGroup.ID && !ctx.claims.Root {
ctx.respNotAllowed()
return
}
if reqBody.JobType == "crontab" {
var reply []models.CrontabJob
if err = rpcCall(reqBody.Addr, "CrontabJob.Audit", proto.AuditJobArgs{
Root: ctx.claims.Root,
GroupID: ctx.claims.GroupID,
JobIDs: reqBody.JobIDs,
}, &reply); err != nil {
ctx.respRPCError(err)
return
}
var targetNames []string
for _, v := range reply {
targetNames = append(targetNames, v.Name)
}
ctx.pubEvent(strings.Join(targetNames, ","), event_AuditCrontabJob, models.EventSourceName(reqBody.Addr), reqBody)
} else {
var reply []models.DaemonJob
if err = rpcCall(reqBody.Addr, "DaemonJob.Audit", proto.AuditJobArgs{
Root: ctx.claims.Root,
GroupID: ctx.claims.GroupID,
JobIDs: reqBody.JobIDs,
}, &reply); err != nil {
ctx.respRPCError(err)
return
}
var targetNames []string
for _, v := range reply {
targetNames = append(targetNames, v.Name)
}
ctx.pubEvent(strings.Join(targetNames, ","), event_AuditDaemonJob, models.EventSourceName(reqBody.Addr), reqBody)
}
ctx.respSucc("", nil)
}
// Signup 注册新用户
func Signup(ctx *myctx) {
var (
err error
user models.User
reqBody UserReqParams
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
if reqBody.GroupName != "" {
group := models.Group{
Name: reqBody.GroupName,
}
if err = models.DB().Save(&group).Error; err != nil {
ctx.respDBError(err)
return
}
reqBody.GroupID = group.ID
}
user.Username = reqBody.Username
user.Passwd = reqBody.Passwd
user.GroupID = reqBody.GroupID
user.Root = reqBody.Root
user.Avatar = reqBody.Avatar
user.Mail = reqBody.Mail
reqBody.Passwd = ""
if user.GroupID == models.SuperGroup.ID {
user.Root = true
}
if err = user.Create(); err != nil {
ctx.respDBError(err)
return
}
ctx.pubEvent(user.Username, event_SignUpUser, "", reqBody)
ctx.respSucc("", true)
}
func EditUser(ctx *myctx) {
var (
err error
user models.User
reqBody EditUserReqParams
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
// change password
if reqBody.OldPwd != "" && reqBody.Passwd != "" {
if !user.VerifyByUserId(reqBody.UserID, reqBody.OldPwd) {
ctx.respParamError(errors.New("密码错误"))
return
}
}
user.ID = reqBody.UserID
user.Passwd = reqBody.Passwd
user.Avatar = reqBody.Avatar
user.Mail = reqBody.Mail
if err = user.Update(); err != nil {
ctx.respDBError(err)
return
}
ctx.pubEvent(user.Username, event_EditUser, "", reqBody)
ctx.respSucc("", true)
}
func DeleteUser(ctx *myctx) {
var (
err error
user models.User
reqBody DeleteUserReqParams
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
return
}
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
user.ID = reqBody.UserID
if err = user.Delete(); err != nil {
ctx.respDBError(err)
return
}
ctx.pubEvent(user.Username, event_DeleteUser, "", reqBody)
ctx.respSucc("", true)
}
// UserStat 统计信息
func UserStat(ctx *myctx) {
var (
err error
auditNumStat struct {
CrontabJobAuditNum uint
DaemonJobAuditNum uint
CrontabJobFailNum uint
DaemonTaskNum uint
CrontabTaskNum uint
NodeNum uint
}
cfg = ctx.adm.getOpts()
)
err = models.DB().Raw(
`select
sum(crontab_job_audit_num) as crontab_job_audit_num,
sum(daemon_job_audit_num) as daemon_job_audit_num,
sum(crontab_job_fail_num) as crontab_job_fail_num,
sum(daemon_task_num) as daemon_task_num,
sum(crontab_task_num) as crontab_task_num,
count(*) as node_num
from nodes
where group_id=? and deleted_at is null`, ctx.claims.GroupID).Scan(&auditNumStat).Error
if err != nil {
ctx.respDBError(err)
return
}
ctx.respSucc("", map[string]interface{}{
"systemInfo": util.SystemInfo(cfg.ServerStartTime),
"auditStat": auditNumStat,
"version": version.String(cfg.App.AppName),
})
}
// GroupUser 超级管理员设置普通用户分组
func GroupUser(ctx *myctx) {
var (
reqBody SetGroupReqParams
err error
user models.User
group models.Group
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respBasicError(err)
return
}
if !ctx.isSuper() {
ctx.respNotAllowed()
return
}
if reqBody.TargetGroupName != "" {
group.Name = reqBody.TargetGroupName
if err = models.DB().Save(&group).Error; err != nil {
ctx.respDBError(err)
return
}
reqBody.TargetGroupID = group.ID
}
user.ID = reqBody.UserID
user.GroupID = reqBody.TargetGroupID
if reqBody.TargetGroupID == models.SuperGroup.ID {
user.Root = true
} else {
user.Root = reqBody.Root
}
if err = user.SetGroup(&group); err != nil {
ctx.respDBError(err)
return
}
ctx.pubEvent(group.Name, event_GroupUser, models.EventSourceUsername(user.Username), reqBody)
ctx.respSucc("", nil)
}
// GetUserList 获得用户列表
// 支持获得全部用户,所属分组用户,指定分组用户(超级管理员)
func GetUserList(ctx *myctx) {
var (
reqBody GetUsersParams
userList []models.User
err error
total int64
)
if err = ctx.Valid(&reqBody); err != nil {
ctx.respParamError(err)
}
if reqBody.IsAll && ctx.claims.GroupID != models.SuperGroup.ID {
ctx.respNotAllowed()
return
}
if !reqBody.IsAll && reqBody.QueryGroupID != ctx.claims.GroupID && ctx.claims.GroupID != models.SuperGroup.ID {
ctx.respNotAllowed()
return
}
if reqBody.QueryGroupID == 0 {
reqBody.QueryGroupID = ctx.claims.GroupID
}
m := models.DB().Model(&models.User{})
if reqBody.IsAll {
err = m.Where("username like ?", "%"+reqBody.SearchTxt+"%").Count(&total).Error
} else {
err = m.Where("group_id=? and username like ?", reqBody.QueryGroupID, "%"+reqBody.SearchTxt+"%").Count(&total).Error
}
if err != nil && err != sql.ErrNoRows {
ctx.respBasicError(err)
return
}
if reqBody.IsAll {
err = models.DB().Preload("Group").Where("username like ?", "%"+reqBody.SearchTxt+"%").Order("id desc").Offset((reqBody.Page - 1) * reqBody.Pagesize).Limit(reqBody.Pagesize).Find(&userList).Error
} else {
err = models.DB().Preload("Group").Where("group_id=? and username like ?", reqBody.QueryGroupID, "%"+reqBody.SearchTxt+"%").Offset((reqBody.Page - 1) * reqBody.Pagesize).Limit(reqBody.Pagesize).Find(&userList).Error
}
if err != nil && err != sql.ErrNoRows {
ctx.respDBError(err)
return
}
ctx.respSucc("", map[string]interface{}{
"list": userList,
"total": total,
"page": reqBody.Page,
"pagesize": reqBody.Pagesize,
})
}

75
jiacrontab_admin/util.go Normal file
View File

@@ -0,0 +1,75 @@
package admin
import (
"errors"
"jiacrontab/models"
"jiacrontab/pkg/rpc"
"reflect"
"strings"
"github.com/iwannay/log"
)
func rpcCall(addr string, serviceMethod string, args interface{}, reply interface{}) error {
err := rpc.Call(addr, serviceMethod, args, reply)
if err != nil {
log.Errorf("rpcCall(%s->%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
}

331
jiacrontabd/cmd.go Normal file
View File

@@ -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
}

110
jiacrontabd/config.go Normal file
View File

@@ -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
}

1
jiacrontabd/const.go Normal file
View File

@@ -0,0 +1 @@
package jiacrontabd

275
jiacrontabd/daemon.go Normal file
View File

@@ -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<br/>创建者:%s<br/>开始时间:%s<br/>异常:%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()
}

122
jiacrontabd/dependencies.go Normal file
View File

@@ -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)
}
}
}

489
jiacrontabd/jiacrontabd.go Normal file
View File

@@ -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))
}

604
jiacrontabd/job.go Normal file
View File

@@ -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<br/>创建者:%s<br/>开始时间:%s<br/>耗时:%.4f<br/>异常:%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<br/>创建者:%s<br/>开始时间:%s<br/>异常:%s<br/>重试次数:%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<br/>创建者:%v<br/>开始时间:%s<br/>超时:%ds<br/>重试次数:%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()
}

571
jiacrontabd/srv.go Normal file
View File

@@ -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
}

83
jiacrontabd/util.go Normal file
View File

@@ -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
}

222
models/crontab.go Normal file
View File

@@ -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
}

9
models/crontab_test.go Normal file
View File

@@ -0,0 +1,9 @@
package models
import (
"testing"
)
func TestStringSlice_Value(t *testing.T) {
}

33
models/daemon.go Normal file
View File

@@ -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"`
}

115
models/db.go Normal file
View File

@@ -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)
}
}

29
models/event.go Normal file
View File

@@ -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)
}
}

24
models/group.go Normal file
View File

@@ -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 = "超级管理员"
}

33
models/history.go Normal file
View File

@@ -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)
}
}

96
models/node.go Normal file
View File

@@ -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
}

13
models/setting.go Normal file
View File

@@ -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"` // 配置内容
}

116
models/user.go Normal file
View File

@@ -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
}

278
pkg/base/stat.go Normal file
View File

@@ -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)
}

53
pkg/base/storage.go Normal file
View File

@@ -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
}

88
pkg/crontab/crontab.go Normal file
View File

@@ -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
}
}
}

View File

@@ -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)
}

213
pkg/crontab/job.go Normal file
View File

@@ -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<<uint(t.Month())&j.month == 0 {
// If we have to add a month, reset the other parts to 0.
if !added {
added = true
// Otherwise, set the date at the beginning (since the current time is irrelevant).
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
t = t.AddDate(0, 1, 0)
// Wrapped around.
if t.Month() == time.January {
goto WRAP
}
}
// Now get a day in that month.
for !dayMatches(j, t) {
if !added {
added = true
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
t = t.AddDate(0, 0, 1)
if t.Day() == 1 {
goto WRAP
}
}
for 1<<uint(t.Hour())&j.hour == 0 {
if !added {
added = true
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
}
t = t.Add(1 * time.Hour)
if t.Hour() == 0 {
goto WRAP
}
}
for 1<<uint(t.Minute())&j.minute == 0 {
if !added {
added = true
t = t.Truncate(time.Minute)
}
t = t.Add(1 * time.Minute)
if t.Minute() == 0 {
goto WRAP
}
}
for 1<<uint(t.Second())&j.second == 0 {
if !added {
added = true
t = t.Truncate(time.Second)
}
t = t.Add(1 * time.Second)
if t.Second() == 0 {
goto WRAP
}
}
return t, nil
}
func dayMatches(j *Job, t time.Time) bool {
if j.Day == "L" {
l := util.CountDaysOfMonth(t.Year(), int(t.Month()))
j.dom = getBits(uint(l), uint(l), 1)
}
var (
domMatch bool = 1<<uint(t.Day())&j.dom > 0
dowMatch bool = 1<<uint(t.Weekday())&j.dow > 0
)
if j.dom&starBit > 0 || j.dow&starBit > 0 {
return domMatch && dowMatch
}
return domMatch || dowMatch
}

70
pkg/crontab/job_test.go Normal file
View File

@@ -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")
}

128
pkg/crontab/parse.go Normal file
View File

@@ -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
}

134
pkg/file/file.go Normal file
View File

@@ -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
}

220
pkg/finder/finder.go Normal file
View File

@@ -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
})
}
}
}

53
pkg/finder/reader.go Normal file
View File

@@ -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]
}
}

38
pkg/kproc/proc.go Normal file
View File

@@ -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
}

78
pkg/kproc/proc_posix.go Normal file
View File

@@ -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()
}

51
pkg/kproc/proc_windows.go Normal file
View File

@@ -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()
}

31
pkg/mailer/login.go Normal file
View File

@@ -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
}

204
pkg/mailer/mail.go Normal file
View File

@@ -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
}

75
pkg/pprof/pprof.go Normal file
View File

@@ -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)
}
}

20
pkg/pprof/pprof_posix.go Normal file
View File

@@ -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()
}
}

View File

@@ -0,0 +1,4 @@
package pprof
func listenSignal() {
}

89
pkg/pqueue/pqueue.go Normal file
View File

@@ -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
}

79
pkg/pqueue/pqueue_test.go Normal file
View File

@@ -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
}
}

18
pkg/proto/apicode.go Normal file
View File

@@ -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"
)

66
pkg/proto/args.go Normal file
View File

@@ -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{}

10
pkg/proto/const.go Normal file
View File

@@ -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"
)

70
pkg/proto/crontab.go Normal file
View File

@@ -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
}

12
pkg/proto/daemon.go Normal file
View File

@@ -0,0 +1,12 @@
package proto
import (
"jiacrontab/models"
)
type EditDaemonJobArgs struct {
Job models.DaemonJob
GroupID uint
UserID uint
Root bool
}

14
pkg/proto/resp.go Normal file
View File

@@ -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"`
}

111
pkg/rpc/client.go Normal file
View File

@@ -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()
}
}

66
pkg/rpc/client_test.go Normal file
View File

@@ -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)
}

86
pkg/rpc/clients.go Normal file
View File

@@ -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),
}
}

41
pkg/rpc/server.go Normal file
View File

@@ -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)
}
}

58
pkg/test/assertions.go Normal file
View File

@@ -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 <nil> (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 <nil>\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
}

45
pkg/test/fakes.go Normal file
View File

@@ -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 },
}
}

22
pkg/test/logger.go Normal file
View File

@@ -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}
}

10
pkg/util/arr.go Normal file
View File

@@ -0,0 +1,10 @@
package util
func FilterEmptyEle(in []string) (out []string) {
for _, v := range in {
if v != "" {
out = append(out, v)
}
}
return
}

173
pkg/util/fn.go Normal file
View File

@@ -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())
}

30
pkg/util/ip.go Normal file
View File

@@ -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 ""
}

18
pkg/util/time.go Normal file
View File

@@ -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
}

View File

@@ -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()
}()
}

12
pkg/version/ver.go Normal file
View File

@@ -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())
}

BIN
qq.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB