├── .dependabot └── config.yml ├── .dockerignore ├── .drone.yml ├── .github └── workflows │ └── releasecharts.yaml ├── .gitignore ├── .pipeline └── cloudbuild.yaml ├── .scripts ├── gen_packages.sh ├── index_repo.sh └── repo-sync.sh ├── .test ├── ct.yaml └── e2e-kind.sh ├── Dockerfile ├── Dockerfile.aarch64 ├── Dockerfile.armhf ├── Dockerfile.debian ├── Dockerfile.debug ├── Dockerfile.local ├── Dockerfile.tests ├── LICENSE ├── Makefile ├── approvals ├── approvals.go └── approvals_test.go ├── azure-pipelines.yml ├── bot ├── approvals.go ├── bot.go ├── deployments.go ├── formatter │ ├── approvals.go │ ├── custom.go │ ├── deployments.go │ ├── formatter.go │ └── reflect.go ├── hipchat │ ├── approvals.go │ ├── client.go │ ├── hipchat.go │ ├── hipchat_test.go │ └── templates.go └── slack │ ├── approvals.go │ ├── slack.go │ └── slack_test.go ├── build.ps1 ├── chart └── keel │ ├── .helmignore │ ├── Chart.yaml │ ├── OWNERS │ ├── README.md │ ├── security-mitigation.yaml │ ├── templates │ ├── 00-namespace.yaml │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── clusterrole.yaml │ ├── clusterrolebinding.yaml │ ├── deployment.yaml │ ├── ingress.yaml │ ├── pod-disruption-budget.yaml │ ├── pvc.yaml │ ├── secret.yaml │ ├── secrets-webhookrelay.yaml │ ├── service-account.yaml │ └── service.yaml │ └── values.yaml ├── cmd └── keel │ └── main.go ├── compose.debug.yml ├── compose.tests.yml ├── compose.yml ├── constants └── constants.go ├── deployment ├── README.md └── deployment-template.yaml ├── envsettings.ps1.template ├── extension ├── approval │ └── approval_collector.go ├── credentialshelper │ ├── aws │ │ ├── aws.go │ │ ├── aws_test.go │ │ ├── cache.go │ │ └── cache_test.go │ ├── credentialshelper.go │ ├── gcr │ │ └── gcr.go │ └── secrets │ │ └── secrets.go └── notification │ ├── auditor │ └── auditor.go │ ├── discord │ ├── discord.go │ └── discord_test.go │ ├── hipchat │ └── hipchat.go │ ├── mail │ └── mail.go │ ├── mattermost │ └── mattermost.go │ ├── notification.go │ ├── notification_test.go │ ├── slack │ └── slack.go │ ├── teams │ ├── teams.go │ └── teams_test.go │ └── webhook │ ├── webhook.go │ └── webhook_test.go ├── go.mod ├── go.sum ├── helpers.ps1 ├── internal ├── k8s │ ├── cache.go │ ├── cache_test.go │ ├── converter.go │ ├── resource.go │ ├── resource_test.go │ ├── translator.go │ └── watcher.go ├── policy │ ├── force.go │ ├── glob.go │ ├── glob_test.go │ ├── policy.go │ ├── policy_test.go │ ├── regexp.go │ ├── semver.go │ ├── semver_test.go │ └── semverpolicytype_jsonenums.go └── workgroup │ └── workgroup.go ├── pkg ├── auth │ ├── auth.go │ └── ctx.go ├── http │ ├── approvals_endpoint.go │ ├── approvals_endpoint_test.go │ ├── audit_endpoint.go │ ├── auth.go │ ├── azure_webhook_trigger.go │ ├── azure_webhook_trigger_test.go │ ├── debug.go │ ├── dockerhub_webhook_trigger.go │ ├── dockerhub_webhook_trigger_test.go │ ├── github_webhook_trigger.go │ ├── github_webhook_trigger_test.go │ ├── harbor_webhook_trigger.go │ ├── harbor_webhook_trigger_test.go │ ├── http.go │ ├── jfrog_webhook_trigger.go │ ├── jfrog_webhook_trigger_test.go │ ├── native_webhook_trigger.go │ ├── native_webhook_trigger_test.go │ ├── policy_endpoint.go │ ├── quay_webhook_trigger.go │ ├── quay_webhook_trigger_test.go │ ├── registry_notifications.go │ ├── registry_notifications_test.go │ ├── resources_endpoint.go │ ├── stats_endpoint.go │ └── tracked_endpoint.go └── store │ ├── sql │ ├── approvals.go │ ├── audit.go │ └── sql.go │ └── store.go ├── provider ├── helm3 │ ├── approvals.go │ ├── common.go │ ├── common_test.go │ ├── helm3.go │ ├── helm3_test.go │ ├── implementer.go │ ├── implementer_test.go │ ├── updates.go │ └── updates_test.go ├── kubernetes │ ├── approvals.go │ ├── approvals_test.go │ ├── implementer.go │ ├── kubernetes.go │ ├── kubernetes_test.go │ ├── updates.go │ └── updates_test.go └── provider.go ├── readme.md ├── registry ├── docker │ ├── json.go │ ├── manifest.go │ ├── manifest_test.go │ ├── registry.go │ ├── tags.go │ └── tags_test.go ├── registry.go └── registry_test.go ├── secrets ├── match.go ├── match_test.go ├── secrets.go └── secrets_test.go ├── static ├── keel-logo.png ├── keel-ui.png └── keel.png ├── tests ├── acceptance_polling_test.go ├── acceptance_test.go └── helpers.go ├── trigger ├── poll │ ├── manager.go │ ├── manager_test.go │ ├── multi_tags_watcher.go │ ├── multi_tags_watcher_test.go │ ├── single_tag_watcher.go │ ├── watcher.go │ └── watcher_test.go └── pubsub │ ├── manager.go │ ├── manager_test.go │ ├── pubsub.go │ ├── pubsub_test.go │ ├── util.go │ └── util_test.go ├── types ├── approvals.go ├── audit.go ├── level_jsonenums.go ├── notification_jsonenums.go ├── policytype_jsonenums.go ├── providertype_jsonenums.go ├── tracked_images.go ├── triggertype_jsonenums.go ├── types.go ├── types_test.go └── version_info.go ├── ui ├── .editorconfig ├── .env ├── .gitattributes ├── .gitignore ├── .prettierrc ├── .travis.yml ├── README.md ├── babel.config.js ├── docs │ ├── add-page-loading-animate.md │ ├── load-on-demand.md │ ├── multi-tabs.md │ └── webpack-bundle-analyzer.md ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── color.less │ ├── index.html │ ├── loading │ │ ├── loading.css │ │ ├── loading.html │ │ └── option2 │ │ │ ├── html_code_segment.html │ │ │ ├── loading.css │ │ │ └── loading.svg │ └── logo.png ├── src │ ├── App.vue │ ├── api │ │ └── index.js │ ├── assets │ │ ├── background.svg │ │ ├── icons │ │ │ └── bx-analyse.svg │ │ ├── keel-logo.png │ │ ├── keel-logo.svg │ │ ├── logo.png │ │ └── logo.svg │ ├── components │ │ ├── ArticleListContent │ │ │ ├── ArticleListContent.vue │ │ │ └── index.js │ │ ├── AvatarList │ │ │ ├── Item.vue │ │ │ ├── List.vue │ │ │ ├── index.js │ │ │ ├── index.less │ │ │ └── index.md │ │ ├── Charts │ │ │ ├── Bar.vue │ │ │ ├── ChartCard.vue │ │ │ ├── Liquid.vue │ │ │ ├── MiniArea.vue │ │ │ ├── MiniBar.vue │ │ │ ├── MiniProgress.vue │ │ │ ├── MiniSmoothArea.vue │ │ │ ├── Radar.vue │ │ │ ├── RankList.vue │ │ │ ├── TagCloud.vue │ │ │ ├── TransferBar.vue │ │ │ ├── Trend.vue │ │ │ ├── chart.less │ │ │ └── smooth.area.less │ │ ├── CountDown │ │ │ ├── CountDown.vue │ │ │ ├── index.js │ │ │ └── index.md │ │ ├── DescriptionList │ │ │ ├── DescriptionList.vue │ │ │ └── index.js │ │ ├── Ellipsis │ │ │ ├── Ellipsis.vue │ │ │ ├── index.js │ │ │ └── index.md │ │ ├── Exception │ │ │ ├── ExceptionPage.vue │ │ │ ├── index.js │ │ │ └── type.js │ │ ├── FooterToolbar │ │ │ ├── FooterToolBar.vue │ │ │ ├── index.js │ │ │ ├── index.less │ │ │ └── index.md │ │ ├── GlobalFooter │ │ │ ├── GlobalFooter.vue │ │ │ └── index.js │ │ ├── GlobalHeader │ │ │ ├── GlobalHeader.vue │ │ │ └── index.js │ │ ├── IconSelector │ │ │ ├── IconSelector.vue │ │ │ ├── README.md │ │ │ ├── icons.js │ │ │ └── index.js │ │ ├── Menu │ │ │ ├── SideMenu.vue │ │ │ ├── index.js │ │ │ ├── menu.js │ │ │ └── menu.render.js │ │ ├── MultiTab │ │ │ ├── MultiTab.vue │ │ │ ├── index.js │ │ │ └── index.less │ │ ├── NoticeIcon │ │ │ ├── NoticeIcon.vue │ │ │ └── index.js │ │ ├── NumberInfo │ │ │ ├── NumberInfo.vue │ │ │ ├── index.js │ │ │ ├── index.less │ │ │ └── index.md │ │ ├── PageHeader │ │ │ ├── PageHeader.vue │ │ │ └── index.js │ │ ├── PageLoading │ │ │ └── index.jsx │ │ ├── Result │ │ │ ├── Result.vue │ │ │ └── index.js │ │ ├── SettingDrawer │ │ │ ├── SettingDrawer.vue │ │ │ ├── SettingItem.vue │ │ │ ├── index.js │ │ │ └── settingConfig.js │ │ ├── StandardFormRow │ │ │ ├── StandardFormRow.vue │ │ │ └── index.js │ │ ├── Table │ │ │ ├── README.md │ │ │ └── index.js │ │ ├── TagSelect │ │ │ ├── TagSelectOption.jsx │ │ │ └── index.jsx │ │ ├── Tree │ │ │ └── Tree.jsx │ │ ├── Trend │ │ │ ├── Trend.vue │ │ │ ├── index.js │ │ │ ├── index.less │ │ │ └── index.md │ │ ├── _util │ │ │ └── util.js │ │ ├── global.less │ │ ├── index.js │ │ ├── index.less │ │ └── tools │ │ │ ├── Breadcrumb.vue │ │ │ ├── DetailList.vue │ │ │ ├── HeadInfo.vue │ │ │ ├── Logo.vue │ │ │ ├── TwoStepCaptcha.vue │ │ │ ├── UserMenu.vue │ │ │ └── index.js │ ├── config │ │ ├── defaultDarkModeSettings.js │ │ ├── defaultSettings.js │ │ └── router.config.js │ ├── core │ │ ├── bootstrap.js │ │ ├── directives │ │ │ └── action.js │ │ ├── icons.js │ │ ├── lazy_lib │ │ │ └── components_use.js │ │ ├── lazy_use.js │ │ └── use.js │ ├── layouts │ │ ├── BasicLayout.vue │ │ ├── BlankLayout.vue │ │ ├── PageView.vue │ │ ├── RouteView.vue │ │ ├── UserLayout.vue │ │ └── index.js │ ├── main.js │ ├── permission.js │ ├── router │ │ ├── README.md │ │ └── index.js │ ├── store │ │ ├── getters.js │ │ ├── index.js │ │ ├── modules │ │ │ ├── app.js │ │ │ ├── approvals.js │ │ │ ├── audit.js │ │ │ ├── permission.js │ │ │ ├── resources.js │ │ │ ├── stats.js │ │ │ ├── tracked.js │ │ │ └── user.js │ │ └── mutation-types.js │ ├── utils │ │ ├── device.js │ │ ├── domUtil.js │ │ ├── filter.js │ │ ├── helper │ │ │ └── permission.js │ │ ├── mixin.js │ │ ├── permissions.js │ │ ├── storage.js │ │ ├── util.js │ │ └── utils.less │ └── views │ │ ├── 404.vue │ │ ├── approvals │ │ └── Approvals.vue │ │ ├── audit │ │ └── AuditLogs.vue │ │ ├── dashboard │ │ └── Analysis.vue │ │ ├── exception │ │ ├── 403.vue │ │ ├── 404.vue │ │ └── 500.vue │ │ ├── tracked │ │ └── TrackedImageList.vue │ │ └── user │ │ └── Login.vue ├── tests │ └── unit │ │ └── .eslintrc.js ├── vue.config.js ├── webstorm.config.js └── yarn.lock ├── util ├── codecs │ └── codecs.go ├── image │ ├── parse.go │ ├── parse_test.go │ ├── reference.go │ └── validation.go ├── policies │ └── policies.go ├── stopper │ └── stopper.go ├── templates │ └── templates.go ├── testing │ └── testing.go ├── timeutil │ ├── backoff.go │ ├── backoff_test.go │ └── now.go └── version │ ├── version.go │ └── version_test.go ├── values.yaml └── version └── keel.go /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | 2 | version: 1 3 | update_configs: 4 | - package_manager: "javascript" 5 | directory: "/ui" 6 | update_schedule: "daily" 7 | - package_manager: "go:modules" 8 | directory: "/" 9 | update_schedule: "daily" 10 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.git 2 | envsettings.ps1 3 | envsettings.ps1.template 4 | helpers.ps1 5 | LICENSE 6 | compose* 7 | build.ps1 8 | azure-pipelines.yml 9 | .gitignore 10 | .drone.yml 11 | readme.md 12 | serviceaccount/* 13 | chart/* 14 | Dockerfile* 15 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | workspace: 5 | base: /go 6 | path: src/github.com/keel-hq/keel 7 | 8 | steps: 9 | - name: unit-test 10 | image: golang:1.23.4 11 | commands: 12 | - make test 13 | 14 | - name: build 15 | image: golang:1.23.4 16 | commands: 17 | - make install 18 | 19 | - name: build-ui 20 | image: node:16.20.2-alpine 21 | commands: 22 | - cd ui 23 | - yarn 24 | - yarn run lint --no-fix 25 | 26 | - name: publish-image 27 | image: plugins/docker 28 | settings: 29 | repo: keelhq/keel 30 | auto_tag: true 31 | username: 32 | from_secret: docker_username 33 | password: 34 | from_secret: docker_password 35 | 36 | # - name: build-arm 37 | # image: golang:1.14.2 38 | # commands: 39 | # - make arm 40 | 41 | # - name: publish-arm-image 42 | # image: plugins/docker 43 | # settings: 44 | # repo: keelhq/keel-arm 45 | # dockerfile: dockerfile.armhf 46 | # auto_tag: true 47 | # username: 48 | # from_secret: docker_username 49 | # password: 50 | # from_secret: docker_password 51 | 52 | # - name: publish-aarch64-image 53 | # image: plugins/docker 54 | # settings: 55 | # repo: keelhq/keel-aarch64 56 | # dockerfile: dockerfile.aarch64 57 | # auto_tag: true 58 | # username: 59 | # from_secret: docker_username 60 | # password: 61 | # from_secret: docker_password 62 | 63 | - name: slack 64 | image: plugins/slack 65 | when: 66 | status: [ success, failure ] 67 | settings: 68 | webhook: 69 | from_secret: slack_url 70 | channel: general 71 | username: drone 72 | icon_url: https://i.pinimg.com/originals/51/29/a4/5129a48ddad9e8408d2757dd10eb836f.jpg 73 | -------------------------------------------------------------------------------- /.github/workflows/releasecharts.yaml: -------------------------------------------------------------------------------- 1 | name: Release Charts 2 | on: 3 | push: 4 | tags: 5 | - "chart-*" 6 | jobs: 7 | release: 8 | # depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions 9 | # see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token 10 | permissions: 11 | contents: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | - name: Configure Git 19 | run: | 20 | git config user.name "$GITHUB_ACTOR" 21 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 22 | - name: Install Helm 23 | uses: azure/setup-helm@v4.2.0 24 | - name: Extract tag version 25 | id: get_version 26 | run: echo "version=${GITHUB_REF##*/chart-}" >> $GITHUB_ENV 27 | - name: Update Chart.yaml version 28 | run: | 29 | sed -i "s/^version:.*/version: ${GITHUB_ENV_VERSION}/" chart/keel/Chart.yaml 30 | env: 31 | GITHUB_ENV_VERSION: ${{ env.version }} 32 | - name: Run chart-releaser 33 | uses: helm/chart-releaser-action@v1.6.0 34 | with: 35 | charts_dir: chart 36 | env: 37 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .kubeconfig 2 | deployment.yml 3 | .test_env.sh 4 | cmd/keel/release 5 | cmd/keel/keel 6 | hack/deployment-norbac.yaml 7 | hack/deployment-rbac.yaml 8 | hack/deployment-norbac-helm.yaml 9 | .vscode 10 | .idea/ 11 | tests.out 12 | envsettings.ps1 13 | serviceaccount/* 14 | cmd/keel/keel.exe 15 | test_results.xml -------------------------------------------------------------------------------- /.pipeline/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | 3 | # Generate Helm packages 4 | - name: 'ubuntu' 5 | args: ['bash', './.scripts/gen_packages.sh'] 6 | id: 'gen_packages' 7 | 8 | # Fetch charts and index.yaml from GCS bucket 9 | - name: 'gcr.io/cloud-builders/gsutil' 10 | args: ['rsync', 'gs://charts.keel.sh', 'temp/'] 11 | id: 'fetch_charts_index' 12 | 13 | # Index repository 14 | - name: 'ubuntu' 15 | env: 16 | - 'REPO_URL=https://charts.keel.sh' 17 | args: ['bash', './.scripts/index_repo.sh'] 18 | 19 | # Upload charts to GCS bucket 20 | - name: gcr.io/cloud-builders/gsutil 21 | args: ['rsync', 'temp/', 'gs://charts.keel.sh'] 22 | id: 'upload_charts' 23 | -------------------------------------------------------------------------------- /.scripts/gen_packages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo "Installing curl" 5 | apt update 6 | apt install curl -y 7 | 8 | echo "Installing helm" 9 | curl https://raw.githubusercontent.com/kubernetes/helm/master/scripts/get | bash 10 | helm init -c 11 | 12 | echo "Packaging charts from source code" 13 | mkdir -p temp 14 | for d in chart/* 15 | do 16 | # shellcheck disable=SC3010 17 | if [[ -d $d ]] 18 | then 19 | # Will generate a helm package per chart in a folder 20 | echo "$d" 21 | helm package "$d" 22 | # shellcheck disable=SC2035 23 | mv *.tgz temp/ 24 | fi 25 | done 26 | -------------------------------------------------------------------------------- /.scripts/index_repo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | echo "Installing curl" 5 | apt update 6 | apt install curl -y 7 | 8 | echo "Installing helm" 9 | curl https://raw.githubusercontent.com/kubernetes/helm/master/scripts/get | bash 10 | helm init -c 11 | 12 | echo "Indexing repository" 13 | if [ -f index.yaml ]; then 14 | helm repo index --url "${REPO_URL}" --merge index.yaml ./temp 15 | else 16 | helm repo index --url "${REPO_URL}" ./temp 17 | fi -------------------------------------------------------------------------------- /.scripts/repo-sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # the repo path to this repository 5 | REPO_URL="https://charts.keel.sh" 6 | 7 | function gen_packages() { 8 | echo "Packaging charts from source code" 9 | mkdir -p temp 10 | for d in chart/* 11 | do 12 | if [[ -d $d ]] 13 | then 14 | # Will generate a helm package per chart in a folder 15 | echo "$d" 16 | helm package "$d" 17 | # shellcheck disable=SC2035 18 | mv *.tgz temp/ 19 | fi 20 | done 21 | } 22 | 23 | function index() { 24 | echo "Fetch charts and index.yaml" 25 | gsutil rsync gs://charts.keel.sh ./temp/ 26 | 27 | echo "Indexing repository" 28 | if [ -f index.yaml ]; then 29 | helm repo index --url ${REPO_URL} --merge index.yaml ./temp 30 | else 31 | helm repo index --url ${REPO_URL} ./temp 32 | fi 33 | } 34 | 35 | function upload() { 36 | echo "Upload charts to GCS bucket" 37 | gsutil rsync ./temp/ gs://charts.keel.sh 38 | } 39 | 40 | # generate helm chart packages 41 | gen_packages 42 | 43 | # create index 44 | index 45 | 46 | # upload to GCS bucket 47 | upload 48 | -------------------------------------------------------------------------------- /.test/ct.yaml: -------------------------------------------------------------------------------- 1 | remote: k8s 2 | target-branch: master 3 | chart-dirs: 4 | - chart 5 | excluded-charts: 6 | - common 7 | helm-extra-args: --timeout 800s 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.4 as go-build 2 | COPY . /go/src/github.com/keel-hq/keel 3 | WORKDIR /go/src/github.com/keel-hq/keel 4 | RUN make install 5 | 6 | FROM node:16.20.2-alpine as yarn-build 7 | WORKDIR /app 8 | COPY ui /app 9 | RUN yarn 10 | RUN yarn run lint --no-fix 11 | RUN yarn run build 12 | 13 | FROM alpine:3.20.3 14 | ARG USERNAME=keel 15 | ARG USER_ID=666 16 | ARG GROUP_ID=$USER_ID 17 | 18 | RUN apk --no-cache add ca-certificates 19 | RUN addgroup --gid $GROUP_ID $USERNAME \ 20 | && adduser --home /data --ingroup $USERNAME --disabled-password --uid $USER_ID $USERNAME \ 21 | && mkdir -p /data && chown $USERNAME:0 /data && chmod g=u /data 22 | 23 | COPY --from=go-build /go/bin/keel /bin/keel 24 | COPY --from=yarn-build /app/dist /www 25 | 26 | USER $USER_ID 27 | 28 | VOLUME /data 29 | ENV XDG_DATA_HOME /data 30 | 31 | ENTRYPOINT ["/bin/keel"] 32 | EXPOSE 9300 33 | -------------------------------------------------------------------------------- /Dockerfile.aarch64: -------------------------------------------------------------------------------- 1 | FROM arm64v8/alpine:3.8 2 | ADD ca-certificates.crt /etc/ssl/certs/ 3 | COPY cmd/keel/release/keel-linux-aarch64 /bin/keel 4 | ENTRYPOINT ["/bin/keel"] -------------------------------------------------------------------------------- /Dockerfile.armhf: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine as ui 2 | WORKDIR /app 3 | COPY ui /app 4 | RUN yarn 5 | RUN yarn run lint --no-fix 6 | RUN yarn run build 7 | 8 | FROM arm32v7/debian:buster 9 | ADD ca-certificates.crt /etc/ssl/certs/ 10 | COPY cmd/keel/release/keel-linux-arm /bin/keel 11 | COPY --from=ui /app/dist /www 12 | VOLUME /data 13 | ENV XDG_DATA_HOME /data 14 | 15 | EXPOSE 9300 16 | ENTRYPOINT ["/bin/keel"] -------------------------------------------------------------------------------- /Dockerfile.debian: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.4 2 | COPY . /go/src/github.com/keel-hq/keel 3 | WORKDIR /go/src/github.com/keel-hq/keel 4 | RUN make build 5 | 6 | FROM debian:latest 7 | ARG USERNAME=keel 8 | ARG USER_ID=666 9 | ARG GROUP_ID=$USER_ID 10 | 11 | RUN apt-get update && apt-get install -y \ 12 | ca-certificates \ 13 | && rm -rf /var/lib/apt/lists/* 14 | 15 | RUN addgroup --gid $GROUP_ID $USERNAME \ 16 | && adduser --home /data --ingroup $USERNAME --disabled-password --uid $USER_ID $USERNAME \ 17 | && mkdir -p /data && chown $USERNAME:0 /data && chmod g=u /data 18 | 19 | COPY --from=0 /go/src/github.com/keel-hq/keel/cmd/keel/keel /bin/keel 20 | 21 | USER $USER_ID 22 | ENTRYPOINT ["/bin/keel"] 23 | 24 | EXPOSE 9300 25 | -------------------------------------------------------------------------------- /Dockerfile.debug: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.4 2 | COPY . /go/src/github.com/keel-hq/keel 3 | WORKDIR /go/src/github.com/keel-hq/keel 4 | RUN make install-debug 5 | 6 | FROM node:16.20.2-alpine 7 | WORKDIR /app 8 | COPY ui /app 9 | RUN yarn 10 | RUN yarn run lint --no-fix 11 | RUN yarn run build 12 | 13 | FROM golang:1.22.8 14 | 15 | RUN apt-get update && \ 16 | apt-get install -y --no-install-recommends ca-certificates && \ 17 | rm -rf /var/lib/apt/lists/* 18 | 19 | VOLUME /data 20 | ENV XDG_DATA_HOME /data 21 | 22 | COPY --from=0 /go/bin/keel /bin/keel 23 | COPY --from=1 /app/dist /www 24 | COPY --from=0 /go/bin/dlv / 25 | #ENTRYPOINT ["/bin/keel"] 26 | ENTRYPOINT ["/dlv", "--listen=:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/bin/keel"] 27 | 28 | EXPOSE 9300 29 | EXPOSE 40000 30 | -------------------------------------------------------------------------------- /Dockerfile.local: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | RUN apk --no-cache add ca-certificates 3 | COPY keel /bin/keel 4 | ENTRYPOINT ["/bin/keel"] 5 | 6 | EXPOSE 9300 -------------------------------------------------------------------------------- /Dockerfile.tests: -------------------------------------------------------------------------------- 1 | FROM golang:1.23.4 2 | 3 | # Install tparse and go-junit-report 4 | RUN go install github.com/mfridman/tparse@latest && \ 5 | go install github.com/jstemmer/go-junit-report@latest 6 | 7 | COPY . /go/src/github.com/keel-hq/keel 8 | 9 | WORKDIR /go/src/github.com/keel-hq/keel 10 | 11 | ENTRYPOINT ["tail", "-f", "/dev/null"] 12 | -------------------------------------------------------------------------------- /bot/formatter/custom.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | const ( 8 | imageHeader = "IMAGE" 9 | createdSinceHeader = "CREATED" 10 | createdAtHeader = "CREATED AT" 11 | sizeHeader = "SIZE" 12 | labelsHeader = "LABELS" 13 | nameHeader = "NAME" 14 | driverHeader = "DRIVER" 15 | scopeHeader = "SCOPE" 16 | ) 17 | 18 | type subContext interface { 19 | FullHeader() string 20 | AddHeader(header string) 21 | } 22 | 23 | // HeaderContext provides the subContext interface for managing headers 24 | type HeaderContext struct { 25 | header []string 26 | } 27 | 28 | // FullHeader returns the header as a string 29 | func (c *HeaderContext) FullHeader() string { 30 | if c.header == nil { 31 | return "" 32 | } 33 | return strings.Join(c.header, "\t") 34 | } 35 | 36 | // AddHeader adds another column to the header 37 | func (c *HeaderContext) AddHeader(header string) { 38 | if c.header == nil { 39 | c.header = []string{} 40 | } 41 | c.header = append(c.header, strings.ToUpper(header)) 42 | } 43 | 44 | func stripNamePrefix(ss []string) []string { 45 | sss := make([]string, len(ss)) 46 | for i, s := range ss { 47 | sss[i] = s[1:] 48 | } 49 | 50 | return sss 51 | } 52 | -------------------------------------------------------------------------------- /bot/formatter/reflect.go: -------------------------------------------------------------------------------- 1 | package formatter 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "unicode" 8 | ) 9 | 10 | func marshalJSON(x interface{}) ([]byte, error) { 11 | m, err := marshalMap(x) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return json.Marshal(m) 16 | } 17 | 18 | // marshalMap marshals x to map[string]interface{} 19 | func marshalMap(x interface{}) (map[string]interface{}, error) { 20 | val := reflect.ValueOf(x) 21 | if val.Kind() != reflect.Ptr { 22 | return nil, fmt.Errorf("expected a pointer to a struct, got %v", val.Kind()) 23 | } 24 | if val.IsNil() { 25 | return nil, fmt.Errorf("expected a pointer to a struct, got nil pointer") 26 | } 27 | valElem := val.Elem() 28 | if valElem.Kind() != reflect.Struct { 29 | return nil, fmt.Errorf("expected a pointer to a struct, got a pointer to %v", valElem.Kind()) 30 | } 31 | typ := val.Type() 32 | m := make(map[string]interface{}) 33 | for i := 0; i < val.NumMethod(); i++ { 34 | k, v, err := marshalForMethod(typ.Method(i), val.Method(i)) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if k != "" { 39 | m[k] = v 40 | } 41 | } 42 | return m, nil 43 | } 44 | 45 | var unmarshallableNames = map[string]struct{}{"FullHeader": {}} 46 | 47 | // marshalForMethod returns the map key and the map value for marshalling the method. 48 | // It returns ("", nil, nil) for valid but non-marshallable parameter. (e.g. "unexportedFunc()") 49 | func marshalForMethod(typ reflect.Method, val reflect.Value) (string, interface{}, error) { 50 | if val.Kind() != reflect.Func { 51 | return "", nil, fmt.Errorf("expected func, got %v", val.Kind()) 52 | } 53 | name, numIn, numOut := typ.Name, val.Type().NumIn(), val.Type().NumOut() 54 | _, blackListed := unmarshallableNames[name] 55 | // FIXME: In text/template, (numOut == 2) is marshallable, 56 | // if the type of the second param is error. 57 | marshallable := unicode.IsUpper(rune(name[0])) && !blackListed && 58 | numIn == 0 && numOut == 1 59 | if !marshallable { 60 | return "", nil, nil 61 | } 62 | result := val.Call(make([]reflect.Value, numIn)) 63 | intf := result[0].Interface() 64 | return name, intf, nil 65 | } 66 | -------------------------------------------------------------------------------- /bot/hipchat/approvals.go: -------------------------------------------------------------------------------- 1 | package hipchat 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/keel-hq/keel/types" 7 | ) 8 | 9 | func (b *Bot) RequestApproval(req *types.Approval) error { 10 | msg := fmt.Sprintf(ApprovalRequiredTempl, 11 | req.Message, req.Identifier, req.Identifier, 12 | req.VotesReceived, req.VotesRequired, req.Delta(), req.Identifier, 13 | req.Provider.String()) 14 | return b.postMessage(formatAsSnippet(msg)) 15 | } 16 | 17 | func (b *Bot) ReplyToApproval(approval *types.Approval) error { 18 | switch approval.Status() { 19 | case types.ApprovalStatusPending: 20 | msg := fmt.Sprintf(VoteReceivedTempl, 21 | approval.VotesReceived, approval.VotesRequired, approval.Delta(), approval.Identifier) 22 | b.postMessage(formatAsSnippet(msg)) 23 | case types.ApprovalStatusRejected: 24 | msg := fmt.Sprintf(ChangeRejectedTempl, 25 | approval.Status().String(), approval.VotesReceived, approval.VotesRequired, 26 | approval.Delta(), approval.Identifier) 27 | b.postMessage(formatAsSnippet(msg)) 28 | case types.ApprovalStatusApproved: 29 | msg := fmt.Sprintf(UpdateApprovedTempl, 30 | approval.VotesReceived, approval.VotesRequired, approval.Delta(), approval.Identifier) 31 | b.postMessage(formatAsSnippet(msg)) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /bot/hipchat/client.go: -------------------------------------------------------------------------------- 1 | package hipchat 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/daneharrigan/hipchat" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type XmppImplementer interface { 11 | Say(roomID, name, body string) 12 | Status(s string) 13 | Join(roomID, resource string) 14 | KeepAlive() 15 | Messages() <-chan *hipchat.Message 16 | } 17 | 18 | type HipchatClient struct { 19 | XmppImplementer 20 | } 21 | 22 | func connect(username, password string, connAttempts int) *HipchatClient { 23 | attempts := connAttempts 24 | for { 25 | if attempts == 0 { 26 | log.Errorf("Can not reach hipchat server after %d attempts", connAttempts) 27 | return nil 28 | } 29 | // Room history is automatically sent when joining a room unless your JID resource is “bot”. 30 | client, err := hipchat.NewClient(username, password, "bot", "plain") 31 | 32 | if err != nil { 33 | log.Errorf("bot.hipchat.connect: Error=%s", err) 34 | if err.Error() == "could not authenticate" { 35 | return nil 36 | } 37 | } 38 | if client != nil && err == nil { 39 | log.Info("Successfully connected to hipchat server") 40 | return &HipchatClient{client} 41 | } 42 | log.Debugln("Can not connect to hipcaht now, wait fo 30 seconds") 43 | time.Sleep(30 * time.Second) 44 | attempts-- 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bot/hipchat/templates.go: -------------------------------------------------------------------------------- 1 | package hipchat 2 | 3 | var ApprovalRequiredTempl = `Approval required! 4 | %s 5 | To vote for change send 'approve %s' to me 6 | To reject it: 'reject %s' 7 | Votes: %d/%d 8 | Delta: %s 9 | Identifier: %s 10 | Provider: %s` 11 | 12 | var VoteReceivedTempl = `Vote received 13 | Waiting for remaining votes! 14 | Votes: %d/%d 15 | Delta: %s 16 | Identifier: %s` 17 | 18 | var ChangeRejectedTempl = `Change rejected 19 | Change was rejected. 20 | Status: %s 21 | Votes: %d/%d 22 | Delta: %s 23 | Identifier: %s` 24 | 25 | var UpdateApprovedTempl = `Update approved! 26 | All approvals received, thanks for voting! 27 | Votes: %d/%d 28 | Delta: %s 29 | Identifier: %s` 30 | -------------------------------------------------------------------------------- /chart/keel/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *~ 18 | # Various IDEs 19 | .project 20 | .idea/ 21 | *.tmproj 22 | -------------------------------------------------------------------------------- /chart/keel/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | name: keel 3 | description: Open source tool for automating Kubernetes deployment updates. 4 | # The chart version number here is just a template. The actual version number is 5 | # replaced during the chart build, see .github/workflows/releasechart.yaml 6 | # The way to trigger a chart release is using a tag "chart-{CHART_VERSION}" 7 | version: 1.0.5 8 | appVersion: 0.20.0 9 | keywords: 10 | - kubernetes deployment 11 | - helm release 12 | - continuous deployment 13 | home: https://github.com/keel-hq/keel 14 | sources: 15 | - https://github.com/keel-hq/keel 16 | engine: gotpl 17 | icon: https://raw.githubusercontent.com/keel-hq/keel/master/static/keel-logo.png 18 | -------------------------------------------------------------------------------- /chart/keel/OWNERS: -------------------------------------------------------------------------------- 1 | approvers: 2 | - rimusz 3 | - rusenask 4 | reviewers: 5 | - rimusz 6 | - rusenask 7 | -------------------------------------------------------------------------------- /chart/keel/security-mitigation.yaml: -------------------------------------------------------------------------------- 1 | schemaVersion: v1 2 | summary: Security mitigation information for this application is tracked by the security-mitigation.yaml file that's part of this helm chart. 3 | mitigations: [] 4 | -------------------------------------------------------------------------------- /chart/keel/templates/00-namespace.yaml: -------------------------------------------------------------------------------- 1 | {{ if .Values.createNamespaceResource }} 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: {{ .Release.Namespace | quote }} 6 | {{- end }} 7 | -------------------------------------------------------------------------------- /chart/keel/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. The {{ template "keel.name" . }} is getting provisioned in your cluster. After a few minutes, you can run the following to verify. 2 | 3 | To verify that {{ template "keel.name" . }} has started, run: 4 | 5 | kubectl --namespace={{ .Release.Namespace }} get pods -l "app={{ template "keel.name" . }}" 6 | 7 | {{- if .Values.service.enabled }} 8 | 9 | 2. Get your Keel service URL: 10 | 11 | {{- if contains "LoadBalancer" .Values.service.type }} 12 | 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | Watch the status with: 'kubectl get svc --namespace {{ .Release.Namespace }} -w {{ template "keel.name" . }}' 15 | 16 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ template "keel.name" . }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') 17 | echo http://$SERVICE_IP:{{ .Values.service.externalPort }} 18 | 19 | {{- else if contains "ClusterIP" .Values.service.type }} 20 | 21 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app={{ template "keel.name" . }}" -o jsonpath="{.items[0].metadata.name}") 22 | echo http://127.0.0.1:{{ .Values.service.externalPort }} 23 | kubectl port-forward --namespace {{ .Release.Namespace }} $POD_NAME {{ .Values.service.externalPort }}:{{ .Values.service.externalPort }} 24 | 25 | {{- else if contains "NodePort" .Values.service.type }} 26 | 27 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ template "keel.name" . }}) 28 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 29 | echo http://$NODE_IP:$NODE_PORT/ 30 | 31 | {{- end }} 32 | 33 | {{- end }} 34 | -------------------------------------------------------------------------------- /chart/keel/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | {{/* 3 | Expand the name of the chart. 4 | */}} 5 | {{- define "keel.name" -}} 6 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 7 | {{- end -}} 8 | 9 | {{- define "serviceAccount.name" -}} 10 | {{- if .Values.rbac.serviceAccount.name -}} 11 | {{- .Values.rbac.serviceAccount.name -}} 12 | {{- else -}} 13 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} 14 | {{- end -}} 15 | {{- end -}} 16 | 17 | {{/* 18 | Create a default fully qualified app name. 19 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 20 | If release name contains chart name it will be used as a full name. 21 | */}} 22 | {{- define "keel.fullname" -}} 23 | {{- if .Values.fullnameOverride -}} 24 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} 25 | {{- else -}} 26 | {{- $name := default .Chart.Name .Values.nameOverride -}} 27 | {{- if contains $name .Release.Name -}} 28 | {{- .Release.Name | trunc 63 | trimSuffix "-" -}} 29 | {{- else -}} 30 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} 31 | {{- end -}} 32 | {{- end -}} 33 | {{- end -}} 34 | 35 | {{/* 36 | Create chart name and version as used by the chart label. 37 | */}} 38 | {{- define "keel.chart" -}} 39 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} 40 | {{- end -}} 41 | -------------------------------------------------------------------------------- /chart/keel/templates/clusterrole.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled }} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: {{ template "keel.name" . }} 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - namespaces 11 | verbs: 12 | - watch 13 | - list 14 | - apiGroups: 15 | - "" 16 | resources: 17 | - secrets 18 | verbs: 19 | - get 20 | - watch 21 | - list 22 | - apiGroups: 23 | - "" 24 | - extensions 25 | - apps 26 | - batch 27 | resources: 28 | - pods 29 | - replicasets 30 | - replicationcontrollers 31 | - statefulsets 32 | - deployments 33 | - daemonsets 34 | - jobs 35 | - cronjobs 36 | verbs: 37 | - get 38 | - delete # required to delete pods during force upgrade of the same tag 39 | - watch 40 | - list 41 | - update 42 | - apiGroups: 43 | - "" 44 | resources: 45 | - configmaps 46 | - pods/portforward 47 | verbs: 48 | - get 49 | - create 50 | - update 51 | {{ end }} 52 | -------------------------------------------------------------------------------- /chart/keel/templates/clusterrolebinding.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.enabled }} 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRoleBinding 4 | metadata: 5 | name: {{ template "keel.name" . }} 6 | roleRef: 7 | apiGroup: rbac.authorization.k8s.io 8 | kind: ClusterRole 9 | name: {{ template "keel.name" . }} 10 | subjects: 11 | - kind: ServiceAccount 12 | name: {{ template "serviceAccount.name" . }} 13 | namespace: {{ .Release.Namespace }} 14 | {{ end }} 15 | -------------------------------------------------------------------------------- /chart/keel/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "keel.fullname" . -}} 3 | 4 | {{- if .Values.gcloud.managedCertificates.enabled }} 5 | apiVersion: networking.gke.io/v1beta1 6 | kind: ManagedCertificate 7 | metadata: 8 | name: {{ $fullName }} 9 | namespace: {{ .Release.Namespace }} 10 | spec: 11 | domains: 12 | {{- range .Values.gcloud.managedCertificates.domains }} 13 | - {{ . }} 14 | {{- end }} 15 | {{- end }} 16 | --- 17 | apiVersion: networking.k8s.io/v1 18 | kind: Ingress 19 | metadata: 20 | name: {{ $fullName }} 21 | labels: 22 | app.kubernetes.io/name: {{ include "keel.name" . }} 23 | helm.sh/chart: {{ include "keel.chart" . }} 24 | app.kubernetes.io/instance: {{ .Release.Name }} 25 | app.kubernetes.io/managed-by: {{ .Release.Service }} 26 | {{- with .Values.ingress.labels }} 27 | {{- toYaml . | nindent 4 }} 28 | {{- end }} 29 | annotations: 30 | {{- with .Values.ingress.annotations }} 31 | {{- toYaml . | nindent 4 }} 32 | {{- end }} 33 | {{- if .Values.gcloud.managedCertificates.enabled }} 34 | networking.gke.io/managed-certificates: {{ $fullName }} 35 | {{- end }} 36 | spec: 37 | {{- if .Values.ingress.ingressClassName }} 38 | ingressClassName: {{ .Values.ingress.ingressClassName }} 39 | {{- end }} 40 | {{- if not .Values.ingress.hosts }} 41 | backend: 42 | serviceName: {{ $fullName }} 43 | servicePort: keel 44 | {{- end }} 45 | {{- if .Values.ingress.tls }} 46 | tls: 47 | {{- range .Values.ingress.tls }} 48 | - hosts: 49 | {{- range .hosts }} 50 | - {{ . | quote }} 51 | {{- end }} 52 | secretName: {{ .secretName }} 53 | {{- end }} 54 | {{- end }} 55 | {{- if .Values.ingress.hosts }} 56 | rules: 57 | {{- range .Values.ingress.hosts }} 58 | - host: {{ .host | quote }} 59 | http: 60 | paths: 61 | {{- range .paths }} 62 | - path: {{ . }} 63 | pathType: Prefix 64 | backend: 65 | service: 66 | name: {{ $fullName }} 67 | port: 68 | name: keel 69 | {{- end }} 70 | {{- end }} 71 | {{- end }} 72 | {{- end }} 73 | -------------------------------------------------------------------------------- /chart/keel/templates/pod-disruption-budget.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.podDisruptionBudget.enabled }} 2 | apiVersion: policy/v1 3 | kind: PodDisruptionBudget 4 | metadata: 5 | name: {{ template "keel.fullname" . }} 6 | namespace: {{ .Release.Namespace }} 7 | spec: 8 | {{- if .Values.podDisruptionBudget.minAvailable }} 9 | minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} 10 | {{- else }} 11 | maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} 12 | {{- end }} 13 | selector: 14 | matchLabels: 15 | app: {{ template "keel.name" . }} 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /chart/keel/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.persistence.enabled }} 2 | kind: PersistentVolumeClaim 3 | apiVersion: v1 4 | metadata: 5 | name: {{ include "keel.fullname" . }} 6 | labels: 7 | app.kubernetes.io/name: {{ include "keel.name" . }} 8 | helm.sh/chart: {{ include "keel.chart" . }} 9 | app.kubernetes.io/instance: {{ .Release.Name }} 10 | app.kubernetes.io/managed-by: {{ .Release.Service }} 11 | spec: 12 | storageClassName: "{{ .Values.persistence.storageClass }}" 13 | accessModes: 14 | - ReadWriteOnce 15 | resources: 16 | requests: 17 | storage: "{{ .Values.persistence.size }}" 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /chart/keel/templates/secret.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.secret.create }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ .Values.secret.name | default (include "keel.fullname" .) }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app: {{ template "keel.name" . }} 9 | chart: {{ template "keel.chart" . }} 10 | release: {{ .Release.Name }} 11 | heritage: {{ .Release.Service }} 12 | type: Opaque 13 | data: 14 | {{- if (and .Values.ecr.enabled .Values.ecr.secretAccessKey) }} 15 | AWS_SECRET_ACCESS_KEY: {{ .Values.ecr.secretAccessKey | b64enc }} 16 | {{- end }} 17 | {{- if .Values.slack.enabled }} 18 | SLACK_BOT_TOKEN: {{ .Values.slack.botToken | b64enc }} 19 | SLACK_APP_TOKEN: {{ .Values.slack.appToken | b64enc }} 20 | {{- end }} 21 | {{- if .Values.googleApplicationCredentials }} 22 | google-application-credentials.json: {{ .Values.googleApplicationCredentials }} 23 | {{- end }} 24 | {{- if .Values.hipchat.enabled }} 25 | HIPCHAT_TOKEN: {{ .Values.hipchat.token | b64enc}} 26 | HIPCHAT_APPROVALS_PASSWORT: {{ .Values.hipchat.password | b64enc }} 27 | {{- end }} 28 | {{- if .Values.teams.enabled }} 29 | TEAMS_WEBHOOK_URL: {{ .Values.teams.webhookUrl | b64enc }} 30 | {{- end }} 31 | {{- if .Values.discord.enabled }} 32 | DISCORD_WEBHOOK_URL: {{ .Values.discord.webhookUrl | b64enc }} 33 | {{- end }} 34 | {{- if and .Values.mail.enabled .Values.mail.smtp.pass }} 35 | MAIL_SMTP_PASS: {{ .Values.mail.smtp.pass | b64enc }} 36 | {{- end }} 37 | {{- if .Values.basicauth.enabled }} 38 | BASIC_AUTH_PASSWORD: {{ .Values.basicauth.password | b64enc }} 39 | {{- end }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /chart/keel/templates/secrets-webhookrelay.yaml: -------------------------------------------------------------------------------- 1 | {{- if (and .Values.webhookRelay.enabled .Values.webhookRelay.key .Values.webhookRelay.secret) }} 2 | apiVersion: v1 3 | kind: Secret 4 | metadata: 5 | name: {{ template "keel.name" . }}-webhookrelay 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app: {{ template "keel.name" . }} 9 | chart: {{ template "keel.chart" . }} 10 | release: {{ .Release.Name }} 11 | heritage: {{ .Release.Service }} 12 | type: Opaque 13 | data: 14 | key: {{ .Values.webhookRelay.key | b64enc }} 15 | secret: {{ .Values.webhookRelay.secret | b64enc }} 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /chart/keel/templates/service-account.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.rbac.serviceAccount.create }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ template "serviceAccount.name" . }} 6 | namespace: {{ .Release.Namespace }} 7 | {{- if (and .Values.ecr.enabled .Values.ecr.roleArn) }} 8 | annotations: 9 | eks.amazonaws.com/role-arn: {{ .Values.ecr.roleArn }} 10 | {{- else if (and .Values.gcr.enabled .Values.gcr.gcpServiceAccount) }} 11 | annotations: 12 | iam.gke.io/gcp-service-account: {{ .Values.gcr.gcpServiceAccount }} 13 | {{- end }} 14 | labels: 15 | app: {{ template "keel.name" . }} 16 | chart: {{ template "keel.chart" . }} 17 | release: {{ .Release.Name }} 18 | heritage: {{ .Release.Service }} 19 | {{ end }} -------------------------------------------------------------------------------- /chart/keel/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.service.enabled }} 2 | apiVersion: v1 3 | kind: Service 4 | metadata: 5 | name: {{ template "keel.name" . }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app: {{ template "keel.name" . }} 9 | chart: {{ template "keel.chart" . }} 10 | release: {{ .Release.Name }} 11 | heritage: {{ .Release.Service }} 12 | {{- with .Values.serviceAnnotations }} 13 | annotations: 14 | {{ toYaml . | indent 4 }} 15 | {{- end }} 16 | spec: 17 | type: {{ .Values.service.type }} 18 | {{- if .Values.service.clusterIP }} 19 | clusterIP: {{ .Values.service.clusterIP | quote }} 20 | {{- end }} 21 | {{- if .Values.service.externalIP }} 22 | externalIPs: 23 | - {{ .Values.service.externalIP }} 24 | {{- end }} 25 | ports: 26 | - port: {{ .Values.service.externalPort }} 27 | {{- if or (ne .Values.service.type "ClusterIP") (ne .Values.service.clusterIP "None") }} 28 | targetPort: 9300 29 | {{- end }} 30 | protocol: TCP 31 | name: keel 32 | selector: 33 | app: {{ template "keel.name" . }} 34 | sessionAffinity: None 35 | {{- end }} 36 | -------------------------------------------------------------------------------- /compose.debug.yml: -------------------------------------------------------------------------------- 1 | services: 2 | keel-debug: 3 | image: ${IMG_KEEL_DEBUG} 4 | security_opt: 5 | - apparmor=unconfined 6 | cap_add: 7 | - SYS_PTRACE 8 | container_name: keel-debug 9 | build: 10 | context: . 11 | dockerfile: Dockerfile.debug 12 | environment: 13 | - KUBERNETES_SERVICE_HOST=${KUBERNETES_SERVICE_HOST} 14 | - KUBERNETES_SERVICE_PORT=${KUBERNETES_SERVICE_PORT} 15 | - BASIC_AUTH_USER=admin 16 | - BASIC_AUTH_PASSWORD=admin 17 | volumes: 18 | - ${SERVICEACCOUNT}:/var/run/secrets/kubernetes.io/serviceaccount 19 | ports: 20 | - '9301:9300' 21 | - '8000:8000' 22 | - '40000:40000' -------------------------------------------------------------------------------- /compose.tests.yml: -------------------------------------------------------------------------------- 1 | services: 2 | keel_tests: 3 | image: ${IMG_KEEL_TESTS} 4 | container_name: keel_tests 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.tests -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | keel: 3 | image: ${IMG_KEEL} 4 | container_name: keel 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | environment: 9 | - KUBERNETES_SERVICE_HOST=${KUBERNETES_SERVICE_HOST} 10 | - KUBERNETES_SERVICE_PORT=${KUBERNETES_SERVICE_PORT} 11 | - BASIC_AUTH_USER=admin 12 | - BASIC_AUTH_PASSWORD=admin 13 | volumes: 14 | - ${SERVICEACCOUNT}:/var/run/secrets/kubernetes.io/serviceaccount 15 | ports: 16 | - '9300:9300' -------------------------------------------------------------------------------- /deployment/README.md: -------------------------------------------------------------------------------- 1 | # Deployment files 2 | 3 | This directory contains example deployment manifests for `keel` that can 4 | be used in place of the official Helm chart. 5 | 6 | This is useful if you are deploying `keel` into an environment without 7 | Helm, or want to inspect a 'bare minimum' deployment. 8 | 9 | ## Where do these come from? 10 | 11 | The manifests in this are generated from the Helm chart automatically. 12 | The `values.yaml` files used to configure `keel` can be found in 13 | [`values`](../chart/keel/values.yaml). 14 | 15 | 16 | They are automatically generated by running `./deployment/scripts/gen-deploy.sh`. 17 | -------------------------------------------------------------------------------- /envsettings.ps1.template: -------------------------------------------------------------------------------- 1 | $ENV:REGISTRY_PATH = "myregistry.azurecr.io/core/" 2 | $ENV:IMAGE_VERSION = "1.0.32"; 3 | $ENV:REGISTRY_SERVER = "" 4 | $ENV:REGISTRY_USER = "" 5 | $ENV:REGISTRY_PWD = "" 6 | $ENV:KUBERNETES_SERVICE_HOST = "xxxx.hcp.region.azmk8s.io" 7 | $ENV:KUBERNETES_SERVICE_PORT = "443" -------------------------------------------------------------------------------- /extension/credentialshelper/aws/aws_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/keel-hq/keel/registry" 8 | "github.com/keel-hq/keel/types" 9 | "github.com/keel-hq/keel/util/image" 10 | ) 11 | 12 | func TestAWS(t *testing.T) { 13 | 14 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" { 15 | t.Skip() 16 | } 17 | 18 | ch := New() 19 | 20 | // image 21 | imgRef, _ := image.Parse("528670773427.dkr.ecr.us-east-2.amazonaws.com/webhook-demo:master") 22 | 23 | creds, err := ch.GetCredentials(&types.TrackedImage{ 24 | Image: imgRef, 25 | }) 26 | if err != nil { 27 | t.Fatalf("cred helper got error: %s", err) 28 | } 29 | 30 | rc := registry.New() 31 | 32 | currentDigest, err := rc.Digest(registry.Opts{ 33 | Registry: imgRef.Scheme() + "://" + imgRef.Registry(), 34 | Name: imgRef.ShortName(), 35 | Tag: imgRef.Tag(), 36 | Username: creds.Username, 37 | Password: creds.Password, 38 | }) 39 | 40 | if err != nil { 41 | t.Fatalf("failed to get digest: %s", err) 42 | } 43 | 44 | if currentDigest != "sha256:7712aa425c17c2e413e5f4d64e2761eda009509d05d0e45a26e389d715aebe23" { 45 | t.Errorf("unexpected digest: %s", currentDigest) 46 | } 47 | } 48 | 49 | func TestCredentialsCaching(t *testing.T) { 50 | 51 | if os.Getenv("AWS_ACCESS_KEY_ID") == "" { 52 | t.Skip() 53 | } 54 | 55 | ch := New() 56 | 57 | imgRef, _ := image.Parse("528670773427.dkr.ecr.us-east-2.amazonaws.com/webhook-demo:master") 58 | for i := 0; i < 200; i++ { 59 | _, err := ch.GetCredentials(&types.TrackedImage{ 60 | Image: imgRef, 61 | }) 62 | if err != nil { 63 | t.Fatalf("cred helper got error: %s", err) 64 | } 65 | } 66 | } 67 | 68 | func TestAWSRegistryParse(t *testing.T) { 69 | registry := "528670773427.dkr.ecr.us-east-2.amazonaws.com" 70 | registryID, region, err := parseRegistry(registry) 71 | if err != nil { 72 | t.Fatalf("parseRegistry got error: %s", err) 73 | } 74 | if registryID != "528670773427" { 75 | t.Fatalf("parseRegistry parse registryID(528670773427) not as expected: %s", registryID) 76 | } 77 | if region != "us-east-2" { 78 | t.Fatalf("parseRegistry parse region(us-east-2) not as expected: %s", region) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /extension/credentialshelper/aws/cache.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | "time" 7 | 8 | "github.com/keel-hq/keel/types" 9 | ) 10 | 11 | type item struct { 12 | credentials *types.Credentials 13 | created time.Time 14 | } 15 | 16 | // Cache - internal cache for aws 17 | type Cache struct { 18 | creds map[string]*item 19 | tick time.Duration 20 | ttl time.Duration 21 | mu *sync.RWMutex 22 | } 23 | 24 | // NewCache - new credentials cache 25 | func NewCache(ttl time.Duration) *Cache { 26 | c := &Cache{ 27 | creds: make(map[string]*item), 28 | ttl: ttl, 29 | tick: 30 * time.Second, 30 | mu: &sync.RWMutex{}, 31 | } 32 | go c.expiryService() 33 | return c 34 | } 35 | 36 | func (c *Cache) expiryService() { 37 | ticker := time.NewTicker(c.tick) 38 | defer ticker.Stop() 39 | for { 40 | select { 41 | case <-ticker.C: 42 | c.expire() 43 | } 44 | } 45 | } 46 | 47 | func (c *Cache) expire() { 48 | c.mu.Lock() 49 | defer c.mu.Unlock() 50 | t := time.Now() 51 | for k, v := range c.creds { 52 | if t.Sub(v.created) > c.ttl { 53 | delete(c.creds, k) 54 | } 55 | } 56 | } 57 | 58 | // Put - saves new creds 59 | func (c *Cache) Put(registry string, creds *types.Credentials) { 60 | c.mu.Lock() 61 | defer c.mu.Unlock() 62 | c.creds[registry] = &item{credentials: creds, created: time.Now()} 63 | } 64 | 65 | // Get - retrieves creds 66 | func (c *Cache) Get(registry string) (*types.Credentials, error) { 67 | c.mu.RLock() 68 | defer c.mu.RUnlock() 69 | 70 | item, ok := c.creds[registry] 71 | if !ok { 72 | return nil, fmt.Errorf("not found") 73 | } 74 | 75 | cr := new(types.Credentials) 76 | *cr = *item.credentials 77 | 78 | return cr, nil 79 | } 80 | -------------------------------------------------------------------------------- /extension/credentialshelper/aws/cache_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | 7 | "github.com/keel-hq/keel/types" 8 | 9 | "testing" 10 | ) 11 | 12 | func TestPutCreds(t *testing.T) { 13 | c := NewCache(time.Second * 5) 14 | 15 | creds := &types.Credentials{ 16 | Username: "user-1", 17 | Password: "pass-1", 18 | } 19 | 20 | c.Put("reg1", creds) 21 | 22 | stored, err := c.Get("reg1") 23 | if err != nil { 24 | t.Fatalf("unexpected error: %s", err) 25 | } 26 | 27 | if stored.Username != "user-1" { 28 | t.Errorf("username mismatch: %s", stored.Username) 29 | } 30 | if stored.Password != "pass-1" { 31 | t.Errorf("password mismatch: %s", stored.Password) 32 | } 33 | } 34 | 35 | func TestExpiry(t *testing.T) { 36 | c := &Cache{ 37 | creds: make(map[string]*item), 38 | mu: &sync.RWMutex{}, 39 | ttl: time.Millisecond * 500, 40 | tick: time.Millisecond * 100, 41 | } 42 | 43 | go c.expiryService() 44 | 45 | creds := &types.Credentials{ 46 | Username: "user-1", 47 | Password: "pass-1", 48 | } 49 | 50 | c.Put("reg1", creds) 51 | 52 | time.Sleep(1100 * time.Millisecond) 53 | 54 | _, err := c.Get("reg1") 55 | if err == nil { 56 | t.Fatalf("expected to get an error about missing record") 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /extension/credentialshelper/secrets/secrets.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "github.com/keel-hq/keel/secrets" 5 | 6 | "github.com/keel-hq/keel/types" 7 | ) 8 | 9 | // CredentialsHelper - credentials helper that uses kubernetes secrets to get 10 | // username/password for registries 11 | type CredentialsHelper struct { 12 | secretsGetter secrets.Getter 13 | } 14 | 15 | // IsEnabled returns whether credentials helper is enabled. By default 16 | // secrets based cred helper is always enabled, no additional configuration is required 17 | func (ch *CredentialsHelper) IsEnabled() bool { return true } 18 | 19 | // GetCredentials looks into kubernetes secrets to find registry credentials 20 | func (ch *CredentialsHelper) GetCredentials(image *types.TrackedImage) (*types.Credentials, error) { 21 | return ch.secretsGetter.Get(image) 22 | } 23 | 24 | // New creates a new instance of secrets based credentials helper 25 | func New(sg secrets.Getter) *CredentialsHelper { 26 | return &CredentialsHelper{ 27 | secretsGetter: sg, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /extension/notification/auditor/auditor.go: -------------------------------------------------------------------------------- 1 | package auditor 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | 6 | "github.com/keel-hq/keel/extension/notification" 7 | "github.com/keel-hq/keel/pkg/store" 8 | "github.com/keel-hq/keel/types" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | type auditor struct { 14 | store store.Store 15 | } 16 | 17 | func New(store store.Store) *auditor { 18 | return &auditor{ 19 | store: store, 20 | } 21 | } 22 | 23 | func (a *auditor) Configure(config *notification.Config) (bool, error) { 24 | 25 | log.WithFields(log.Fields{ 26 | "name": "auditor", 27 | }).Info("extension.notification.auditor: audit logger configured") 28 | 29 | return true, nil 30 | } 31 | 32 | func (a *auditor) Send(event types.EventNotification) error { 33 | al := &types.AuditLog{ 34 | ID: uuid.New().String(), 35 | AccountID: "system", 36 | Username: "system", 37 | Action: event.Type.String(), 38 | ResourceKind: event.ResourceKind, 39 | Identifier: event.Identifier, 40 | Message: event.Message, 41 | } 42 | al.SetMetadata(event.Metadata) 43 | _, err := a.store.CreateAuditLog(al) 44 | 45 | return err 46 | } 47 | -------------------------------------------------------------------------------- /extension/notification/discord/discord_test.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/keel-hq/keel/types" 12 | ) 13 | 14 | func TestDiscordWebhookRequest(t *testing.T) { 15 | currentTime := time.Now() 16 | handler := func(resp http.ResponseWriter, req *http.Request) { 17 | body, err := io.ReadAll(req.Body) 18 | if err != nil { 19 | t.Errorf("failed to parse body: %s", err) 20 | } 21 | 22 | bodyStr := string(body) 23 | 24 | if !strings.Contains(bodyStr, types.NotificationPreDeploymentUpdate.String()) { 25 | t.Errorf("missing deployment type") 26 | } 27 | 28 | if !strings.Contains(bodyStr, "debug") { 29 | t.Errorf("missing level") 30 | } 31 | 32 | if !strings.Contains(bodyStr, "update deployment") { 33 | t.Errorf("missing name") 34 | } 35 | if !strings.Contains(bodyStr, "message here") { 36 | t.Errorf("missing message") 37 | } 38 | 39 | t.Log(bodyStr) 40 | } 41 | 42 | // create test server with handler 43 | ts := httptest.NewServer(http.HandlerFunc(handler)) 44 | defer ts.Close() 45 | 46 | s := &sender{ 47 | endpoint: ts.URL, 48 | client: &http.Client{}, 49 | } 50 | 51 | s.Send(types.EventNotification{ 52 | Name: "update deployment", 53 | Message: "message here", 54 | CreatedAt: currentTime, 55 | Type: types.NotificationPreDeploymentUpdate, 56 | Level: types.LevelDebug, 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /extension/notification/teams/teams_test.go: -------------------------------------------------------------------------------- 1 | package teams 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "fmt" 10 | 11 | "github.com/keel-hq/keel/constants" 12 | "github.com/keel-hq/keel/types" 13 | "github.com/keel-hq/keel/version" 14 | ) 15 | 16 | func TestTrimLeftChar(t *testing.T) { 17 | fmt.Printf("%q\n", "Hello, 世界") 18 | fmt.Printf("%q\n", TrimFirstChar("")) 19 | fmt.Printf("%q\n", TrimFirstChar("H")) 20 | fmt.Printf("%q\n", TrimFirstChar("世")) 21 | fmt.Printf("%q\n", TrimFirstChar("Hello")) 22 | fmt.Printf("%q\n", TrimFirstChar("世界")) 23 | } 24 | 25 | func TestTeamsRequest(t *testing.T) { 26 | handler := func(resp http.ResponseWriter, req *http.Request) { 27 | body, err := io.ReadAll(req.Body) 28 | if err != nil { 29 | t.Errorf("failed to parse body: %s", err) 30 | } 31 | 32 | bodyStr := string(body) 33 | 34 | if !strings.Contains(bodyStr, "MessageCard") { 35 | t.Errorf("missing MessageCard indicator") 36 | } 37 | 38 | if !strings.Contains(bodyStr, "themeColor") { 39 | t.Errorf("missing themeColor") 40 | } 41 | 42 | if !strings.Contains(bodyStr, constants.KeelLogoURL) { 43 | t.Errorf("missing logo url") 44 | } 45 | 46 | if !strings.Contains(bodyStr, "**" + types.NotificationPreDeploymentUpdate.String() + "**") { 47 | t.Errorf("missing deployment type") 48 | } 49 | 50 | if !strings.Contains(bodyStr, version.GetKeelVersion().Version) { 51 | t.Errorf("missing version") 52 | } 53 | 54 | if !strings.Contains(bodyStr, "update deployment") { 55 | t.Errorf("missing name") 56 | } 57 | if !strings.Contains(bodyStr, "message here") { 58 | t.Errorf("missing message") 59 | } 60 | 61 | t.Log(bodyStr) 62 | 63 | } 64 | 65 | // create test server with handler 66 | ts := httptest.NewServer(http.HandlerFunc(handler)) 67 | defer ts.Close() 68 | 69 | s := &sender{ 70 | endpoint: ts.URL, 71 | client: &http.Client{}, 72 | } 73 | 74 | s.Send(types.EventNotification{ 75 | Name: "update deployment", 76 | Message: "message here", 77 | Type: types.NotificationPreDeploymentUpdate, 78 | }) 79 | } 80 | -------------------------------------------------------------------------------- /extension/notification/webhook/webhook_test.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/keel-hq/keel/types" 12 | ) 13 | 14 | func TestWebhookRequest(t *testing.T) { 15 | currentTime := time.Now() 16 | handler := func(resp http.ResponseWriter, req *http.Request) { 17 | body, err := io.ReadAll(req.Body) 18 | if err != nil { 19 | t.Errorf("failed to parse body: %s", err) 20 | } 21 | 22 | bodyStr := string(body) 23 | 24 | if !strings.Contains(bodyStr, types.NotificationPreDeploymentUpdate.String()) { 25 | t.Errorf("missing deployment type") 26 | } 27 | 28 | if !strings.Contains(bodyStr, "debug") { 29 | t.Errorf("missing level") 30 | } 31 | 32 | if !strings.Contains(bodyStr, "update deployment") { 33 | t.Errorf("missing name") 34 | } 35 | if !strings.Contains(bodyStr, "message here") { 36 | t.Errorf("missing message") 37 | } 38 | 39 | t.Log(bodyStr) 40 | 41 | } 42 | 43 | // create test server with handler 44 | ts := httptest.NewServer(http.HandlerFunc(handler)) 45 | defer ts.Close() 46 | 47 | s := &sender{ 48 | endpoint: ts.URL, 49 | client: &http.Client{}, 50 | } 51 | 52 | s.Send(types.EventNotification{ 53 | Name: "update deployment", 54 | Message: "message here", 55 | CreatedAt: currentTime, 56 | Type: types.NotificationPreDeploymentUpdate, 57 | Level: types.LevelDebug, 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /internal/k8s/cache_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "testing" 5 | 6 | apps_v1 "k8s.io/api/apps/v1" 7 | core_v1 "k8s.io/api/core/v1" 8 | meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | func TestAddGet(t *testing.T) { 12 | 13 | cc := &GenericResourceCache{} 14 | 15 | d := &apps_v1.Deployment{ 16 | meta_v1.TypeMeta{}, 17 | meta_v1.ObjectMeta{ 18 | Name: "dep-1", 19 | Namespace: "xxxx", 20 | Annotations: map[string]string{}, 21 | Labels: map[string]string{}, 22 | }, 23 | apps_v1.DeploymentSpec{ 24 | Template: core_v1.PodTemplateSpec{ 25 | Spec: core_v1.PodSpec{ 26 | Containers: []core_v1.Container{ 27 | { 28 | Image: "gcr.io/v2-namespace/hi-world:1.1.1", 29 | }, 30 | }, 31 | }, 32 | }, 33 | }, 34 | apps_v1.DeploymentStatus{}, 35 | } 36 | 37 | gr, err := NewGenericResource(d) 38 | if err != nil { 39 | t.Fatalf("failed to create generic resource: %s", err) 40 | } 41 | 42 | cc.Add(gr) 43 | 44 | // updating deployment 45 | stored := cc.Values()[0] 46 | stored.UpdateContainer(0, "gcr.io/v2-namespace/hi-world:2.2.2.") 47 | 48 | // getting again 49 | stored2 := cc.Values()[0] 50 | if stored2.Containers()[0].Image != "gcr.io/v2-namespace/hi-world:1.1.1" { 51 | t.Errorf("cached entry got modified: %s", stored2.Containers()[0].Image) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/k8s/translator.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | ) 6 | 7 | type Translator struct { 8 | logrus.FieldLogger 9 | 10 | GenericResourceCache 11 | 12 | KeelSelector string 13 | } 14 | 15 | func (t *Translator) OnAdd(obj interface{}, isInInitialList bool) { 16 | gr, err := NewGenericResource(obj) 17 | if err != nil { 18 | t.Errorf("OnAdd failed to add resource %T: %#v", obj, obj) 19 | return 20 | } 21 | t.Debugf("added %s %s", gr.Kind(), gr.Name) 22 | t.GenericResourceCache.Add(gr) 23 | } 24 | 25 | func (t *Translator) OnUpdate(oldObj, newObj interface{}) { 26 | gr, err := NewGenericResource(newObj) 27 | if err != nil { 28 | t.Errorf("OnUpdate failed to update resource %T: %#v", newObj, newObj) 29 | return 30 | } 31 | t.Debugf("updated %s %s", gr.Kind(), gr.Name) 32 | t.GenericResourceCache.Add(gr) 33 | } 34 | 35 | func (t *Translator) OnDelete(obj interface{}) { 36 | gr, err := NewGenericResource(obj) 37 | if err != nil { 38 | t.Errorf("OnDelete failed to delete resource %T: %#v", obj, obj) 39 | return 40 | } 41 | t.Debugf("deleted %s %s", gr.Kind(), gr.Name) 42 | t.GenericResourceCache.Remove(gr.GetIdentifier()) 43 | } 44 | -------------------------------------------------------------------------------- /internal/policy/force.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import "github.com/keel-hq/keel/types" 4 | 5 | type ForcePolicy struct { 6 | matchTag bool 7 | } 8 | 9 | func NewForcePolicy(matchTag bool) *ForcePolicy { 10 | return &ForcePolicy{ 11 | matchTag: matchTag, 12 | } 13 | } 14 | 15 | func (fp *ForcePolicy) ShouldUpdate(current, new string) (bool, error) { 16 | if fp.matchTag && current != new { 17 | return false, nil 18 | } 19 | return true, nil 20 | } 21 | 22 | func (fp *ForcePolicy) Filter(tags []string) []string { 23 | // todo: why is this not sorting? 24 | return append([]string{}, tags...) 25 | } 26 | 27 | func (fp *ForcePolicy) Name() string { 28 | return "force" 29 | } 30 | 31 | func (fp *ForcePolicy) Type() types.PolicyType { return types.PolicyTypeForce } 32 | 33 | func (fp *ForcePolicy) KeepTag() bool { return fp.matchTag } 34 | -------------------------------------------------------------------------------- /internal/policy/glob.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | "github.com/keel-hq/keel/types" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/ryanuber/go-glob" 10 | ) 11 | 12 | type GlobPolicy struct { 13 | policy string // original string 14 | pattern string // without prefix 15 | } 16 | 17 | func NewGlobPolicy(policy string) (*GlobPolicy, error) { 18 | if strings.Contains(policy, ":") { 19 | parts := strings.Split(policy, ":") 20 | if len(parts) == 2 { 21 | return &GlobPolicy{ 22 | policy: policy, 23 | pattern: parts[1], 24 | }, nil 25 | } 26 | } 27 | 28 | return nil, fmt.Errorf("invalid glob policy: %s", policy) 29 | } 30 | 31 | func (p *GlobPolicy) ShouldUpdate(current string, new string) (bool, error) { 32 | return (glob.Glob(p.pattern, new) && strings.Compare(new, current) > 0), nil 33 | } 34 | 35 | func (p *GlobPolicy) Filter(tags []string) []string { 36 | filtered := []string{} 37 | 38 | for _, tag := range tags { 39 | if glob.Glob(p.pattern, tag) { 40 | filtered = append(filtered, tag) 41 | } 42 | } 43 | 44 | // sort desc alphabetically 45 | sort.Slice(filtered, func(i, j int) bool { 46 | return filtered[i] > filtered[j] 47 | }) 48 | 49 | return filtered 50 | } 51 | 52 | func (p *GlobPolicy) Name() string { return p.policy } 53 | func (p *GlobPolicy) Type() types.PolicyType { return types.PolicyTypeGlob } 54 | func (p *GlobPolicy) KeepTag() bool { return false } 55 | -------------------------------------------------------------------------------- /internal/policy/glob_test.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import "testing" 4 | 5 | func TestGlobPolicy_ShouldUpdate(t *testing.T) { 6 | type fields struct { 7 | policy string 8 | pattern string 9 | } 10 | type args struct { 11 | current string 12 | new string 13 | } 14 | tests := []struct { 15 | name string 16 | fields fields 17 | args args 18 | want bool 19 | wantErr bool 20 | }{ 21 | { 22 | name: "test glob latest", 23 | fields: fields{pattern: "latest"}, 24 | args: args{current: "latest", new: "latest"}, 25 | want: false, 26 | wantErr: false, 27 | }, 28 | { 29 | name: "test glob without *", 30 | fields: fields{pattern: "latest"}, 31 | args: args{current: "latest", new: "earliest"}, 32 | want: false, 33 | wantErr: false, 34 | }, 35 | { 36 | name: "test glob with lat*", 37 | fields: fields{pattern: "lat*"}, 38 | args: args{current: "latest", new: "latest"}, 39 | want: false, 40 | wantErr: false, 41 | }, 42 | { 43 | name: "test glob with latest.*", 44 | fields: fields{pattern: "latest.*"}, 45 | args: args{current: "latest.20241321", new: "latest.20251321"}, 46 | want: true, 47 | wantErr: false, 48 | }, 49 | { 50 | name: "test glob with latest.* reverse", 51 | fields: fields{pattern: "latest.*"}, 52 | args: args{current: "latest.20251321", new: "latest.20241321"}, 53 | want: false, 54 | wantErr: false, 55 | }, 56 | } 57 | for _, tt := range tests { 58 | t.Run(tt.name, func(t *testing.T) { 59 | p := &GlobPolicy{ 60 | policy: tt.fields.policy, 61 | pattern: tt.fields.pattern, 62 | } 63 | got, err := p.ShouldUpdate(tt.args.current, tt.args.new) 64 | if (err != nil) != tt.wantErr { 65 | t.Errorf("GlobPolicy.ShouldUpdate() error = %v, wantErr %v", err, tt.wantErr) 66 | return 67 | } 68 | if got != tt.want { 69 | t.Errorf("GlobPolicy.ShouldUpdate() = %v, want %v", got, tt.want) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/policy/regexp.go: -------------------------------------------------------------------------------- 1 | package policy 2 | 3 | import ( 4 | "fmt" 5 | "github.com/keel-hq/keel/types" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | ) 10 | 11 | // RegexpPolicy - regular expression based pattern 12 | type RegexpPolicy struct { 13 | policy string 14 | regexp *regexp.Regexp 15 | } 16 | 17 | func NewRegexpPolicy(policy string) (*RegexpPolicy, error) { 18 | if strings.Contains(policy, ":") { 19 | parts := strings.SplitN(policy, ":", 2) 20 | if len(parts) == 2 { 21 | 22 | rx, err := regexp.Compile(parts[1]) 23 | if err != nil { 24 | return nil, fmt.Errorf("failed to parse regexp pattern, error: %s", err) 25 | } 26 | 27 | return &RegexpPolicy{ 28 | regexp: rx, 29 | policy: policy, 30 | }, nil 31 | } 32 | } 33 | 34 | return nil, fmt.Errorf("invalid regexp policy: %s", policy) 35 | } 36 | 37 | func (p *RegexpPolicy) ShouldUpdate(current, new string) (bool, error) { 38 | return p.regexp.MatchString(new), nil 39 | } 40 | 41 | func (p *RegexpPolicy) Filter(tags []string) []string { 42 | filtered := []string{} 43 | compare := p.regexp.SubexpIndex("compare") 44 | 45 | for _, tag := range tags { 46 | if p.regexp.MatchString(tag) { 47 | filtered = append(filtered, tag) 48 | } 49 | } 50 | 51 | sort.Slice(filtered, func(i, j int) bool { 52 | if compare != -1 { 53 | mi := p.regexp.FindStringSubmatch(filtered[i]) 54 | mj := p.regexp.FindStringSubmatch(filtered[j]) 55 | return mi[compare] > mj[compare] 56 | } else { 57 | return filtered[i] > filtered[j] 58 | } 59 | }) 60 | 61 | return filtered 62 | } 63 | 64 | func (p *RegexpPolicy) Name() string { return p.policy } 65 | func (p *RegexpPolicy) Type() types.PolicyType { return types.PolicyTypeRegexp } 66 | func (p *RegexpPolicy) KeepTag() bool { return false } 67 | -------------------------------------------------------------------------------- /internal/workgroup/workgroup.go: -------------------------------------------------------------------------------- 1 | package workgroup 2 | 3 | import "sync" 4 | 5 | // Group manages a set of goroutines with related lifetimes. 6 | type Group struct { 7 | fn []func(<-chan struct{}) 8 | } 9 | 10 | // Add adds a function to the Group. Must be called before Run. 11 | func (g *Group) Add(fn func(<-chan struct{})) { 12 | g.fn = append(g.fn, fn) 13 | } 14 | 15 | // Run exectues each function registered with Add in its own goroutine. 16 | // Run blocks until each function has returned. 17 | // The first function to return will trigger the closure of the channel 18 | // passed to each function, who should in turn, return. 19 | func (g *Group) Run() { 20 | var wg sync.WaitGroup 21 | wg.Add(len(g.fn)) 22 | 23 | stop := make(chan struct{}) 24 | result := make(chan error, len(g.fn)) 25 | for _, fn := range g.fn { 26 | go func(fn func(<-chan struct{})) { 27 | defer wg.Done() 28 | fn(stop) 29 | result <- nil 30 | }(fn) 31 | } 32 | 33 | <-result 34 | close(stop) 35 | wg.Wait() 36 | } 37 | -------------------------------------------------------------------------------- /pkg/auth/ctx.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type ctxKeyType string 9 | 10 | const authenticationAccountObjectContextKey ctxKeyType = "authenticated-account-object" 11 | 12 | // SetAuthenticationDetails sets user details for this request 13 | func SetAuthenticationDetails(r *http.Request, u *User) *http.Request { 14 | ctx := context.WithValue(r.Context(), authenticationAccountObjectContextKey, u) 15 | return r.WithContext(ctx) 16 | } 17 | 18 | // GetAccountFromCtx - get current authenticated account info from ctx 19 | func GetAccountFromCtx(ctx context.Context) *User { 20 | if u := ctx.Value(authenticationAccountObjectContextKey); u != nil { 21 | return u.(*User) 22 | } 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/http/audit_endpoint.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/keel-hq/keel/types" 9 | ) 10 | 11 | func (s *TriggerServer) adminAuditLogHandler(resp http.ResponseWriter, req *http.Request) { 12 | 13 | query := &types.AuditLogQuery{} 14 | limitS := req.URL.Query().Get("limit") 15 | if limitS != "" { 16 | l, err := strconv.Atoi(limitS) 17 | if err == nil { 18 | query.Limit = l 19 | } 20 | } 21 | 22 | offsetS := req.URL.Query().Get("offset") 23 | if offsetS != "" { 24 | o, err := strconv.Atoi(offsetS) 25 | if err == nil { 26 | query.Offset = o 27 | } 28 | } 29 | 30 | kindFilter := req.URL.Query().Get("filter") 31 | if kindFilter != "" { 32 | kinds := strings.Split(kindFilter, ",") 33 | query.ResourceKindFilter = kinds 34 | } 35 | 36 | emailFilter := req.URL.Query().Get("email") 37 | if emailFilter != "" { 38 | query.Email = strings.TrimSpace(emailFilter) 39 | } 40 | 41 | entries, err := s.store.GetAuditLogs(query) 42 | if err != nil { 43 | response(nil, 500, err, resp, req) 44 | return 45 | } 46 | 47 | result := auditLogsResponse{ 48 | Data: entries, 49 | Offset: query.Offset, 50 | Limit: query.Limit, 51 | } 52 | 53 | count, err := s.store.AuditLogsCount(query) 54 | if err == nil { 55 | result.Total = count 56 | } 57 | 58 | response(result, http.StatusOK, err, resp, req) 59 | } 60 | 61 | type auditLogsResponse struct { 62 | Data []*types.AuditLog `json:"data"` 63 | Total int `json:"total"` 64 | Limit int `json:"limit"` 65 | Offset int `json:"offset"` 66 | } 67 | -------------------------------------------------------------------------------- /pkg/http/azure_webhook_trigger_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | var fakeAzureWebhook = `{ 12 | "id": "cb8c3971-9adc-488b-bdd8-43cbb4974ff5", 13 | "timestamp": "2017-11-17T16:52:01.343145347Z", 14 | "action": "push", 15 | "target": { 16 | "mediaType": "application/vnd.docker.distribution.manifest.v2+json", 17 | "size": 524, 18 | "digest": "sha256:80f0d5c8786bb9e621a45ece0db56d11cdc624ad20da9fe62e9d25490f331d7d", 19 | "length": 524, 20 | "repository": "hello-world", 21 | "tag": "v1" 22 | }, 23 | "request": { 24 | "id": "3cbb6949-7549-4fa1-86cd-a6d5451dffc7", 25 | "host": "myregistry.azurecr.io", 26 | "method": "PUT", 27 | "useragent": "docker/17.09.0-ce go/go1.8.3 git-commit/afdb6d4 kernel/4.10.0-27-generic os/linux arch/amd64 UpstreamClient(Docker-Client/17.09.0-ce \\(linux\\))" 28 | } 29 | } 30 | ` 31 | 32 | func TestAzureWebhookHandler(t *testing.T) { 33 | 34 | fp := &fakeProvider{} 35 | srv, teardown := NewTestingServer(fp) 36 | defer teardown() 37 | 38 | req, err := http.NewRequest("POST", "/v1/webhooks/azure", bytes.NewBuffer([]byte(fakeAzureWebhook))) 39 | if err != nil { 40 | t.Fatalf("failed to create req: %s", err) 41 | } 42 | 43 | //The response recorder used to record HTTP responses 44 | rec := httptest.NewRecorder() 45 | 46 | srv.router.ServeHTTP(rec, req) 47 | if rec.Code != 200 { 48 | t.Errorf("unexpected status code: %d", rec.Code) 49 | 50 | t.Log(rec.Body.String()) 51 | } 52 | 53 | if len(fp.submitted) != 1 { 54 | t.Fatalf("unexpected number of events submitted: %d", len(fp.submitted)) 55 | } 56 | 57 | if fp.submitted[0].Repository.Name != "myregistry.azurecr.io/hello-world" { 58 | t.Errorf("myregistry.azurecr.io/hello-world but got %s", fp.submitted[0].Repository.Name) 59 | } 60 | 61 | if fp.submitted[0].Repository.Tag != "v1" { 62 | t.Errorf("expected v1 but got %s", fp.submitted[0].Repository.Tag) 63 | } 64 | 65 | if fp.submitted[0].Repository.Digest != "sha256:80f0d5c8786bb9e621a45ece0db56d11cdc624ad20da9fe62e9d25490f331d7d" { 66 | t.Errorf("expected sha256:80f0d5c8786bb9e621a45ece0db56d11cdc624ad20da9fe62e9d25490f331d7d but got %s", fp.submitted[0].Repository.Digest) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/http/debug.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "expvar" 5 | "fmt" 6 | "net/http" 7 | "net/http/pprof" 8 | "runtime" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func init() { 14 | expvar.Publish("Goroutines", expvar.Func(goroutines)) 15 | } 16 | 17 | func goroutines() interface{} { 18 | return runtime.NumGoroutine() 19 | } 20 | 21 | // DebugHandler expose debug routes 22 | type DebugHandler struct{} 23 | 24 | // AddRoutes add debug routes on a router 25 | func (g DebugHandler) AddRoutes(router *mux.Router) { 26 | router.Methods(http.MethodGet).Path("/debug/vars"). 27 | HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 28 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 29 | fmt.Fprint(w, "{\n") 30 | first := true 31 | expvar.Do(func(kv expvar.KeyValue) { 32 | if !first { 33 | fmt.Fprint(w, ",\n") 34 | } 35 | first = false 36 | fmt.Fprintf(w, "%q: %s", kv.Key, kv.Value) 37 | }) 38 | fmt.Fprint(w, "\n}\n") 39 | }) 40 | 41 | router.Methods(http.MethodGet).PathPrefix("/debug/pprof/cmdline").HandlerFunc(pprof.Cmdline) 42 | router.Methods(http.MethodGet).PathPrefix("/debug/pprof/profile").HandlerFunc(pprof.Profile) 43 | router.Methods(http.MethodGet).PathPrefix("/debug/pprof/symbol").HandlerFunc(pprof.Symbol) 44 | router.Methods(http.MethodGet).PathPrefix("/debug/pprof/trace").HandlerFunc(pprof.Trace) 45 | router.Methods(http.MethodGet).PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/http/native_webhook_trigger.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/keel-hq/keel/types" 10 | "github.com/prometheus/client_golang/prometheus" 11 | 12 | log "github.com/sirupsen/logrus" 13 | ) 14 | 15 | var newNativeWebhooksCounter = prometheus.NewCounterVec( 16 | prometheus.CounterOpts{ 17 | Name: "native_webhook_requests_total", 18 | Help: "How many /v1/webhooks/native requests processed, partitioned by image.", 19 | }, 20 | []string{"image"}, 21 | ) 22 | 23 | func init() { 24 | prometheus.MustRegister(newNativeWebhooksCounter) 25 | } 26 | 27 | // nativeHandler - used to trigger event directly 28 | func (s *TriggerServer) nativeHandler(resp http.ResponseWriter, req *http.Request) { 29 | repo := types.Repository{} 30 | if err := json.NewDecoder(req.Body).Decode(&repo); err != nil { 31 | log.WithFields(log.Fields{ 32 | "error": err, 33 | }).Error("failed to decode request") 34 | resp.WriteHeader(http.StatusBadRequest) 35 | return 36 | } 37 | 38 | event := types.Event{} 39 | 40 | if repo.Name == "" { 41 | resp.WriteHeader(http.StatusBadRequest) 42 | fmt.Fprintf(resp, "repository name cannot be empty") 43 | return 44 | } 45 | 46 | if repo.Tag == "" { 47 | resp.WriteHeader(http.StatusBadRequest) 48 | fmt.Fprintf(resp, "repository tag cannot be empty") 49 | return 50 | } 51 | 52 | event.Repository = repo 53 | event.CreatedAt = time.Now() 54 | event.TriggerName = "native" 55 | s.trigger(event) 56 | 57 | resp.WriteHeader(http.StatusOK) 58 | 59 | newNativeWebhooksCounter.With(prometheus.Labels{"image": event.Repository.Name}).Inc() 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /pkg/http/policy_endpoint.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/keel-hq/keel/types" 9 | ) 10 | 11 | type resourcePolicyUpdateRequest struct { 12 | Policy string `json:"policy"` 13 | Identifier string `json:"identifier"` 14 | Provider string `json:"provider"` 15 | } 16 | 17 | func (s *TriggerServer) policyUpdateHandler(resp http.ResponseWriter, req *http.Request) { 18 | var policyRequest resourcePolicyUpdateRequest 19 | dec := json.NewDecoder(req.Body) 20 | defer req.Body.Close() 21 | 22 | err := dec.Decode(&policyRequest) 23 | if err != nil { 24 | resp.WriteHeader(http.StatusBadRequest) 25 | fmt.Fprintf(resp, "%s", err) 26 | return 27 | } 28 | 29 | if policyRequest.Identifier == "" { 30 | http.Error(resp, "identifier cannot be empty", http.StatusBadRequest) 31 | return 32 | } 33 | 34 | for _, v := range s.grc.Values() { 35 | if v.Identifier == policyRequest.Identifier { 36 | 37 | labels := v.GetLabels() 38 | delete(labels, types.KeelPolicyLabel) 39 | v.SetLabels(labels) 40 | 41 | ann := v.GetAnnotations() 42 | ann[types.KeelPolicyLabel] = policyRequest.Policy 43 | 44 | v.SetAnnotations(ann) 45 | 46 | err := s.kubernetesClient.Update(v) 47 | 48 | response(&APIResponse{Status: "updated"}, 200, err, resp, req) 49 | return 50 | } 51 | } 52 | 53 | resp.WriteHeader(http.StatusNotFound) 54 | fmt.Fprintf(resp, "resource with identifier '%s' not found", policyRequest.Identifier) 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /pkg/http/quay_webhook_trigger_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | var fakeQuayWebhook = `{ 12 | "name": "repository", 13 | "repository": "mynamespace/repository", 14 | "namespace": "mynamespace", 15 | "docker_url": "quay.io/mynamespace/repository", 16 | "homepage": "https://quay.io/repository/mynamespace/repository", 17 | "updated_tags": [ 18 | "1.2.3" 19 | ] 20 | } 21 | ` 22 | 23 | func TestQuayWebhookHandler(t *testing.T) { 24 | 25 | fp := &fakeProvider{} 26 | srv, teardown := NewTestingServer(fp) 27 | defer teardown() 28 | 29 | req, err := http.NewRequest("POST", "/v1/webhooks/quay", bytes.NewBuffer([]byte(fakeQuayWebhook))) 30 | if err != nil { 31 | t.Fatalf("failed to create req: %s", err) 32 | } 33 | 34 | //The response recorder used to record HTTP responses 35 | rec := httptest.NewRecorder() 36 | 37 | srv.router.ServeHTTP(rec, req) 38 | if rec.Code != 200 { 39 | t.Errorf("unexpected status code: %d", rec.Code) 40 | 41 | t.Log(rec.Body.String()) 42 | } 43 | 44 | if len(fp.submitted) != 1 { 45 | t.Fatalf("unexpected number of events submitted: %d", len(fp.submitted)) 46 | } 47 | 48 | if fp.submitted[0].Repository.Name != "quay.io/mynamespace/repository" { 49 | t.Errorf("expected quay.io/mynamespace/repository but got %s", fp.submitted[0].Repository.Name) 50 | } 51 | 52 | if fp.submitted[0].Repository.Tag != "1.2.3" { 53 | t.Errorf("expected 1.2.3 but got %s", fp.submitted[0].Repository.Tag) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pkg/http/resources_endpoint.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/keel-hq/keel/internal/k8s" 7 | "github.com/keel-hq/keel/internal/policy" 8 | 9 | "github.com/keel-hq/keel/provider/kubernetes" 10 | ) 11 | 12 | type resource struct { 13 | Provider string `json:"provider"` 14 | Identifier string `json:"identifier"` 15 | Name string `json:"name"` 16 | Namespace string `json:"namespace"` 17 | Kind string `json:"kind"` 18 | Policy string `json:"policy"` 19 | Images []string `json:"images"` 20 | Labels map[string]string `json:"labels"` 21 | Annotations map[string]string `json:"annotations"` 22 | Status k8s.Status `json:"status"` 23 | } 24 | 25 | func (s *TriggerServer) resourcesHandler(resp http.ResponseWriter, req *http.Request) { 26 | 27 | vals := s.grc.Values() 28 | 29 | var res []resource 30 | 31 | for _, v := range vals { 32 | 33 | p := policy.GetPolicyFromLabelsOrAnnotations(v.GetLabels(), v.GetAnnotations()) 34 | filterFunc := kubernetes.GetMonitorContainersFromMeta(v.GetLabels(), v.GetAnnotations()) 35 | 36 | res = append(res, resource{ 37 | Provider: "kubernetes", 38 | Identifier: v.Identifier, 39 | Name: v.Name, 40 | Namespace: v.Namespace, 41 | Kind: v.Kind(), 42 | Policy: p.Name(), 43 | Labels: v.GetLabels(), 44 | Annotations: v.GetAnnotations(), 45 | Images: v.GetImages(filterFunc), 46 | Status: v.GetStatus(), 47 | }) 48 | } 49 | 50 | response(res, 200, nil, resp, req) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/http/stats_endpoint.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/keel-hq/keel/types" 7 | ) 8 | 9 | type dailyStats struct { 10 | Timestamp int `json:"timestamp"` 11 | WebhooksReceived int `json:"webhooksReceived"` 12 | ApprovalsApproved int `json:"approvalsApproved"` 13 | ApprovalsRejected int `json:"approvalsRejected"` 14 | Updates int `json:"updates"` 15 | } 16 | 17 | func (s *TriggerServer) statsHandler(resp http.ResponseWriter, req *http.Request) { 18 | stats, err := s.store.AuditStatistics(&types.AuditLogStatsQuery{}) 19 | response(stats, 200, err, resp, req) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/store/sql/approvals.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | "github.com/jinzhu/gorm" 8 | 9 | "github.com/keel-hq/keel/pkg/store" 10 | "github.com/keel-hq/keel/types" 11 | ) 12 | 13 | func (s *SQLStore) CreateApproval(approval *types.Approval) (*types.Approval, error) { 14 | 15 | // generating ID 16 | if approval.ID == "" { 17 | approval.ID = uuid.New().String() 18 | } 19 | 20 | tx := s.db.Begin() 21 | // Note the use of tx as the database handle once you are within a transaction 22 | if err := tx.Create(approval).Error; err != nil { 23 | tx.Rollback() 24 | return nil, err 25 | } 26 | 27 | tx.Commit() 28 | 29 | return approval, nil 30 | } 31 | 32 | func (s *SQLStore) UpdateApproval(approval *types.Approval) error { 33 | if approval.ID == "" { 34 | return fmt.Errorf("ID not specified") 35 | } 36 | return s.db.Save(approval).Error 37 | } 38 | 39 | func (s *SQLStore) GetApproval(q *types.GetApprovalQuery) (*types.Approval, error) { 40 | var result types.Approval 41 | var err error 42 | if q.ID == "" { 43 | err = s.db.Where("identifier = ? AND archived = ?", q.Identifier, q.Archived).First(&result).Error 44 | } else { 45 | err = s.db.Where(&types.Approval{ 46 | ID: q.ID, 47 | Identifier: q.Identifier, 48 | Archived: q.Archived, 49 | // Rejected: q.Rejected, 50 | }).First(&result).Error 51 | } 52 | if err == gorm.ErrRecordNotFound { 53 | 54 | return nil, store.ErrRecordNotFound 55 | } 56 | 57 | return &result, err 58 | } 59 | 60 | func (s *SQLStore) ListApprovals(q *types.GetApprovalQuery) ([]*types.Approval, error) { 61 | var approvals []*types.Approval 62 | err := s.db.Order("updated_at desc").Where(&types.Approval{ 63 | Identifier: q.Identifier, 64 | Archived: q.Archived, 65 | }).Find(&approvals).Error 66 | return approvals, err 67 | } 68 | 69 | func (s *SQLStore) DeleteApproval(approval *types.Approval) error { 70 | if approval.ID == "" { 71 | return fmt.Errorf("ID not specified") 72 | } 73 | return s.db.Delete(approval).Error 74 | } 75 | -------------------------------------------------------------------------------- /pkg/store/sql/sql.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/keel-hq/keel/types" 10 | 11 | // importing sqlite driver 12 | _ "github.com/jinzhu/gorm/dialects/sqlite" 13 | 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | type SQLStore struct { 18 | db *gorm.DB 19 | } 20 | 21 | type Opts struct { 22 | DatabaseType string // sqlite3 / postgres 23 | URI string // path or conn string 24 | } 25 | 26 | func New(opts Opts) (*SQLStore, error) { 27 | ctx, cancel := context.WithTimeout(context.Background(), 300*time.Second) 28 | defer cancel() 29 | db, err := connect(ctx, opts) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | err = db.AutoMigrate( 35 | &types.Approval{}, 36 | &types.AuditLog{}, 37 | ).Error 38 | if err != nil { 39 | log.WithFields(log.Fields{ 40 | "error": err, 41 | }).Error("database migration failed ") 42 | return nil, err 43 | } 44 | 45 | return &SQLStore{ 46 | db: db, 47 | }, nil 48 | } 49 | 50 | // Close - closes database connection 51 | func (s *SQLStore) Close() error { 52 | s.db.Close() 53 | return nil 54 | } 55 | 56 | func (s *SQLStore) OK() bool { 57 | err := s.db.DB().Ping() 58 | return err == nil 59 | } 60 | 61 | func connect(ctx context.Context, opts Opts) (*gorm.DB, error) { 62 | for { 63 | select { 64 | case <-ctx.Done(): 65 | return nil, fmt.Errorf("sql store startup deadline exceeded") 66 | default: 67 | db, err := gorm.Open(opts.DatabaseType, opts.URI) 68 | if err != nil { 69 | time.Sleep(1 * time.Second) 70 | log.WithFields(log.Fields{ 71 | "error": err, 72 | "uri": opts.URI, 73 | }).Warn("sql store connector: can't reach DB, waiting") 74 | continue 75 | } 76 | 77 | db.DB().SetMaxOpenConns(40) 78 | 79 | // success 80 | return db, nil 81 | 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /pkg/store/store.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/keel-hq/keel/types" 7 | ) 8 | 9 | type Store interface { 10 | CreateAuditLog(entry *types.AuditLog) (id string, err error) 11 | GetAuditLogs(query *types.AuditLogQuery) (logs []*types.AuditLog, err error) 12 | AuditLogsCount(query *types.AuditLogQuery) (int, error) 13 | AuditStatistics(query *types.AuditLogStatsQuery) ([]types.AuditLogStats, error) 14 | 15 | CreateApproval(approval *types.Approval) (*types.Approval, error) 16 | UpdateApproval(approval *types.Approval) error 17 | GetApproval(q *types.GetApprovalQuery) (*types.Approval, error) 18 | ListApprovals(q *types.GetApprovalQuery) ([]*types.Approval, error) 19 | DeleteApproval(approval *types.Approval) error 20 | 21 | OK() bool 22 | Close() error 23 | } 24 | 25 | // errors 26 | var ( 27 | ErrRecordNotFound = errors.New("record not found") 28 | ) 29 | -------------------------------------------------------------------------------- /provider/helm3/implementer_test.go: -------------------------------------------------------------------------------- 1 | package helm3 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestImplementerList(t *testing.T) { 8 | t.Skip() 9 | 10 | imp := NewHelm3Implementer() 11 | releases, err := imp.ListReleases() 12 | if err != nil { 13 | t.Fatalf("unexpected error: %s", err) 14 | } 15 | 16 | if len(releases) == 0 { 17 | t.Errorf("why no releases? ") 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /registry/docker/json.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | var ErrNoMorePages = errors.New("no more pages") 12 | 13 | // Matches an RFC 5988 (https://tools.ietf.org/html/rfc5988#section-5) 14 | // Link header. For example, 15 | // 16 | // ; type="application/json"; rel="next" 17 | // 18 | // The URL is _supposed_ to be wrapped by angle brackets `< ... >`, 19 | // but e.g., quay.io does not include them. Similarly, params like 20 | // `rel="next"` may not have quoted values in the wild. 21 | var nextLinkRE = regexp.MustCompile(`^ *]+)>? *(?:;[^;]*)*; *rel="?next"?(?:;.*)?`) 22 | 23 | func getNextLink(resp *http.Response) (string, error) { 24 | for _, link := range resp.Header[http.CanonicalHeaderKey("Link")] { 25 | parts := nextLinkRE.FindStringSubmatch(link) 26 | if parts != nil { 27 | return parts[1], nil 28 | } 29 | } 30 | return "", ErrNoMorePages 31 | } 32 | 33 | // getPaginatedJSON accepts a string and a pointer, and returns the 34 | // next page URL while updating pointed-to variable with a parsed JSON 35 | // value. When there are no more pages it returns `ErrNoMorePages`. 36 | func (r *Registry) getPaginatedJSON(url string, response interface{}) (string, error) { 37 | resp, err := r.Client.Get(url) 38 | if err != nil { 39 | return "", err 40 | } 41 | defer resp.Body.Close() 42 | 43 | decoder := json.NewDecoder(resp.Body) 44 | err = decoder.Decode(response) 45 | if err != nil { 46 | return "", err 47 | } 48 | next, err := getNextLink(resp) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | if !strings.HasPrefix(next, r.URL) { 54 | next = r.URL + next 55 | } 56 | 57 | return next, nil 58 | } 59 | -------------------------------------------------------------------------------- /registry/docker/manifest.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | manifestv2 "github.com/distribution/distribution/v3/manifest/schema2" 9 | "github.com/opencontainers/go-digest" 10 | oci "github.com/opencontainers/image-spec/specs-go/v1" 11 | ) 12 | 13 | // ManifestDigest - get manifest digest 14 | func (r *Registry) ManifestDigest(repository, reference string) (digest.Digest, error) { 15 | url := r.url("/v2/%s/manifests/%s", repository, reference) 16 | r.Logf("registry.manifest.head url=%s repository=%s reference=%s", url, repository, reference) 17 | 18 | // Try HEAD request first because it's free 19 | resp, err := r.request("HEAD", url) 20 | if err != nil { 21 | return "", err 22 | } 23 | 24 | if hdr := resp.Header.Get("Docker-Content-Digest"); hdr != "" { 25 | return digest.Parse(hdr) 26 | } 27 | 28 | // HEAD request didn't return a digest, attempt to fetch digest from body 29 | r.Logf("registry.manifest.get url=%s repository=%s reference=%s", url, repository, reference) 30 | resp, err = r.request("GET", url) 31 | if err != nil { 32 | return "", err 33 | } 34 | defer resp.Body.Close() 35 | 36 | // Try to get digest from body instead, should be equal to what would be presented 37 | // in Docker-Content-Digest 38 | body, err := io.ReadAll(resp.Body) 39 | if err != nil { 40 | return "", err 41 | } 42 | return digest.FromBytes(body), nil 43 | } 44 | 45 | // request performs a request against a url 46 | func (r *Registry) request(method string, url string) (*http.Response, error) { 47 | req, err := http.NewRequest(method, url, nil) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | req.Header.Set("Accept", strings.Join([]string{manifestv2.MediaTypeManifest, oci.MediaTypeImageIndex, oci.MediaTypeImageManifest}, ",")) 53 | resp, err := r.Client.Do(req) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return resp, nil 59 | } 60 | -------------------------------------------------------------------------------- /registry/docker/manifest_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | manifestV2 "github.com/distribution/distribution/v3/manifest/schema2" 11 | ) 12 | 13 | func TestGetDigest(t *testing.T) { 14 | 15 | req, err := http.NewRequest("GET", "https://registry.opensource.zalan.do/v2/teapot/external-dns/manifests/v0.4.8", nil) 16 | if err != nil { 17 | t.Fatalf("failed to create request: %s", err) 18 | } 19 | req.Header.Set("Accept", manifestV2.MediaTypeManifest) 20 | 21 | client := &http.Client{} 22 | resp, err := client.Do(req) 23 | if err != nil { 24 | t.Fatalf("failed to request: %s", err) 25 | } 26 | defer resp.Body.Close() 27 | 28 | bodyBytes, _ := io.ReadAll(resp.Body) 29 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | w.Header().Add("content-type", "application/vnd.docker.distribution.manifest.v2+json; charset=ISO-8859-1") 31 | io.Copy(w, resp.Body) 32 | 33 | // Reset body for additional calls 34 | resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) 35 | })) 36 | defer ts.Close() 37 | 38 | reg := New(ts.URL, "", "") 39 | 40 | digest, err := reg.ManifestDigest(ts.URL, "notimportant") 41 | if err != nil { 42 | t.Errorf("failed to get digest") 43 | } 44 | 45 | if digest.String() != "sha256:7aa5175f39a7e8a4172972524302c9a8196f681e40d6ee5d2f6bf0ab7d600fee" { 46 | t.Errorf("unexpected digest: %s", digest.String()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /registry/docker/tags.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | func (r *Registry) Tags(repository string) (tags []string, err error) { 4 | url := r.url("/v2/%s/tags/list", repository) 5 | 6 | var response tagsResponse 7 | for { 8 | r.Logf("registry.tags url=%s repository=%s", url, repository) 9 | url, err = r.getPaginatedJSON(url, &response) 10 | switch err { 11 | case ErrNoMorePages: 12 | tags = append(tags, response.Tags...) 13 | return tags, nil 14 | case nil: 15 | tags = append(tags, response.Tags...) 16 | continue 17 | default: 18 | return nil, err 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /registry/docker/tags_test.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetDigestDockerHub(t *testing.T) { 8 | client := New("https://index.docker.io", "", "") 9 | 10 | tags, err := client.Tags("karolisr/keel") 11 | if err != nil { 12 | t.Errorf("failed to get tags, error: %s", err) 13 | } 14 | 15 | if len(tags) == 0 { 16 | t.Errorf("no tags?") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /secrets/match.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | func registryMatches(imageRegistry, secretRegistry string) bool { 9 | 10 | if imageRegistry == secretRegistry { 11 | return true 12 | } 13 | 14 | imageRegistry = stripScheme(imageRegistry) 15 | secretRegistry = stripScheme(secretRegistry) 16 | 17 | if imageRegistry == secretRegistry { 18 | return true 19 | } 20 | 21 | // checking domains only 22 | if domainOnly(imageRegistry) == domainOnly(secretRegistry) { 23 | return true 24 | } 25 | 26 | // stripping any paths 27 | irh, err := url.Parse("https://" + imageRegistry) 28 | if err != nil { 29 | return false 30 | } 31 | srh, err := url.Parse("https://" + secretRegistry) 32 | if err != nil { 33 | return false 34 | } 35 | 36 | if irh.Hostname() == srh.Hostname() { 37 | return true 38 | } 39 | 40 | return false 41 | } 42 | 43 | func stripScheme(url string) string { 44 | 45 | if strings.HasPrefix(url, "http://") { 46 | return strings.TrimPrefix(url, "http://") 47 | } 48 | if strings.HasPrefix(url, "https://") { 49 | return strings.TrimPrefix(url, "https://") 50 | } 51 | return url 52 | } 53 | -------------------------------------------------------------------------------- /secrets/match_test.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import "testing" 4 | 5 | func Test_registryMatches(t *testing.T) { 6 | type args struct { 7 | imageRegistry string 8 | secretRegistry string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want bool 14 | }{ 15 | { 16 | name: "matches", 17 | args: args{imageRegistry: "docker.io", secretRegistry: "docker.io"}, 18 | want: true, 19 | }, 20 | { 21 | name: "doesnt match", 22 | args: args{imageRegistry: "docker.io", secretRegistry: "index.docker.io"}, 23 | want: false, 24 | }, 25 | { 26 | name: "matches, secret with port", 27 | args: args{imageRegistry: "docker.io", secretRegistry: "docker.io:443"}, 28 | want: true, 29 | }, 30 | { 31 | name: "matches, image with port", 32 | args: args{imageRegistry: "docker.io:443", secretRegistry: "docker.io"}, 33 | want: true, 34 | }, 35 | { 36 | name: "matches, image with scheme", 37 | args: args{imageRegistry: "https://docker.io", secretRegistry: "docker.io"}, 38 | want: true, 39 | }, 40 | { 41 | name: "matches, secret with scheme", 42 | args: args{imageRegistry: "docker.io", secretRegistry: "https://docker.io"}, 43 | want: true, 44 | }, 45 | { 46 | name: "matches, both with scheme", 47 | args: args{imageRegistry: "https://docker.io", secretRegistry: "https://docker.io"}, 48 | want: true, 49 | }, 50 | { 51 | name: "matches, both with scheme and port", 52 | args: args{imageRegistry: "https://docker.io:443", secretRegistry: "https://docker.io:443"}, 53 | want: true, 54 | }, 55 | { 56 | name: "matches, both with scheme and port and a URL path in the secret", 57 | args: args{imageRegistry: "https://docker.io:443", secretRegistry: "https://docker.io:443/v1"}, 58 | want: true, 59 | }, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | if got := registryMatches(tt.args.imageRegistry, tt.args.secretRegistry); got != tt.want { 64 | t.Errorf("registryMatches() = %v, want %v", got, tt.want) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /static/keel-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/static/keel-logo.png -------------------------------------------------------------------------------- /static/keel-ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/static/keel-ui.png -------------------------------------------------------------------------------- /static/keel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/static/keel.png -------------------------------------------------------------------------------- /trigger/poll/manager.go: -------------------------------------------------------------------------------- 1 | package poll 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/keel-hq/keel/provider" 9 | 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | // DefaultManager - default manager is responsible for scanning deployments and identifying 14 | // deployments that have market 15 | type DefaultManager struct { 16 | providers provider.Providers 17 | 18 | // repository watcher 19 | watcher Watcher 20 | 21 | mu *sync.Mutex 22 | 23 | // scanTick - scan interval in seconds, defaults to 60 seconds 24 | scanTick int 25 | 26 | // root context 27 | ctx context.Context 28 | } 29 | 30 | // NewPollManager - new default poller 31 | func NewPollManager(providers provider.Providers, watcher Watcher) *DefaultManager { 32 | return &DefaultManager{ 33 | providers: providers, 34 | watcher: watcher, 35 | mu: &sync.Mutex{}, 36 | scanTick: 3, 37 | } 38 | } 39 | 40 | // Start - start scanning deployment for changes 41 | func (s *DefaultManager) Start(ctx context.Context) error { 42 | // setting root context 43 | s.ctx = ctx 44 | 45 | log.Info("trigger.poll.manager: polling trigger configured") 46 | 47 | // initial scan 48 | err := s.scan(ctx) 49 | if err != nil { 50 | log.WithFields(log.Fields{ 51 | "error": err, 52 | }).Error("trigger.poll.manager: scan failed") 53 | } 54 | 55 | ticker := time.NewTicker(time.Duration(s.scanTick) * time.Second) 56 | defer ticker.Stop() 57 | 58 | for { 59 | select { 60 | case <-ctx.Done(): 61 | return nil 62 | case <-ticker.C: 63 | err := s.scan(ctx) 64 | if err != nil { 65 | log.WithFields(log.Fields{ 66 | "error": err, 67 | }).Error("trigger.poll.manager: kubernetes scan failed") 68 | } 69 | } 70 | } 71 | } 72 | 73 | func (s *DefaultManager) scan(ctx context.Context) error { 74 | trackedImages, err := s.providers.TrackedImages() 75 | if err != nil { 76 | return err 77 | } 78 | 79 | err = s.watcher.Watch(trackedImages...) 80 | if err != nil { 81 | log.WithFields(log.Fields{ 82 | "error": err, 83 | }).Error("trigger.poll.manager: got error(-s) while watching images") 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /trigger/pubsub/util.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "regexp" 7 | 8 | log "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // MetadataEndpoint - default metadata server for gcloud pubsub 12 | const MetadataEndpoint = "http://metadata/computeMetadata/v1/instance/attributes/cluster-name" 13 | 14 | func containerRegistrySubName(clusterName, projectID, topic string) string { 15 | 16 | if clusterName == "" { 17 | var err error 18 | clusterName, err = getClusterName(MetadataEndpoint) 19 | if err != nil { 20 | clusterName = "unknown" 21 | log.WithFields(log.Fields{ 22 | "error": err, 23 | "metadata_endpoint": MetadataEndpoint, 24 | }).Warn("trigger.pubsub.containerRegistrySubName: got error while retrieving cluster metadata, messages might be lost if more than one Keel instance is created") 25 | } 26 | } 27 | 28 | return "keel-" + clusterName + "-" + projectID + "-" + topic 29 | } 30 | 31 | // https://cloud.google.com/compute/docs/storing-retrieving-metadata 32 | func getClusterName(metadataEndpoint string) (string, error) { 33 | req, err := http.NewRequest(http.MethodGet, metadataEndpoint, nil) 34 | if err != nil { 35 | return "", err 36 | } 37 | 38 | req.Header.Add("Metadata-Flavor", "Google") 39 | 40 | client := &http.Client{} 41 | resp, err := client.Do(req) 42 | if err != nil { 43 | return "", err 44 | } 45 | defer resp.Body.Close() 46 | body, err := io.ReadAll(resp.Body) 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | return string(body), nil 52 | } 53 | 54 | // isGoogleArtifactRegistry - we only care about gcr.io and pkg.dev images, 55 | // with other registries - we won't be able to receive events. 56 | // Theoretically if someone publishes messages for updated images to 57 | // google pubsub - we could turn this off 58 | func isGoogleArtifactRegistry(registry string) bool { 59 | matched, err := regexp.MatchString(`(gcr\.io|pkg\.dev)`, registry) 60 | if err != nil { 61 | log.WithFields(log.Fields{ 62 | "error": err, 63 | }).Warn("trigger.pubsub.isGoogleArtifactRegistry: got error while checking if registry is gcr") 64 | return false 65 | } 66 | return matched 67 | } 68 | -------------------------------------------------------------------------------- /types/level_jsonenums.go: -------------------------------------------------------------------------------- 1 | // generated by jsonenums -type=Level; DO NOT EDIT 2 | 3 | package types 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | _LevelNameToValue = map[string]Level{ 12 | "LevelDebug": LevelDebug, 13 | "LevelInfo": LevelInfo, 14 | "LevelSuccess": LevelSuccess, 15 | "LevelWarn": LevelWarn, 16 | "LevelError": LevelError, 17 | "LevelFatal": LevelFatal, 18 | } 19 | 20 | _LevelValueToName = map[Level]string{ 21 | LevelDebug: "LevelDebug", 22 | LevelInfo: "LevelInfo", 23 | LevelSuccess: "LevelSuccess", 24 | LevelWarn: "LevelWarn", 25 | LevelError: "LevelError", 26 | LevelFatal: "LevelFatal", 27 | } 28 | ) 29 | 30 | func init() { 31 | var v Level 32 | if _, ok := interface{}(v).(fmt.Stringer); ok { 33 | _LevelNameToValue = map[string]Level{ 34 | interface{}(LevelDebug).(fmt.Stringer).String(): LevelDebug, 35 | interface{}(LevelInfo).(fmt.Stringer).String(): LevelInfo, 36 | interface{}(LevelSuccess).(fmt.Stringer).String(): LevelSuccess, 37 | interface{}(LevelWarn).(fmt.Stringer).String(): LevelWarn, 38 | interface{}(LevelError).(fmt.Stringer).String(): LevelError, 39 | interface{}(LevelFatal).(fmt.Stringer).String(): LevelFatal, 40 | } 41 | } 42 | } 43 | 44 | // MarshalJSON is generated so Level satisfies json.Marshaler. 45 | func (r Level) MarshalJSON() ([]byte, error) { 46 | if s, ok := interface{}(r).(fmt.Stringer); ok { 47 | return json.Marshal(s.String()) 48 | } 49 | s, ok := _LevelValueToName[r] 50 | if !ok { 51 | return nil, fmt.Errorf("invalid Level: %d", r) 52 | } 53 | return json.Marshal(s) 54 | } 55 | 56 | // UnmarshalJSON is generated so Level satisfies json.Unmarshaler. 57 | func (r *Level) UnmarshalJSON(data []byte) error { 58 | var s string 59 | if err := json.Unmarshal(data, &s); err != nil { 60 | return fmt.Errorf("Level should be a string, got %s", data) 61 | } 62 | v, ok := _LevelNameToValue[s] 63 | if !ok { 64 | return fmt.Errorf("invalid Level %q", s) 65 | } 66 | *r = v 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /types/policytype_jsonenums.go: -------------------------------------------------------------------------------- 1 | // generated by jsonenums -type=PolicyType; DO NOT EDIT 2 | 3 | package types 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | _PolicyTypeNameToValue = map[string]PolicyType{ 12 | "PolicyTypeNone": PolicyTypeNone, 13 | "PolicyTypeSemver": PolicyTypeSemver, 14 | "PolicyTypeForce": PolicyTypeForce, 15 | "PolicyTypeGlob": PolicyTypeGlob, 16 | "PolicyTypeRegexp": PolicyTypeRegexp, 17 | } 18 | 19 | _PolicyTypeValueToName = map[PolicyType]string{ 20 | PolicyTypeNone: "PolicyTypeNone", 21 | PolicyTypeSemver: "PolicyTypeSemver", 22 | PolicyTypeForce: "PolicyTypeForce", 23 | PolicyTypeGlob: "PolicyTypeGlob", 24 | PolicyTypeRegexp: "PolicyTypeRegexp", 25 | } 26 | ) 27 | 28 | func init() { 29 | var v PolicyType 30 | if _, ok := interface{}(v).(fmt.Stringer); ok { 31 | _PolicyTypeNameToValue = map[string]PolicyType{ 32 | interface{}(PolicyTypeNone).(fmt.Stringer).String(): PolicyTypeNone, 33 | interface{}(PolicyTypeSemver).(fmt.Stringer).String(): PolicyTypeSemver, 34 | interface{}(PolicyTypeForce).(fmt.Stringer).String(): PolicyTypeForce, 35 | interface{}(PolicyTypeGlob).(fmt.Stringer).String(): PolicyTypeGlob, 36 | interface{}(PolicyTypeRegexp).(fmt.Stringer).String(): PolicyTypeRegexp, 37 | } 38 | } 39 | } 40 | 41 | // MarshalJSON is generated so PolicyType satisfies json.Marshaler. 42 | func (r PolicyType) MarshalJSON() ([]byte, error) { 43 | if s, ok := interface{}(r).(fmt.Stringer); ok { 44 | return json.Marshal(s.String()) 45 | } 46 | s, ok := _PolicyTypeValueToName[r] 47 | if !ok { 48 | return nil, fmt.Errorf("invalid PolicyType: %d", r) 49 | } 50 | return json.Marshal(s) 51 | } 52 | 53 | // UnmarshalJSON is generated so PolicyType satisfies json.Unmarshaler. 54 | func (r *PolicyType) UnmarshalJSON(data []byte) error { 55 | var s string 56 | if err := json.Unmarshal(data, &s); err != nil { 57 | return fmt.Errorf("PolicyType should be a string, got %s", data) 58 | } 59 | v, ok := _PolicyTypeNameToValue[s] 60 | if !ok { 61 | return fmt.Errorf("invalid PolicyType %q", s) 62 | } 63 | *r = v 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /types/providertype_jsonenums.go: -------------------------------------------------------------------------------- 1 | // generated by jsonenums -type=ProviderType; DO NOT EDIT 2 | 3 | package types 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | _ProviderTypeNameToValue = map[string]ProviderType{ 12 | "ProviderTypeUnknown": ProviderTypeUnknown, 13 | "ProviderTypeKubernetes": ProviderTypeKubernetes, 14 | "ProviderTypeHelm": ProviderTypeHelm, 15 | } 16 | 17 | _ProviderTypeValueToName = map[ProviderType]string{ 18 | ProviderTypeUnknown: "ProviderTypeUnknown", 19 | ProviderTypeKubernetes: "ProviderTypeKubernetes", 20 | ProviderTypeHelm: "ProviderTypeHelm", 21 | } 22 | ) 23 | 24 | func init() { 25 | var v ProviderType 26 | if _, ok := interface{}(v).(fmt.Stringer); ok { 27 | _ProviderTypeNameToValue = map[string]ProviderType{ 28 | interface{}(ProviderTypeUnknown).(fmt.Stringer).String(): ProviderTypeUnknown, 29 | interface{}(ProviderTypeKubernetes).(fmt.Stringer).String(): ProviderTypeKubernetes, 30 | interface{}(ProviderTypeHelm).(fmt.Stringer).String(): ProviderTypeHelm, 31 | } 32 | } 33 | } 34 | 35 | // MarshalJSON is generated so ProviderType satisfies json.Marshaler. 36 | func (r ProviderType) MarshalJSON() ([]byte, error) { 37 | if s, ok := interface{}(r).(fmt.Stringer); ok { 38 | return json.Marshal(s.String()) 39 | } 40 | s, ok := _ProviderTypeValueToName[r] 41 | if !ok { 42 | return nil, fmt.Errorf("invalid ProviderType: %d", r) 43 | } 44 | return json.Marshal(s) 45 | } 46 | 47 | // UnmarshalJSON is generated so ProviderType satisfies json.Unmarshaler. 48 | func (r *ProviderType) UnmarshalJSON(data []byte) error { 49 | var s string 50 | if err := json.Unmarshal(data, &s); err != nil { 51 | return fmt.Errorf("ProviderType should be a string, got %s", data) 52 | } 53 | v, ok := _ProviderTypeNameToValue[s] 54 | if !ok { 55 | return fmt.Errorf("invalid ProviderType %q", s) 56 | } 57 | *r = v 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /types/tracked_images.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "github.com/keel-hq/keel/util/image" 6 | ) 7 | 8 | // Credentials - registry credentials 9 | type Credentials struct { 10 | Username, Password string 11 | } 12 | 13 | // TrackedImage - tracked image data+metadata 14 | type TrackedImage struct { 15 | Image *image.Reference `json:"image"` 16 | Trigger TriggerType `json:"trigger"` 17 | PollSchedule string `json:"pollSchedule"` 18 | Provider string `json:"provider"` 19 | Namespace string `json:"namespace"` 20 | Secrets []string `json:"secrets"` 21 | Meta map[string]string `json:"meta"` // metadata supplied by providers 22 | // a list of pre-release tags, ie: 1.0.0-dev, 1.5.0-prod get translated into 23 | // dev, prod 24 | // combined semver tags 25 | Tags []string `json:"tags"` 26 | Policy Policy `json:"policy"` 27 | } 28 | 29 | type Policy interface { 30 | ShouldUpdate(current, new string) (bool, error) 31 | Name() string 32 | Filter(tags []string) []string 33 | Type() PolicyType 34 | KeepTag() bool 35 | } 36 | 37 | func (i TrackedImage) String() string { 38 | return fmt.Sprintf("namespace:%s,image:%s:%s,provider:%s,trigger:%s,sched:%s,secrets:%s", i.Namespace, i.Image.Repository(), i.Image.Tag(), i.Provider, i.Trigger, i.PollSchedule, i.Secrets) 39 | } 40 | -------------------------------------------------------------------------------- /types/triggertype_jsonenums.go: -------------------------------------------------------------------------------- 1 | // generated by jsonenums -type=TriggerType; DO NOT EDIT 2 | 3 | package types 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | _TriggerTypeNameToValue = map[string]TriggerType{ 12 | "TriggerTypeDefault": TriggerTypeDefault, 13 | "TriggerTypePoll": TriggerTypePoll, 14 | } 15 | 16 | _TriggerTypeValueToName = map[TriggerType]string{ 17 | TriggerTypeDefault: "TriggerTypeDefault", 18 | TriggerTypePoll: "TriggerTypePoll", 19 | } 20 | ) 21 | 22 | func init() { 23 | var v TriggerType 24 | if _, ok := interface{}(v).(fmt.Stringer); ok { 25 | _TriggerTypeNameToValue = map[string]TriggerType{ 26 | interface{}(TriggerTypeDefault).(fmt.Stringer).String(): TriggerTypeDefault, 27 | interface{}(TriggerTypePoll).(fmt.Stringer).String(): TriggerTypePoll, 28 | } 29 | } 30 | } 31 | 32 | // MarshalJSON is generated so TriggerType satisfies json.Marshaler. 33 | func (r TriggerType) MarshalJSON() ([]byte, error) { 34 | if s, ok := interface{}(r).(fmt.Stringer); ok { 35 | return json.Marshal(s.String()) 36 | } 37 | s, ok := _TriggerTypeValueToName[r] 38 | if !ok { 39 | return nil, fmt.Errorf("invalid TriggerType: %d", r) 40 | } 41 | return json.Marshal(s) 42 | } 43 | 44 | // UnmarshalJSON is generated so TriggerType satisfies json.Unmarshaler. 45 | func (r *TriggerType) UnmarshalJSON(data []byte) error { 46 | var s string 47 | if err := json.Unmarshal(data, &s); err != nil { 48 | return fmt.Errorf("TriggerType should be a string, got %s", data) 49 | } 50 | v, ok := _TriggerTypeNameToValue[s] 51 | if !ok { 52 | return fmt.Errorf("invalid TriggerType %q", s) 53 | } 54 | *r = v 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /types/version_info.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | // VersionInfo describes version and runtime info. 4 | type VersionInfo struct { 5 | Name string `json:"name"` 6 | BuildDate string `json:"buildDate"` 7 | Revision string `json:"revision"` 8 | Version string `json:"version"` 9 | APIVersion string `json:"apiVersion"` 10 | GoVersion string `json:"goVersion"` 11 | OS string `json:"os"` 12 | Arch string `json:"arch"` 13 | KernelVersion string `json:"kernelVersion"` 14 | Experimental bool `json:"experimental"` 15 | } 16 | 17 | // VersionResponse - version API call response 18 | type VersionResponse struct { 19 | Client *VersionInfo 20 | Server *VersionInfo 21 | } 22 | 23 | // ServerOK returns true when the client could connect to the keel 24 | // and parse the information received. It returns false otherwise. 25 | func (v VersionResponse) ServerOK() bool { 26 | return v.Server != nil 27 | } 28 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset=utf-8 3 | end_of_line=lf 4 | insert_final_newline=false 5 | indent_style=space 6 | indent_size=2 7 | 8 | [{*.ng,*.sht,*.html,*.shtm,*.shtml,*.htm}] 9 | indent_style=space 10 | indent_size=2 11 | 12 | [{*.jhm,*.xslt,*.xul,*.rng,*.xsl,*.xsd,*.ant,*.tld,*.fxml,*.jrxml,*.xml,*.jnlp,*.wsdl}] 13 | indent_style=space 14 | indent_size=2 15 | 16 | [{.babelrc,.stylelintrc,jest.config,.eslintrc,.prettierrc,*.json,*.jsb3,*.jsb2,*.bowerrc}] 17 | indent_style=space 18 | indent_size=2 19 | 20 | [*.svg] 21 | indent_style=space 22 | indent_size=2 23 | 24 | [*.js.map] 25 | indent_style=space 26 | indent_size=2 27 | 28 | [*.less] 29 | indent_style=space 30 | indent_size=2 31 | 32 | [*.vue] 33 | indent_style=space 34 | indent_size=2 35 | 36 | [{.analysis_options,*.yml,*.yaml}] 37 | indent_style=space 38 | indent_size=2 39 | 40 | -------------------------------------------------------------------------------- /ui/.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | VUE_APP_PREVIEW=false 3 | -------------------------------------------------------------------------------- /ui/.gitattributes: -------------------------------------------------------------------------------- 1 | public/* linguist-vendored -------------------------------------------------------------------------------- /ui/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /ui/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10.15.0 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn run lint --no-fix && yarn run build 8 | -------------------------------------------------------------------------------- /ui/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app', 4 | [ 5 | '@babel/preset-env', 6 | { 7 | 'useBuiltIns': 'entry' 8 | } 9 | ] 10 | ] 11 | // if your use import on Demand, Use this code 12 | // , 13 | // plugins: [ 14 | // [ 'import', { 15 | // 'libraryName': 'ant-design-vue', 16 | // 'libraryDirectory': 'es', 17 | // 'style': true // `style: true` 会加载 less 文件 18 | // } ] 19 | // ] 20 | } 21 | -------------------------------------------------------------------------------- /ui/docs/add-page-loading-animate.md: -------------------------------------------------------------------------------- 1 | 为首屏增加 加载动画 2 | ==== 3 | 4 | 5 | 6 | ## 需求 7 | 8 | > 为了缓解用户第一次访问时,加载 JS 过大所导致用户等待白屏时间过长导致的用户体验不好,进行的一个优化动效。 9 | 10 | 11 | 12 | ## 实现方案 13 | 14 | 1. 将 动画加载 dom 元素放在 #app 内,Vue 生命周期开始时,会自动清掉 #app 下的所有元素。 15 | 2. 将 动画加载 dom 元素放在 body 下,Vue 生命周期开始时 App.vue (created, mounted) 调用 `@/utils/utll` 下的 removeLoadingAnimate(#id, timeout) 则会移除加载动画 16 | 17 | 最后一步: 18 | ​ 将样式插入到 `public/index.html` 文件的 `` 最好写成内联 `` 19 | 20 | 21 | 22 | ---- 23 | 24 | 目前提供有两个样式,均在 `public/loading` 文件夹内。且 pro 已经默认使用了一套 loading 动画方案,可以直接参考 `public/index.html` 25 | 26 | 27 | ## 写在最后 28 | 29 | 目前 pro 有页面 overflow 显示出浏览器滚动条时,页面会抖动一下的问题。 30 | 31 | 欢迎各位提供能解决的方案和实现 demo。如果在条件允许的情况下,建议请直接使用 pro 进行改造,也欢迎直接 PR 到 pro 的仓库 32 | -------------------------------------------------------------------------------- /ui/docs/multi-tabs.md: -------------------------------------------------------------------------------- 1 | 多(页签)标签 模式 2 | ==== 3 | 4 | 5 | ## 让框架支持打开的页面增加多标签,可随时切换 6 | 7 | ### 关于如何移除该功能 组件 8 | 1. 移除 `/src/components/layouts/BasicLayout.vue` L3, L12, L19 9 | ```vue 10 | 11 | ``` 12 | 2. 移除 `/src/config/defaultSettings.js` L25 13 | 14 | 3. 移除 `src/store/modules/app.js` L27, L76-L79, L118-L120 15 | 16 | 4. 移除 `src/utils/mixin.js` L21 17 | 18 | 5. 删除组件目录 `src/components/MultiTab` 19 | 20 | > 以上 `L x` 均代表行N ,如 L3 = 行3 -------------------------------------------------------------------------------- /ui/docs/webpack-bundle-analyzer.md: -------------------------------------------------------------------------------- 1 | 先增加依赖 2 | 3 | ```bash 4 | // npm 5 | $ npm install --save-dev webpack-bundle-analyzer 6 | 7 | // or yarn 8 | $ yarn add webpack-bundle-analyzer -D 9 | ``` 10 | 11 | 配置文件 `vue.config.js` 增加 `configureWebpack.plugins` 参数 12 | 13 | ``` 14 | const path = require('path') 15 | const webpack = require('webpack') 16 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 17 | 18 | function resolve (dir) { 19 | return path.join(__dirname, dir) 20 | } 21 | 22 | // vue.config.js 23 | module.exports = { 24 | configureWebpack: { 25 | plugins: [ 26 | // Ignore all locale files of moment.js 27 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 28 | // 依赖大小分析工具 29 | new BundleAnalyzerPlugin(), 30 | ] 31 | }, 32 | 33 | 34 | ... 35 | } 36 | ``` 37 | 38 | 39 | 40 | 启动 `cli` 的 `build` 命令进行项目编译,编译完成时,会自动运行一个 http://localhost:8888 的地址,完整显示了支持库依赖 -------------------------------------------------------------------------------- /ui/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue' 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest' 12 | }, 13 | moduleNameMapper: { 14 | '^@/(.*)$': '/src/$1' 15 | }, 16 | snapshotSerializers: [ 17 | 'jest-serializer-vue' 18 | ], 19 | testMatch: [ 20 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 21 | ], 22 | testURL: 'http://localhost/' 23 | } 24 | -------------------------------------------------------------------------------- /ui/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | } 8 | }, 9 | "exclude": ["node_modules", "dist"], 10 | "include": ["src/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Keel 9 | 10 | 11 | 12 | 15 |
16 |
17 |
18 | 19 |
20 |
21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ui/public/loading/loading.css: -------------------------------------------------------------------------------- 1 | #preloadingAnimation{position:fixed;left:0;top:0;height:100%;width:100%;background:#ffffff;user-select:none;z-index: 9999;overflow: hidden}.lds-roller{display:inline-block;position:relative;left:50%;top:50%;transform:translate(-50%,-50%);width:64px;height:64px;}.lds-roller div{animation:lds-roller 1.2s cubic-bezier(0.5,0,0.5,1) infinite;transform-origin:32px 32px;}.lds-roller div:after{content:" ";display:block;position:absolute;width:6px;height:6px;border-radius:50%;background:#13c2c2;margin:-3px 0 0 -3px;}.lds-roller div:nth-child(1){animation-delay:-0.036s;}.lds-roller div:nth-child(1):after{top:50px;left:50px;}.lds-roller div:nth-child(2){animation-delay:-0.072s;}.lds-roller div:nth-child(2):after{top:54px;left:45px;}.lds-roller div:nth-child(3){animation-delay:-0.108s;}.lds-roller div:nth-child(3):after{top:57px;left:39px;}.lds-roller div:nth-child(4){animation-delay:-0.144s;}.lds-roller div:nth-child(4):after{top:58px;left:32px;}.lds-roller div:nth-child(5){animation-delay:-0.18s;}.lds-roller div:nth-child(5):after{top:57px;left:25px;}.lds-roller div:nth-child(6){animation-delay:-0.216s;}.lds-roller div:nth-child(6):after{top:54px;left:19px;}.lds-roller div:nth-child(7){animation-delay:-0.252s;}.lds-roller div:nth-child(7):after{top:50px;left:14px;}.lds-roller div:nth-child(8){animation-delay:-0.288s;}.lds-roller div:nth-child(8):after{top:45px;left:10px;}#preloadingAnimation .load-tips{color: #13c2c2;font-size:2rem;position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);margin-top:80px;text-align:center;width:400px;height:64px;} @keyframes lds-roller{0%{transform:rotate(0deg);} 100%{transform:rotate(360deg);}} -------------------------------------------------------------------------------- /ui/public/loading/loading.html: -------------------------------------------------------------------------------- 1 |
Loading
-------------------------------------------------------------------------------- /ui/public/loading/option2/html_code_segment.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
-------------------------------------------------------------------------------- /ui/public/loading/option2/loading.css: -------------------------------------------------------------------------------- 1 | .preloading-animate{background:#ffffff;width:100%;height:100%;position:fixed;left:0;top:0;z-index:299;}.preloading-animate .preloading-wrapper{position:absolute;width:5rem;height:5rem;left:50%;top:50%;transform:translate(-50%,-50%);}.preloading-animate .preloading-wrapper .preloading-balls{font-size:5rem;} -------------------------------------------------------------------------------- /ui/public/loading/option2/loading.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/ui/public/logo.png -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | 33 | -------------------------------------------------------------------------------- /ui/src/api/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default { 4 | get (url, request) { 5 | return Vue.http.get(url, request) 6 | .then((response) => Promise.resolve(response.body)) 7 | .catch((error) => Promise.reject(error)) 8 | }, 9 | post (url, request) { 10 | return Vue.http.post(url, request) 11 | .then((response) => Promise.resolve(response)) 12 | .catch((error) => Promise.reject(error)) 13 | }, 14 | put (url, request) { 15 | return Vue.http.put(url, request) 16 | .then((response) => Promise.resolve(response)) 17 | .catch((error) => Promise.reject(error)) 18 | }, 19 | patch (url, request) { 20 | return Vue.http.patch(url, request) 21 | .then((response) => Promise.resolve(response)) 22 | .catch((error) => Promise.reject(error)) 23 | }, 24 | delete (url, request) { 25 | return Vue.http.delete(url, request) 26 | .then((response) => Promise.resolve(response)) 27 | .catch((error) => Promise.reject(error)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /ui/src/assets/icons/bx-analyse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui/src/assets/keel-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/ui/src/assets/keel-logo.png -------------------------------------------------------------------------------- /ui/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/ui/src/assets/logo.png -------------------------------------------------------------------------------- /ui/src/components/ArticleListContent/ArticleListContent.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 47 | 48 | 90 | -------------------------------------------------------------------------------- /ui/src/components/ArticleListContent/index.js: -------------------------------------------------------------------------------- 1 | import ArticleListContent from './ArticleListContent' 2 | 3 | export default ArticleListContent 4 | -------------------------------------------------------------------------------- /ui/src/components/AvatarList/Item.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 47 | -------------------------------------------------------------------------------- /ui/src/components/AvatarList/index.js: -------------------------------------------------------------------------------- 1 | import AvatarList from './List' 2 | import './index.less' 3 | 4 | export default AvatarList 5 | -------------------------------------------------------------------------------- /ui/src/components/AvatarList/index.less: -------------------------------------------------------------------------------- 1 | @import "../index"; 2 | 3 | @avatar-list-prefix-cls: ~"@{ant-pro-prefix}-avatar-list"; 4 | @avatar-list-item-prefix-cls: ~"@{ant-pro-prefix}-avatar-list-item"; 5 | 6 | .@{avatar-list-prefix-cls} { 7 | display: inline-block; 8 | 9 | ul { 10 | list-style: none; 11 | display: inline-block; 12 | padding: 0; 13 | margin: 0 0 0 8px; 14 | font-size: 0; 15 | } 16 | } 17 | 18 | .@{avatar-list-item-prefix-cls} { 19 | display: inline-block; 20 | font-size: @font-size-base; 21 | margin-left: -8px; 22 | width: @avatar-size-base; 23 | height: @avatar-size-base; 24 | 25 | :global { 26 | .ant-avatar { 27 | border: 1px solid #fff; 28 | cursor: pointer; 29 | } 30 | } 31 | 32 | &.large { 33 | width: @avatar-size-lg; 34 | height: @avatar-size-lg; 35 | } 36 | 37 | &.small { 38 | width: @avatar-size-sm; 39 | height: @avatar-size-sm; 40 | } 41 | 42 | &.mini { 43 | width: 20px; 44 | height: 20px; 45 | 46 | :global { 47 | .ant-avatar { 48 | width: 20px; 49 | height: 20px; 50 | line-height: 20px; 51 | 52 | .ant-avatar-string { 53 | font-size: 12px; 54 | line-height: 18px; 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /ui/src/components/Charts/Bar.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 58 | -------------------------------------------------------------------------------- /ui/src/components/Charts/Liquid.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 64 | 65 | 68 | -------------------------------------------------------------------------------- /ui/src/components/Charts/MiniArea.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 53 | 54 | 57 | -------------------------------------------------------------------------------- /ui/src/components/Charts/MiniBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 46 | 47 | 50 | -------------------------------------------------------------------------------- /ui/src/components/Charts/MiniProgress.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | 37 | 76 | -------------------------------------------------------------------------------- /ui/src/components/Charts/MiniSmoothArea.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | 38 | 41 | -------------------------------------------------------------------------------- /ui/src/components/Charts/Radar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 65 | 66 | 69 | -------------------------------------------------------------------------------- /ui/src/components/Charts/RankList.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 30 | 31 | 78 | -------------------------------------------------------------------------------- /ui/src/components/Charts/TransferBar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | -------------------------------------------------------------------------------- /ui/src/components/Charts/Trend.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 52 | 53 | 83 | -------------------------------------------------------------------------------- /ui/src/components/Charts/chart.less: -------------------------------------------------------------------------------- 1 | .antv-chart-mini { 2 | position: relative; 3 | width: 100%; 4 | 5 | .chart-wrapper { 6 | position: absolute; 7 | bottom: -28px; 8 | width: 100%; 9 | 10 | /* margin: 0 -5px; 11 | overflow: hidden;*/ 12 | } 13 | } -------------------------------------------------------------------------------- /ui/src/components/Charts/smooth.area.less: -------------------------------------------------------------------------------- 1 | @import "../index"; 2 | 3 | @smoothArea-prefix-cls: ~"@{ant-pro-prefix}-smooth-area"; 4 | 5 | .@{smoothArea-prefix-cls} { 6 | position: relative; 7 | width: 100%; 8 | 9 | .chart-wrapper { 10 | position: absolute; 11 | bottom: -28px; 12 | width: 100%; 13 | } 14 | } -------------------------------------------------------------------------------- /ui/src/components/CountDown/index.js: -------------------------------------------------------------------------------- 1 | import CountDown from './CountDown' 2 | 3 | export default CountDown 4 | -------------------------------------------------------------------------------- /ui/src/components/CountDown/index.md: -------------------------------------------------------------------------------- 1 | # CountDown 倒计时 2 | 3 | 倒计时组件。 4 | 5 | 6 | 7 | 引用方式: 8 | 9 | ```javascript 10 | import CountDown from '@/components/CountDown/CountDown' 11 | 12 | export default { 13 | components: { 14 | CountDown 15 | } 16 | } 17 | ``` 18 | 19 | 20 | 21 | ## 代码演示 [demo](https://pro.loacg.com/test/home) 22 | 23 | ```html 24 | 25 | ``` 26 | 27 | 28 | 29 | ## API 30 | 31 | | 参数 | 说明 | 类型 | 默认值 | 32 | |----------|------------------------------------------|-------------|-------| 33 | | target | 目标时间 | Date | - | 34 | | onEnd | 倒计时结束回调 | funtion | -| 35 | -------------------------------------------------------------------------------- /ui/src/components/DescriptionList/index.js: -------------------------------------------------------------------------------- 1 | import DescriptionList from './DescriptionList' 2 | export default DescriptionList 3 | -------------------------------------------------------------------------------- /ui/src/components/Ellipsis/Ellipsis.vue: -------------------------------------------------------------------------------- 1 | 65 | -------------------------------------------------------------------------------- /ui/src/components/Ellipsis/index.js: -------------------------------------------------------------------------------- 1 | import Ellipsis from './Ellipsis' 2 | 3 | export default Ellipsis 4 | -------------------------------------------------------------------------------- /ui/src/components/Ellipsis/index.md: -------------------------------------------------------------------------------- 1 | # Ellipsis 文本自动省略号 2 | 3 | 文本过长自动处理省略号,支持按照文本长度和最大行数两种方式截取。 4 | 5 | 6 | 7 | 引用方式: 8 | 9 | ```javascript 10 | import Ellipsis from '@/components/Ellipsis' 11 | 12 | export default { 13 | components: { 14 | Ellipsis 15 | } 16 | } 17 | ``` 18 | 19 | 20 | 21 | ## 代码演示 [demo](https://pro.loacg.com/test/home) 22 | 23 | ```html 24 | 25 | There were injuries alleged in three cases in 2015, and a 26 | fourth incident in September, according to the safety recall report. After meeting with US regulators in October, the firm decided to issue a voluntary recall. 27 | 28 | ``` 29 | 30 | 31 | 32 | ## API 33 | 34 | 35 | 参数 | 说明 | 类型 | 默认值 36 | ----|------|-----|------ 37 | tooltip | 移动到文本展示完整内容的提示 | boolean | - 38 | length | 在按照长度截取下的文本最大字符数,超过则截取省略 | number | - -------------------------------------------------------------------------------- /ui/src/components/Exception/index.js: -------------------------------------------------------------------------------- 1 | import ExceptionPage from './ExceptionPage.vue' 2 | export default ExceptionPage 3 | -------------------------------------------------------------------------------- /ui/src/components/Exception/type.js: -------------------------------------------------------------------------------- 1 | const types = { 2 | 403: { 3 | img: 'https://gw.alipayobjects.com/zos/rmsportal/wZcnGqRDyhPOEYFcZDnb.svg', 4 | title: '403', 5 | desc: 'Not authorized.' 6 | }, 7 | 404: { 8 | img: 'https://gw.alipayobjects.com/zos/rmsportal/KpnpchXsobRgLElEozzI.svg', 9 | title: '404', 10 | desc: 'Lost? Page not found :|' 11 | }, 12 | 500: { 13 | img: 'https://gw.alipayobjects.com/zos/rmsportal/RVRUAYdCGeYNBWoKiIwB.svg', 14 | title: '500', 15 | desc: 'Auch, server error!' 16 | } 17 | } 18 | 19 | export default types 20 | -------------------------------------------------------------------------------- /ui/src/components/FooterToolbar/FooterToolBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | 28 | 31 | -------------------------------------------------------------------------------- /ui/src/components/FooterToolbar/index.js: -------------------------------------------------------------------------------- 1 | import FooterToolBar from './FooterToolBar' 2 | import './index.less' 3 | 4 | export default FooterToolBar 5 | -------------------------------------------------------------------------------- /ui/src/components/FooterToolbar/index.less: -------------------------------------------------------------------------------- 1 | @import "../index"; 2 | 3 | @footer-toolbar-prefix-cls: ~"@{ant-pro-prefix}-footer-toolbar"; 4 | 5 | .@{footer-toolbar-prefix-cls} { 6 | position: fixed; 7 | width: 100%; 8 | bottom: 0; 9 | right: 0; 10 | height: 56px; 11 | line-height: 56px; 12 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.03); 13 | background: #fff; 14 | border-top: 1px solid #e8e8e8; 15 | padding: 0 24px; 16 | z-index: 9; 17 | 18 | &:after { 19 | content: ""; 20 | display: block; 21 | clear: both; 22 | } 23 | } -------------------------------------------------------------------------------- /ui/src/components/FooterToolbar/index.md: -------------------------------------------------------------------------------- 1 | # FooterToolbar 底部工具栏 2 | 3 | 固定在底部的工具栏。 4 | 5 | 6 | 7 | ## 何时使用 8 | 9 | 固定在内容区域的底部,不随滚动条移动,常用于长页面的数据搜集和提交工作。 10 | 11 | 12 | 13 | 引用方式: 14 | 15 | ```javascript 16 | import FooterToolBar from '@/components/FooterToolbar' 17 | 18 | export default { 19 | components: { 20 | FooterToolBar 21 | } 22 | } 23 | ``` 24 | 25 | 26 | 27 | ## 代码演示 28 | 29 | ```html 30 | 31 | 提交 32 | 33 | ``` 34 | 或 35 | ```html 36 | 37 | 提交 38 | 39 | ``` 40 | 41 | 42 | ## API 43 | 44 | 参数 | 说明 | 类型 | 默认值 45 | ----|------|-----|------ 46 | children (slot) | 工具栏内容,向右对齐 | - | - 47 | extra | 额外信息,向左对齐 | String, Object | - 48 | 49 | -------------------------------------------------------------------------------- /ui/src/components/GlobalFooter/GlobalFooter.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | 27 | 54 | -------------------------------------------------------------------------------- /ui/src/components/GlobalFooter/index.js: -------------------------------------------------------------------------------- 1 | import GlobalFooter from './GlobalFooter' 2 | export default GlobalFooter 3 | -------------------------------------------------------------------------------- /ui/src/components/GlobalHeader/index.js: -------------------------------------------------------------------------------- 1 | import GlobalHeader from './GlobalHeader' 2 | export default GlobalHeader 3 | -------------------------------------------------------------------------------- /ui/src/components/IconSelector/IconSelector.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /ui/src/components/IconSelector/README.md: -------------------------------------------------------------------------------- 1 | IconSelector 2 | ==== 3 | 4 | > 图标选择组件,常用于为某一个数据设定一个图标时使用 5 | > eg: 设定菜单列表时,为每个菜单设定一个图标 6 | 7 | 该组件由 [@Saraka](https://github.com/saraka-tsukai) 封装 8 | 9 | 10 | 11 | ### 使用方式 12 | 13 | ```vue 14 | 19 | 20 | 39 | ``` 40 | 41 | 42 | 43 | ### 事件 44 | 45 | 46 | | 名称 | 说明 | 类型 | 默认值 | 47 | | ------ | -------------------------- | ------ | ------ | 48 | | change | 当改变了 `icon` 选中项触发 | String | - | 49 | -------------------------------------------------------------------------------- /ui/src/components/IconSelector/index.js: -------------------------------------------------------------------------------- 1 | import IconSelector from './IconSelector' 2 | export default IconSelector 3 | -------------------------------------------------------------------------------- /ui/src/components/Menu/SideMenu.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 62 | -------------------------------------------------------------------------------- /ui/src/components/Menu/index.js: -------------------------------------------------------------------------------- 1 | import SMenu from './menu' 2 | export default SMenu 3 | -------------------------------------------------------------------------------- /ui/src/components/MultiTab/index.js: -------------------------------------------------------------------------------- 1 | import MultiTab from './MultiTab' 2 | import './index.less' 3 | 4 | export default MultiTab 5 | -------------------------------------------------------------------------------- /ui/src/components/MultiTab/index.less: -------------------------------------------------------------------------------- 1 | @import '../index'; 2 | 3 | @multi-tab-prefix-cls: ~"@{ant-pro-prefix}-multi-tab"; 4 | @multi-tab-wrapper-prefix-cls: ~"@{ant-pro-prefix}-multi-tab-wrapper"; 5 | 6 | /* 7 | .topmenu .@{multi-tab-prefix-cls} { 8 | max-width: 1200px; 9 | margin: -23px auto 24px auto; 10 | } 11 | */ 12 | .@{multi-tab-prefix-cls} { 13 | margin: -23px -24px 24px -24px; 14 | background: #fff; 15 | } 16 | 17 | .topmenu .@{multi-tab-wrapper-prefix-cls} { 18 | max-width: 1200px; 19 | margin: 0 auto; 20 | } 21 | 22 | .topmenu.content-width-Fluid .@{multi-tab-wrapper-prefix-cls} { 23 | max-width: 100%; 24 | margin: 0 auto; 25 | } 26 | -------------------------------------------------------------------------------- /ui/src/components/NoticeIcon/index.js: -------------------------------------------------------------------------------- 1 | import NoticeIcon from './NoticeIcon' 2 | export default NoticeIcon 3 | -------------------------------------------------------------------------------- /ui/src/components/NumberInfo/NumberInfo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 51 | 52 | 55 | -------------------------------------------------------------------------------- /ui/src/components/NumberInfo/index.js: -------------------------------------------------------------------------------- 1 | import NumberInfo from './NumberInfo' 2 | 3 | export default NumberInfo 4 | -------------------------------------------------------------------------------- /ui/src/components/NumberInfo/index.less: -------------------------------------------------------------------------------- 1 | @import "../index"; 2 | 3 | @numberInfo-prefix-cls: ~"@{ant-pro-prefix}-number-info"; 4 | 5 | .@{numberInfo-prefix-cls} { 6 | 7 | .ant-pro-number-info-subtitle { 8 | color: @text-color-secondary; 9 | font-size: @font-size-base; 10 | height: 22px; 11 | line-height: 22px; 12 | overflow: hidden; 13 | text-overflow: ellipsis; 14 | word-break: break-all; 15 | white-space: nowrap; 16 | } 17 | 18 | .number-info-value { 19 | margin-top: 4px; 20 | font-size: 0; 21 | overflow: hidden; 22 | text-overflow: ellipsis; 23 | word-break: break-all; 24 | white-space: nowrap; 25 | 26 | & > span { 27 | color: @heading-color; 28 | display: inline-block; 29 | line-height: 32px; 30 | height: 32px; 31 | font-size: 24px; 32 | margin-right: 32px; 33 | } 34 | 35 | .sub-total { 36 | color: @text-color-secondary; 37 | font-size: @font-size-lg; 38 | vertical-align: top; 39 | margin-right: 0; 40 | i { 41 | font-size: 12px; 42 | transform: scale(0.82); 43 | margin-left: 4px; 44 | } 45 | :global { 46 | .anticon-caret-up { 47 | color: @red-6; 48 | } 49 | .anticon-caret-down { 50 | color: @green-6; 51 | } 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /ui/src/components/NumberInfo/index.md: -------------------------------------------------------------------------------- 1 | # NumberInfo 数据文本 2 | 3 | 常用在数据卡片中,用于突出展示某个业务数据。 4 | 5 | 6 | 7 | 引用方式: 8 | 9 | ```javascript 10 | import NumberInfo from '@/components/NumberInfo' 11 | 12 | export default { 13 | components: { 14 | NumberInfo 15 | } 16 | } 17 | ``` 18 | 19 | 20 | 21 | ## 代码演示 [demo](https://pro.loacg.com/test/home) 22 | 23 | ```html 24 | 29 | ``` 30 | 31 | 32 | 33 | ## API 34 | 35 | 参数 | 说明 | 类型 | 默认值 36 | ----|------|-----|------ 37 | title | 标题 | ReactNode\|string | - 38 | subTitle | 子标题 | ReactNode\|string | - 39 | total | 总量 | ReactNode\|string | - 40 | subTotal | 子总量 | ReactNode\|string | - 41 | status | 增加状态 | 'up \| down' | - 42 | theme | 状态样式 | string | 'light' 43 | gap | 设置数字和描述之间的间距(像素)| number | 8 44 | -------------------------------------------------------------------------------- /ui/src/components/PageHeader/index.js: -------------------------------------------------------------------------------- 1 | import PageHeader from './PageHeader' 2 | export default PageHeader 3 | -------------------------------------------------------------------------------- /ui/src/components/PageLoading/index.jsx: -------------------------------------------------------------------------------- 1 | import { Spin } from 'ant-design-vue' 2 | 3 | export default { 4 | name: 'PageLoading', 5 | render () { 6 | return (
7 | 8 |
) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /ui/src/components/Result/index.js: -------------------------------------------------------------------------------- 1 | import Result from './Result.vue' 2 | export default Result 3 | -------------------------------------------------------------------------------- /ui/src/components/SettingDrawer/SettingItem.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 24 | 25 | 39 | -------------------------------------------------------------------------------- /ui/src/components/SettingDrawer/index.js: -------------------------------------------------------------------------------- 1 | import SettingDrawer from './SettingDrawer' 2 | export default SettingDrawer 3 | -------------------------------------------------------------------------------- /ui/src/components/StandardFormRow/index.js: -------------------------------------------------------------------------------- 1 | import StandardFormRow from './StandardFormRow' 2 | 3 | export default StandardFormRow 4 | -------------------------------------------------------------------------------- /ui/src/components/TagSelect/TagSelectOption.jsx: -------------------------------------------------------------------------------- 1 | import { Tag } from 'ant-design-vue' 2 | const { CheckableTag } = Tag 3 | 4 | export default { 5 | name: 'TagSelectOption', 6 | props: { 7 | prefixCls: { 8 | type: String, 9 | default: 'ant-pro-tag-select-option' 10 | }, 11 | value: { 12 | type: [String, Number, Object], 13 | default: '' 14 | }, 15 | checked: { 16 | type: Boolean, 17 | default: false 18 | } 19 | }, 20 | data () { 21 | return { 22 | localChecked: this.checked || false 23 | } 24 | }, 25 | watch: { 26 | 'checked' (val) { 27 | this.localChecked = val 28 | }, 29 | '$parent.items': { 30 | handler: function (val) { 31 | this.value && val.hasOwnProperty(this.value) && (this.localChecked = val[this.value]) 32 | }, 33 | deep: true 34 | } 35 | }, 36 | render () { 37 | const { $slots, value } = this 38 | const onChange = (checked) => { 39 | this.$emit('change', { value, checked }) 40 | } 41 | return ( 42 | {$slots.default} 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /ui/src/components/Trend/Trend.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 38 | 39 | 42 | -------------------------------------------------------------------------------- /ui/src/components/Trend/index.js: -------------------------------------------------------------------------------- 1 | import Trend from './Trend.vue' 2 | 3 | export default Trend 4 | -------------------------------------------------------------------------------- /ui/src/components/Trend/index.less: -------------------------------------------------------------------------------- 1 | @import "../index"; 2 | 3 | @trend-prefix-cls: ~"@{ant-pro-prefix}-trend"; 4 | 5 | .@{trend-prefix-cls} { 6 | display: inline-block; 7 | font-size: @font-size-base; 8 | line-height: 22px; 9 | 10 | .up, 11 | .down { 12 | margin-left: 4px; 13 | position: relative; 14 | top: 1px; 15 | 16 | i { 17 | font-size: 12px; 18 | transform: scale(0.83); 19 | } 20 | } 21 | 22 | .item-text { 23 | display: inline-block; 24 | margin-left: 8px; 25 | color: rgba(0,0,0,.85); 26 | } 27 | 28 | .up { 29 | color: @red-6; 30 | } 31 | .down { 32 | color: @green-6; 33 | top: -1px; 34 | } 35 | 36 | &.reverse-color .up { 37 | color: @green-6; 38 | } 39 | &.reverse-color .down { 40 | color: @red-6; 41 | } 42 | } -------------------------------------------------------------------------------- /ui/src/components/Trend/index.md: -------------------------------------------------------------------------------- 1 | # Trend 趋势标记 2 | 3 | 趋势符号,标记上升和下降趋势。通常用绿色代表“好”,红色代表“不好”,股票涨跌场景除外。 4 | 5 | 6 | 7 | 引用方式: 8 | 9 | ```javascript 10 | import Trend from '@/components/Trend' 11 | 12 | export default { 13 | components: { 14 | Trend 15 | } 16 | } 17 | ``` 18 | 19 | 20 | 21 | ## 代码演示 [demo](https://pro.loacg.com/test/home) 22 | 23 | ```html 24 | 5% 25 | ``` 26 | 或 27 | ```html 28 | 29 | 工资 30 | 5% 31 | 32 | ``` 33 | 或 34 | ```html 35 | 5% 36 | ``` 37 | 38 | 39 | ## API 40 | 41 | | 参数 | 说明 | 类型 | 默认值 | 42 | |----------|------------------------------------------|-------------|-------| 43 | | flag | 上升下降标识:`up|down` | string | - | 44 | | reverseColor | 颜色反转 | Boolean | false | 45 | 46 | -------------------------------------------------------------------------------- /ui/src/components/_util/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * components util 3 | */ 4 | 5 | /** 6 | * 清理空值,对象 7 | * @param children 8 | * @returns {*[]} 9 | */ 10 | export function filterEmpty (children = []) { 11 | return children.filter(c => c.tag || (c.text && c.text.trim() !== '')) 12 | } 13 | 14 | /** 15 | * 获取字符串长度,英文字符 长度1,中文字符长度2 16 | * @param {*} str 17 | */ 18 | export const getStrFullLength = (str = '') => 19 | str.split('').reduce((pre, cur) => { 20 | const charCode = cur.charCodeAt(0) 21 | if (charCode >= 0 && charCode <= 128) { 22 | return pre + 1 23 | } 24 | return pre + 2 25 | }, 0) 26 | 27 | /** 28 | * 截取字符串,根据 maxLength 截取后返回 29 | * @param {*} str 30 | * @param {*} maxLength 31 | */ 32 | export const cutStrByFullLength = (str = '', maxLength) => { 33 | let showLength = 0 34 | return str.split('').reduce((pre, cur) => { 35 | const charCode = cur.charCodeAt(0) 36 | if (charCode >= 0 && charCode <= 128) { 37 | showLength += 1 38 | } else { 39 | showLength += 2 40 | } 41 | if (showLength <= maxLength) { 42 | return pre + cur 43 | } 44 | return pre 45 | }, '') 46 | } 47 | -------------------------------------------------------------------------------- /ui/src/components/index.js: -------------------------------------------------------------------------------- 1 | // chart 2 | import Bar from '@/components/Charts/Bar' 3 | import ChartCard from '@/components/Charts/ChartCard' 4 | import Liquid from '@/components/Charts/Liquid' 5 | import MiniArea from '@/components/Charts/MiniArea' 6 | import MiniSmoothArea from '@/components/Charts/MiniSmoothArea' 7 | import MiniBar from '@/components/Charts/MiniBar' 8 | import MiniProgress from '@/components/Charts/MiniProgress' 9 | import Radar from '@/components/Charts/Radar' 10 | import RankList from '@/components/Charts/RankList' 11 | import TransferBar from '@/components/Charts/TransferBar' 12 | import TagCloud from '@/components/Charts/TagCloud' 13 | 14 | // pro components 15 | import AvatarList from '@/components/AvatarList' 16 | import CountDown from '@/components/CountDown' 17 | import Ellipsis from '@/components/Ellipsis' 18 | import FooterToolbar from '@/components/FooterToolbar' 19 | import NumberInfo from '@/components/NumberInfo' 20 | import DescriptionList from '@/components/DescriptionList' 21 | import Tree from '@/components/Tree/Tree' 22 | import Trend from '@/components/Trend' 23 | import STable from '@/components/Table' 24 | import MultiTab from '@/components/MultiTab' 25 | import Result from '@/components/Result' 26 | import IconSelector from '@/components/IconSelector' 27 | import TagSelect from '@/components/TagSelect' 28 | import ExceptionPage from '@/components/Exception' 29 | import StandardFormRow from '@/components/StandardFormRow' 30 | import ArticleListContent from '@/components/ArticleListContent' 31 | 32 | export { 33 | AvatarList, 34 | Bar, 35 | ChartCard, 36 | Liquid, 37 | MiniArea, 38 | MiniSmoothArea, 39 | MiniBar, 40 | MiniProgress, 41 | Radar, 42 | TagCloud, 43 | RankList, 44 | TransferBar, 45 | Trend, 46 | CountDown, 47 | Ellipsis, 48 | FooterToolbar, 49 | NumberInfo, 50 | DescriptionList, 51 | // 兼容写法,请勿继续使用 52 | DescriptionList as DetailList, 53 | Tree, 54 | STable, 55 | MultiTab, 56 | Result, 57 | ExceptionPage, 58 | IconSelector, 59 | TagSelect, 60 | StandardFormRow, 61 | ArticleListContent 62 | } 63 | -------------------------------------------------------------------------------- /ui/src/components/index.less: -------------------------------------------------------------------------------- 1 | @import "~ant-design-vue/lib/style/index"; 2 | 3 | // The prefix to use on all css classes from ant-pro. 4 | @ant-pro-prefix : ant-pro; -------------------------------------------------------------------------------- /ui/src/components/tools/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 43 | 44 | 46 | -------------------------------------------------------------------------------- /ui/src/components/tools/DetailList.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /ui/src/components/tools/HeadInfo.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | 33 | 68 | -------------------------------------------------------------------------------- /ui/src/components/tools/Logo.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 33 | -------------------------------------------------------------------------------- /ui/src/components/tools/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/keel-hq/keel/13a7b5aa509b3f88e13b77c2962af837c770864e/ui/src/components/tools/index.js -------------------------------------------------------------------------------- /ui/src/config/defaultDarkModeSettings.js: -------------------------------------------------------------------------------- 1 | 2 | export default { 3 | primaryColor: '#1890FF', // primary color of ant design 4 | navTheme: 'light', // theme for nav menu 5 | layout: 'topmenu', // nav menu position: sidemenu or topmenu 6 | contentWidth: 'Fixed', // layout of content: Fluid or Fixed, only works when layout is topmenu 7 | fixedHeader: false, // sticky header 8 | fixSiderbar: false, // sticky siderbar 9 | autoHideHeader: false, // auto hide header 10 | colorWeak: true, 11 | multiTab: false, 12 | production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true', 13 | // vue-ls options 14 | storageOptions: { 15 | namespace: 'pro__', 16 | name: 'ls', 17 | storage: 'local' 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/config/defaultSettings.js: -------------------------------------------------------------------------------- 1 | // export default { 2 | // primaryColor: '#1890FF', // primary color of ant design 3 | // navTheme: 'light', // theme for nav menu 4 | // layout: 'topmenu', // nav menu position: sidemenu or topmenu 5 | // contentWidth: 'Fluid', // layout of content: Fluid or Fixed, only works when layout is topmenu 6 | // fixedHeader: false, // sticky header 7 | // fixSiderbar: false, // sticky siderbar 8 | // autoHideHeader: false, // auto hide header 9 | // colorWeak: false, 10 | // multiTab: false, 11 | // production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true', 12 | // // vue-ls options 13 | // storageOptions: { 14 | // namespace: 'pro__', // key prefix 15 | // name: 'ls', // name variable Vue.[ls] or this.[$ls], 16 | // storage: 'local' // storage name session, local, memory 17 | // } 18 | // } 19 | 20 | export default { 21 | primaryColor: '#1890FF', // primary color of ant design 22 | navTheme: 'light', // theme for nav menu 23 | layout: 'topmenu', // nav menu position: sidemenu or topmenu 24 | contentWidth: 'Fluid', // layout of content: Fluid or Fixed, only works when layout is topmenu 25 | fixedHeader: false, // sticky header 26 | fixSiderbar: false, // sticky siderbar 27 | autoHideHeader: false, // auto hide header 28 | colorWeak: false, 29 | multiTab: false, 30 | production: process.env.NODE_ENV === 'production' && process.env.VUE_APP_PREVIEW !== 'true', 31 | // vue-ls options 32 | storageOptions: { 33 | namespace: 'keel__', 34 | name: 'ls', 35 | storage: 'local' 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /ui/src/core/bootstrap.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import store from '@/store/' 3 | import { 4 | ACCESS_TOKEN, 5 | DEFAULT_COLOR, 6 | DEFAULT_THEME, 7 | DEFAULT_LAYOUT_MODE, 8 | DEFAULT_COLOR_WEAK, 9 | SIDEBAR_TYPE, 10 | DEFAULT_FIXED_HEADER, 11 | DEFAULT_FIXED_HEADER_HIDDEN, 12 | DEFAULT_FIXED_SIDEMENU, 13 | DEFAULT_CONTENT_WIDTH_TYPE, 14 | DEFAULT_MULTI_TAB 15 | } from '@/store/mutation-types' 16 | import config from '@/config/defaultSettings' 17 | 18 | export default function Initializer () { 19 | store.commit('SET_SIDEBAR_TYPE', Vue.ls.get(SIDEBAR_TYPE, true)) 20 | store.commit('TOGGLE_THEME', Vue.ls.get(DEFAULT_THEME, config.navTheme)) 21 | store.commit('TOGGLE_LAYOUT_MODE', Vue.ls.get(DEFAULT_LAYOUT_MODE, config.layout)) 22 | store.commit('TOGGLE_FIXED_HEADER', Vue.ls.get(DEFAULT_FIXED_HEADER, config.fixedHeader)) 23 | store.commit('TOGGLE_FIXED_SIDERBAR', Vue.ls.get(DEFAULT_FIXED_SIDEMENU, config.fixSiderbar)) 24 | store.commit('TOGGLE_CONTENT_WIDTH', Vue.ls.get(DEFAULT_CONTENT_WIDTH_TYPE, config.contentWidth)) 25 | store.commit('TOGGLE_FIXED_HEADER_HIDDEN', Vue.ls.get(DEFAULT_FIXED_HEADER_HIDDEN, config.autoHideHeader)) 26 | store.commit('TOGGLE_WEAK', Vue.ls.get(DEFAULT_COLOR_WEAK, config.colorWeak)) 27 | store.commit('TOGGLE_COLOR', Vue.ls.get(DEFAULT_COLOR, config.primaryColor)) 28 | store.commit('TOGGLE_MULTI_TAB', Vue.ls.get(DEFAULT_MULTI_TAB, config.multiTab)) 29 | store.commit('SET_TOKEN', Vue.ls.get(ACCESS_TOKEN)) 30 | 31 | // last step 32 | } 33 | -------------------------------------------------------------------------------- /ui/src/core/directives/action.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import store from '@/store' 3 | 4 | /** 5 | * Action 权限指令 6 | * 指令用法: 7 | * - 在需要控制 action 级别权限的组件上使用 v-action:[method] , 如下: 8 | * 添加用户 9 | * 删除用户 10 | * 修改 11 | * 12 | * - 当前用户没有权限时,组件上使用了该指令则会被隐藏 13 | * - 当后台权限跟 pro 提供的模式不同时,只需要针对这里的权限过滤进行修改即可 14 | * 15 | * @see https://github.com/sendya/ant-design-pro-vue/pull/53 16 | */ 17 | const action = Vue.directive('action', { 18 | inserted: function (el, binding, vnode) { 19 | const actionName = binding.arg 20 | const roles = store.getters.roles 21 | const elVal = vnode.context.$route.meta.permission 22 | const permissionId = elVal instanceof String && [elVal] || elVal 23 | roles.permissions.forEach(p => { 24 | if (!permissionId.includes(p.permissionId)) { 25 | return 26 | } 27 | if (p.actionList && !p.actionList.includes(actionName)) { 28 | el.parentNode && el.parentNode.removeChild(el) || (el.style.display = 'none') 29 | } 30 | }) 31 | } 32 | }) 33 | 34 | export default action 35 | -------------------------------------------------------------------------------- /ui/src/core/icons.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Custom icon list 3 | * All icons are loaded here for easy management 4 | * @see https://vue.ant.design/components/icon/#Custom-Font-Icon 5 | * 6 | * 自定义图标加载表 7 | * 所有图标均从这里加载,方便管理 8 | */ 9 | import bxAnaalyse from '@/assets/icons/bx-analyse.svg?inline' // path to your '*.svg?inline' file. 10 | 11 | export { bxAnaalyse } 12 | -------------------------------------------------------------------------------- /ui/src/core/lazy_lib/components_use.js: -------------------------------------------------------------------------------- 1 | 2 | /* eslint-disable */ 3 | /** 4 | * 该文件是为了按需加载,剔除掉了一些不需要的框架组件。 5 | * 减少了编译支持库包大小 6 | * 7 | * 当需要更多组件依赖时,在该文件加入即可 8 | */ 9 | import Vue from 'vue' 10 | import { 11 | LocaleProvider, 12 | Layout, 13 | Input, 14 | InputNumber, 15 | Button, 16 | Switch, 17 | Radio, 18 | Checkbox, 19 | Select, 20 | Card, 21 | Form, 22 | Row, 23 | Col, 24 | Modal, 25 | Table, 26 | Tabs, 27 | Icon, 28 | Badge, 29 | Popover, 30 | Dropdown, 31 | List, 32 | Avatar, 33 | Breadcrumb, 34 | Steps, 35 | Spin, 36 | Menu, 37 | Drawer, 38 | Tooltip, 39 | Alert, 40 | Tag, 41 | Divider, 42 | DatePicker, 43 | TimePicker, 44 | Upload, 45 | Progress, 46 | Skeleton, 47 | Popconfirm, 48 | message, 49 | notification 50 | } from 'ant-design-vue' 51 | // import VueCropper from 'vue-cropper' 52 | 53 | Vue.use(LocaleProvider) 54 | Vue.use(Layout) 55 | Vue.use(Input) 56 | Vue.use(InputNumber) 57 | Vue.use(Button) 58 | Vue.use(Switch) 59 | Vue.use(Radio) 60 | Vue.use(Checkbox) 61 | Vue.use(Select) 62 | Vue.use(Card) 63 | Vue.use(Form) 64 | Vue.use(Row) 65 | Vue.use(Col) 66 | Vue.use(Modal) 67 | Vue.use(Table) 68 | Vue.use(Tabs) 69 | Vue.use(Icon) 70 | Vue.use(Badge) 71 | Vue.use(Popover) 72 | Vue.use(Dropdown) 73 | Vue.use(List) 74 | Vue.use(Avatar) 75 | Vue.use(Breadcrumb) 76 | Vue.use(Steps) 77 | Vue.use(Spin) 78 | Vue.use(Menu) 79 | Vue.use(Drawer) 80 | Vue.use(Tooltip) 81 | Vue.use(Alert) 82 | Vue.use(Tag) 83 | Vue.use(Divider) 84 | Vue.use(DatePicker) 85 | Vue.use(TimePicker) 86 | Vue.use(Upload) 87 | Vue.use(Progress) 88 | Vue.use(Skeleton) 89 | Vue.use(Popconfirm) 90 | // Vue.use(VueCropper) 91 | Vue.use(notification) 92 | 93 | Vue.prototype.$confirm = Modal.confirm 94 | Vue.prototype.$message = message 95 | Vue.prototype.$notification = notification 96 | Vue.prototype.$info = Modal.info 97 | Vue.prototype.$success = Modal.success 98 | Vue.prototype.$error = Modal.error 99 | Vue.prototype.$warning = Modal.warning -------------------------------------------------------------------------------- /ui/src/core/lazy_use.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueStorage from 'vue-ls' 3 | import config from '@/config/defaultSettings' 4 | 5 | // base library 6 | import '@/core/lazy_lib/components_use' 7 | import Viser from 'viser-vue' 8 | 9 | // ext library 10 | import VueClipboard from 'vue-clipboard2' 11 | import PermissionHelper from '@/utils/helper/permission' 12 | import './directives/action' 13 | 14 | VueClipboard.config.autoSetContainer = true 15 | 16 | Vue.use(Viser) 17 | 18 | Vue.use(VueStorage, config.storageOptions) 19 | Vue.use(VueClipboard) 20 | Vue.use(PermissionHelper) 21 | -------------------------------------------------------------------------------- /ui/src/core/use.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueStorage from 'vue-ls' 3 | import config from '@/config/defaultSettings' 4 | 5 | // base library 6 | import Antd from 'ant-design-vue' 7 | import Viser from 'viser-vue' 8 | import VueCropper from 'vue-cropper' 9 | import 'ant-design-vue/dist/antd.less' 10 | 11 | // ext library 12 | import VueClipboard from 'vue-clipboard2' 13 | import PermissionHelper from '@/utils/helper/permission' 14 | // import '@/components/use' 15 | import './directives/action' 16 | 17 | VueClipboard.config.autoSetContainer = true 18 | 19 | Vue.use(Antd) 20 | Vue.use(Viser) 21 | 22 | Vue.use(VueStorage, config.storageOptions) 23 | Vue.use(VueClipboard) 24 | Vue.use(PermissionHelper) 25 | Vue.use(VueCropper) 26 | -------------------------------------------------------------------------------- /ui/src/layouts/BlankLayout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 17 | -------------------------------------------------------------------------------- /ui/src/layouts/RouteView.vue: -------------------------------------------------------------------------------- 1 | 34 | -------------------------------------------------------------------------------- /ui/src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import UserLayout from './UserLayout' 2 | import BlankLayout from './BlankLayout' 3 | import BasicLayout from './BasicLayout' 4 | import RouteView from './RouteView' 5 | import PageView from './PageView' 6 | 7 | export { UserLayout, BasicLayout, BlankLayout, RouteView, PageView } 8 | -------------------------------------------------------------------------------- /ui/src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import { constantRouterMap, asyncRouterMap } from '@/config/router.config' 4 | 5 | Vue.use(Router) 6 | 7 | export default new Router({ 8 | mode: 'history', 9 | base: process.env.BASE_URL, 10 | scrollBehavior: () => ({ y: 0 }), 11 | // routes: constantRouterMap 12 | routes: constantRouterMap.concat(asyncRouterMap) 13 | }) 14 | -------------------------------------------------------------------------------- /ui/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | import app from './modules/app' 5 | import user from './modules/user' 6 | import tracked from './modules/tracked' 7 | import resources from './modules/resources' 8 | import approvals from './modules/approvals' 9 | import audit from './modules/audit' 10 | import stats from './modules/stats' 11 | import permission from './modules/permission' 12 | import getters from './getters' 13 | 14 | Vue.use(Vuex) 15 | 16 | export default new Vuex.Store({ 17 | modules: { 18 | app, 19 | user, 20 | permission, 21 | tracked, 22 | resources, 23 | approvals, 24 | audit, 25 | stats 26 | }, 27 | state: { 28 | 29 | }, 30 | mutations: { 31 | 32 | }, 33 | actions: { 34 | 35 | }, 36 | getters 37 | }) 38 | -------------------------------------------------------------------------------- /ui/src/store/modules/approvals.js: -------------------------------------------------------------------------------- 1 | import api from '@/api/index.js' 2 | 3 | const approvals = { 4 | state: { 5 | approvals: [], 6 | error: null 7 | }, 8 | 9 | mutations: { 10 | SET_APPROVALS: (state, approvals) => { 11 | state.approvals = [] 12 | state.approvals = approvals 13 | }, 14 | SET_ERROR: (state, error) => { 15 | state.error = error 16 | }, 17 | SET_APPROVAL_LOADING: (state, identifier) => { 18 | var arrayLength = state.approvals.length 19 | for (var i = 0; i < arrayLength; i++) { 20 | if (state.approvals[i].identifier === identifier) { 21 | state.approvals[i]._loading = true 22 | } 23 | } 24 | } 25 | }, 26 | 27 | actions: { 28 | GetApprovals ({ commit }) { 29 | commit('SET_ERROR', null) 30 | return api.get('approvals') 31 | .then((response) => { 32 | commit('SET_APPROVALS', response) 33 | }) 34 | .catch((error) => commit('SET_ERROR', error)) 35 | }, 36 | UpdateApproval ({ commit }, payload) { // can reject/approve 37 | commit('SET_ERROR', null) 38 | commit('SET_APPROVAL_LOADING', payload.identifier) 39 | return api.post(`approvals`, payload) 40 | .then((response) => commit('SET_ERROR', null)) 41 | .catch((error) => commit('SET_ERROR', error)) 42 | }, 43 | SetApproval ({ commit }, payload) { // can increase/decrease approvals count 44 | commit('SET_ERROR', null) 45 | commit('SET_APPROVAL_LOADING', payload.identifier) 46 | return api.put(`approvals`, payload) 47 | .then((response) => commit('SET_ERROR', null)) 48 | .catch((error) => commit('SET_ERROR', error)) 49 | } 50 | } 51 | } 52 | 53 | export default approvals 54 | -------------------------------------------------------------------------------- /ui/src/store/modules/audit.js: -------------------------------------------------------------------------------- 1 | import api from '@/api/index.js' 2 | 3 | const audit = { 4 | state: { 5 | audit_logs: [], 6 | pagination: { 7 | limit: 100, 8 | offset: 0, 9 | total: 5 10 | }, 11 | error: null, 12 | loading: false 13 | }, 14 | 15 | mutations: { 16 | SET_AUDIT_LOGS: (state, logs) => { 17 | state.audit_logs = logs 18 | }, 19 | SET_PAGINATION: (state, pagination) => { 20 | state.pagination = pagination 21 | }, 22 | SET_ERROR: (state, error) => { 23 | state.error = error 24 | }, 25 | SET_LOADING: (state, loading) => { 26 | state.loading = loading 27 | } 28 | }, 29 | 30 | actions: { 31 | GetAuditLogs ({ commit }, query) { 32 | commit('SET_ERROR', null) 33 | commit('SET_LOADING', true) 34 | return api.get(`audit?filter=${query.filter}&limit=${query.limit}&offset=${query.offset}`) 35 | .then((response) => { 36 | commit('SET_AUDIT_LOGS', response.data) 37 | const pagination = { 38 | limit: response.limit, 39 | offset: response.offset, 40 | total: response.total 41 | } 42 | commit('SET_PAGINATION', pagination) 43 | commit('SET_LOADING', false) 44 | }) 45 | .catch((error) => { 46 | commit('SET_LOADING', false) 47 | commit('SET_ERROR', error) 48 | }) 49 | } 50 | } 51 | } 52 | 53 | export default audit 54 | -------------------------------------------------------------------------------- /ui/src/store/modules/permission.js: -------------------------------------------------------------------------------- 1 | import { asyncRouterMap, constantRouterMap } from '@/config/router.config' 2 | 3 | const permission = { 4 | state: { 5 | routers: constantRouterMap, 6 | addRouters: asyncRouterMap 7 | }, 8 | mutations: { 9 | SET_ROUTERS: (state, routers) => { 10 | state.addRouters = routers 11 | state.routers = constantRouterMap.concat(routers) 12 | } 13 | }, 14 | actions: { 15 | GenerateRoutes ({ commit }, data) { 16 | return new Promise(resolve => { 17 | // const { roles } = data 18 | console.log('GenerateRoutes - not doing anything') 19 | // console.log('generating routes') 20 | // console.log(roles) 21 | // const accessedRouters = filterAsyncRouter(asyncRouterMap, roles) 22 | // commit('SET_ROUTERS', accessedRouters) 23 | resolve() 24 | }) 25 | } 26 | } 27 | } 28 | 29 | export default permission 30 | -------------------------------------------------------------------------------- /ui/src/store/modules/stats.js: -------------------------------------------------------------------------------- 1 | import api from '@/api/index.js' 2 | 3 | const stats = { 4 | state: { 5 | stats: [], 6 | totalUpdatesThisPeriod: 0, 7 | error: null, 8 | loading: false 9 | }, 10 | 11 | mutations: { 12 | SET_STATS: (state, stats) => { 13 | state.stats = stats 14 | 15 | // calculate stats 16 | let total = 0 17 | var arrayLength = stats.length 18 | for (var i = 0; i < arrayLength; i++) { 19 | total += stats[i].updates 20 | } 21 | state.totalUpdatesThisPeriod = total 22 | }, 23 | SET_ERROR: (state, error) => { 24 | state.error = error 25 | }, 26 | SET_LOADING: (state, loading) => { 27 | state.loading = loading 28 | } 29 | }, 30 | 31 | actions: { 32 | GetStats ({ commit }) { 33 | commit('SET_ERROR', null) 34 | commit('SET_LOADING', true) 35 | return api.get(`stats`) 36 | .then((response) => { 37 | commit('SET_STATS', response) 38 | commit('SET_LOADING', false) 39 | }) 40 | .catch((error) => { 41 | commit('SET_LOADING', false) 42 | commit('SET_ERROR', error) 43 | }) 44 | } 45 | } 46 | } 47 | 48 | export default stats 49 | -------------------------------------------------------------------------------- /ui/src/store/modules/tracked.js: -------------------------------------------------------------------------------- 1 | import api from '@/api/index.js' 2 | 3 | const tracked = { 4 | state: { 5 | images: [], 6 | error: null 7 | }, 8 | 9 | mutations: { 10 | SET_IMAGES: (state, images) => { 11 | var arrayLength = images.length 12 | // adding IDs so that the table is happy 13 | for (var i = 0; i < arrayLength; i++) { 14 | images[i].id = i.toString() 15 | if (images[i].trigger === 'default') { 16 | images[i].trigger = 'webhook/GCR' 17 | } 18 | } 19 | state.images = images 20 | }, 21 | SET_ERROR: (state, error) => { 22 | state.error = error 23 | } 24 | }, 25 | 26 | actions: { 27 | GetTrackedImages ({ commit }) { 28 | return api.get('tracked') 29 | .then((response) => { 30 | commit('SET_IMAGES', response) 31 | }) 32 | .catch((error) => commit('SET_ERROR', error)) 33 | }, 34 | SetTracking ({ commit }, payload) { 35 | commit('SET_ERROR', null) 36 | return api.put(`tracked`, payload) 37 | .then((response) => commit('SET_ERROR', null)) 38 | .catch((error) => commit('SET_ERROR', error)) 39 | } 40 | } 41 | } 42 | 43 | export default tracked 44 | -------------------------------------------------------------------------------- /ui/src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const ACCESS_TOKEN = 'Access-Token' 2 | 3 | export const USERNAME = 'USERNAME' 4 | export const PASSWORD = 'PASSWORD' 5 | 6 | export const MODE = 'MODE' 7 | export const TRACKED_IMAGES = 'TRACKED_IMAGES' 8 | export const SIDEBAR_TYPE = 'SIDEBAR_TYPE' 9 | export const DEFAULT_THEME = 'DEFAULT_THEME' 10 | export const DEFAULT_LAYOUT_MODE = 'DEFAULT_LAYOUT_MODE' 11 | export const DEFAULT_COLOR = 'DEFAULT_COLOR' 12 | export const DEFAULT_COLOR_WEAK = 'DEFAULT_COLOR_WEAK' 13 | export const DEFAULT_FIXED_HEADER = 'DEFAULT_FIXED_HEADER' 14 | export const DEFAULT_FIXED_SIDEMENU = 'DEFAULT_FIXED_SIDEMENU' 15 | export const DEFAULT_FIXED_HEADER_HIDDEN = 'DEFAULT_FIXED_HEADER_HIDDEN' 16 | export const DEFAULT_CONTENT_WIDTH_TYPE = 'DEFAULT_CONTENT_WIDTH_TYPE' 17 | export const DEFAULT_MULTI_TAB = 'DEFAULT_MULTI_TAB' 18 | 19 | export const CONTENT_WIDTH_TYPE = { 20 | Fluid: 'Fluid', 21 | Fixed: 'Fixed' 22 | } 23 | -------------------------------------------------------------------------------- /ui/src/utils/device.js: -------------------------------------------------------------------------------- 1 | import enquireJs from 'enquire.js' 2 | 3 | export const DEVICE_TYPE = { 4 | DESKTOP: 'desktop', 5 | TABLET: 'tablet', 6 | MOBILE: 'mobile' 7 | } 8 | 9 | export const deviceEnquire = function (callback) { 10 | const matchDesktop = { 11 | match: () => { 12 | callback && callback(DEVICE_TYPE.DESKTOP) 13 | } 14 | } 15 | 16 | const matchLablet = { 17 | match: () => { 18 | callback && callback(DEVICE_TYPE.TABLET) 19 | } 20 | } 21 | 22 | const matchMobile = { 23 | match: () => { 24 | callback && callback(DEVICE_TYPE.MOBILE) 25 | } 26 | } 27 | 28 | // screen and (max-width: 1087.99px) 29 | enquireJs 30 | .register('screen and (max-width: 576px)', matchMobile) 31 | .register('screen and (min-width: 576px) and (max-width: 1199px)', matchLablet) 32 | .register('screen and (min-width: 1200px)', matchDesktop) 33 | } 34 | -------------------------------------------------------------------------------- /ui/src/utils/domUtil.js: -------------------------------------------------------------------------------- 1 | export const setDocumentTitle = function (title) { 2 | document.title = title 3 | const ua = navigator.userAgent 4 | // eslint-disable-next-line 5 | const regex = /\bMicroMessenger\/([\d\.]+)/ 6 | if (regex.test(ua) && /ip(hone|od|ad)/i.test(ua)) { 7 | const i = document.createElement('iframe') 8 | i.src = '/favicon.ico' 9 | i.style.display = 'none' 10 | i.onload = function () { 11 | setTimeout(function () { 12 | i.remove() 13 | }, 9) 14 | } 15 | document.body.appendChild(i) 16 | } 17 | } 18 | 19 | export const domTitle = 'Keel' 20 | -------------------------------------------------------------------------------- /ui/src/utils/filter.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import moment from 'moment' 3 | import 'moment/locale/zh-cn' 4 | moment.locale('zh-cn') 5 | 6 | Vue.filter('NumberFormat', function (value) { 7 | if (!value) { 8 | return '0' 9 | } 10 | const intPartFormat = value.toString().replace(/(\d)(?=(?:\d{3})+$)/g, '$1,') // 将整数部分逢三一断 11 | return intPartFormat 12 | }) 13 | 14 | Vue.filter('dayjs', function (dataStr, pattern = 'YYYY-MM-DD HH:mm:ss') { 15 | return moment(dataStr).format(pattern) 16 | }) 17 | 18 | Vue.filter('moment', function (dataStr, pattern = 'YYYY-MM-DD HH:mm:ss') { 19 | return moment(dataStr).format(pattern) 20 | }) 21 | -------------------------------------------------------------------------------- /ui/src/utils/helper/permission.js: -------------------------------------------------------------------------------- 1 | const PERMISSION_ENUM = { 2 | 'add': { key: 'add', label: '新增' }, 3 | 'delete': { key: 'delete', label: '删除' }, 4 | 'edit': { key: 'edit', label: '修改' }, 5 | 'query': { key: 'query', label: '查询' }, 6 | 'get': { key: 'get', label: '详情' }, 7 | 'enable': { key: 'enable', label: '启用' }, 8 | 'disable': { key: 'disable', label: '禁用' }, 9 | 'import': { key: 'import', label: '导入' }, 10 | 'export': { key: 'export', label: '导出' } 11 | } 12 | 13 | function plugin (Vue) { 14 | if (plugin.installed) { 15 | return 16 | } 17 | 18 | // !Vue.prototype.$auth && Object.defineProperties(Vue.prototype, { 19 | // $auth: { 20 | // get () { 21 | // const _this = this 22 | // return (permissions) => { 23 | // const [permission, action] = permissions.split('.') 24 | // const permissionList = _this.$store.getters.roles.permissions 25 | // return permissionList.find((val) => { 26 | // return val.permissionId === permission 27 | // }).actionList.findIndex((val) => { 28 | // return val === action 29 | // }) > -1 30 | // } 31 | // } 32 | // } 33 | // }) 34 | 35 | !Vue.prototype.$enum && Object.defineProperties(Vue.prototype, { 36 | $enum: { 37 | get () { 38 | // const _this = this; 39 | return (val) => { 40 | let result = PERMISSION_ENUM 41 | val && val.split('.').forEach(v => { 42 | result = result && result[v] || null 43 | }) 44 | return result 45 | } 46 | } 47 | } 48 | }) 49 | } 50 | 51 | export default plugin 52 | -------------------------------------------------------------------------------- /ui/src/utils/permissions.js: -------------------------------------------------------------------------------- 1 | export function actionToObject (json) { 2 | try { 3 | return JSON.parse(json) 4 | } catch (e) { 5 | console.log('err', e.message) 6 | } 7 | return [] 8 | } 9 | -------------------------------------------------------------------------------- /ui/src/utils/storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Set storage 3 | * 4 | * @param name 5 | * @param content 6 | * @param maxAge 7 | */ 8 | export const setStore = (name, content, maxAge = null) => { 9 | if (!global.window || !name) { 10 | return 11 | } 12 | 13 | if (typeof content !== 'string') { 14 | content = JSON.stringify(content) 15 | } 16 | 17 | const storage = global.window.localStorage 18 | 19 | storage.setItem(name, content) 20 | if (maxAge && !isNaN(parseInt(maxAge))) { 21 | const timeout = parseInt(new Date().getTime() / 1000) 22 | storage.setItem(`${name}_expire`, timeout + maxAge) 23 | } 24 | } 25 | 26 | /** 27 | * Get storage 28 | * 29 | * @param name 30 | * @returns {*} 31 | */ 32 | export const getStore = name => { 33 | if (!global.window || !name) { 34 | return 35 | } 36 | 37 | const content = window.localStorage.getItem(name) 38 | const _expire = window.localStorage.getItem(`${name}_expire`) 39 | 40 | if (_expire) { 41 | const now = parseInt(new Date().getTime() / 1000) 42 | if (now > _expire) { 43 | return 44 | } 45 | } 46 | 47 | try { 48 | return JSON.parse(content) 49 | } catch (e) { 50 | return content 51 | } 52 | } 53 | 54 | /** 55 | * Clear storage 56 | * 57 | * @param name 58 | */ 59 | export const clearStore = name => { 60 | if (!global.window || !name) { 61 | return 62 | } 63 | 64 | window.localStorage.removeItem(name) 65 | window.localStorage.removeItem(`${name}_expire`) 66 | } 67 | 68 | /** 69 | * Clear all storage 70 | */ 71 | export const clearAll = () => { 72 | if (!global.window || !name) { 73 | return 74 | } 75 | 76 | window.localStorage.clear() 77 | } 78 | -------------------------------------------------------------------------------- /ui/src/utils/util.js: -------------------------------------------------------------------------------- 1 | export function timeFix () { 2 | const time = new Date() 3 | const hour = time.getHours() 4 | return hour < 9 ? '早上好' : hour <= 11 ? '上午好' : hour <= 13 ? '中午好' : hour < 20 ? '下午好' : '晚上好' 5 | } 6 | 7 | export function welcome () { 8 | const arr = [`Let's automate some deployments!`, 9 | 'Have I missed an update?', 10 | 'Rest for a bit while I keep an eye for new Docker images', 11 | 'Can I watch more registries, please?'] 12 | const index = Math.floor(Math.random() * arr.length) 13 | return arr[index] 14 | } 15 | 16 | /** 17 | * window.resize 18 | */ 19 | export function triggerWindowResizeEvent () { 20 | const event = document.createEvent('HTMLEvents') 21 | event.initEvent('resize', true, true) 22 | event.eventType = 'message' 23 | window.dispatchEvent(event) 24 | } 25 | 26 | export function handleScrollHeader (callback) { 27 | let timer = 0 28 | 29 | let beforeScrollTop = window.pageYOffset 30 | callback = callback || function () {} 31 | window.addEventListener( 32 | 'scroll', 33 | event => { 34 | clearTimeout(timer) 35 | timer = setTimeout(() => { 36 | let direction = 'up' 37 | const afterScrollTop = window.pageYOffset 38 | const delta = afterScrollTop - beforeScrollTop 39 | if (delta === 0) { 40 | return false 41 | } 42 | direction = delta > 0 ? 'down' : 'up' 43 | callback(direction) 44 | beforeScrollTop = afterScrollTop 45 | }, 50) 46 | }, 47 | false 48 | ) 49 | } 50 | 51 | /** 52 | * Remove loading animate 53 | * @param id parent element id or class 54 | * @param timeout 55 | */ 56 | export function removeLoadingAnimate (id = '', timeout = 1500) { 57 | if (id === '') { 58 | return 59 | } 60 | setTimeout(() => { 61 | document.body.removeChild(document.getElementById(id)) 62 | }, timeout) 63 | } 64 | -------------------------------------------------------------------------------- /ui/src/utils/utils.less: -------------------------------------------------------------------------------- 1 | .textOverflow() { 2 | overflow: hidden; 3 | white-space: nowrap; 4 | text-overflow: ellipsis; 5 | word-break: break-all; 6 | } 7 | 8 | .textOverflowMulti(@line: 3, @bg: #fff) { 9 | position: relative; 10 | max-height: @line * 1.5em; 11 | margin-right: -1em; 12 | padding-right: 1em; 13 | overflow: hidden; 14 | line-height: 1.5em; 15 | text-align: justify; 16 | &::before { 17 | position: absolute; 18 | right: 14px; 19 | bottom: 0; 20 | padding: 0 1px; 21 | background: @bg; 22 | content: '...'; 23 | } 24 | &::after { 25 | position: absolute; 26 | right: 14px; 27 | width: 1em; 28 | height: 1em; 29 | margin-top: 0.2em; 30 | background: white; 31 | content: ''; 32 | } 33 | } 34 | 35 | // mixins for clearfix 36 | // ------------------------ 37 | .clearfix() { 38 | zoom: 1; 39 | &::before, 40 | &::after { 41 | display: table; 42 | content: ' '; 43 | } 44 | &::after { 45 | clear: both; 46 | height: 0; 47 | font-size: 0; 48 | visibility: hidden; 49 | } 50 | } -------------------------------------------------------------------------------- /ui/src/views/404.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 16 | -------------------------------------------------------------------------------- /ui/src/views/exception/403.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /ui/src/views/exception/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /ui/src/views/exception/500.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | 18 | -------------------------------------------------------------------------------- /ui/tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /ui/webstorm.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | 4 | function resolve (dir) { 5 | return path.join(__dirname, '.', dir) 6 | } 7 | 8 | module.exports = { 9 | context: path.resolve(__dirname, './'), 10 | resolve: { 11 | extensions: ['.js', '.vue', '.json'], 12 | alias: { 13 | '@': resolve('src') 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /util/codecs/codecs.go: -------------------------------------------------------------------------------- 1 | package codecs 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "encoding/json" 7 | "sync" 8 | ) 9 | 10 | var bufferPool = sync.Pool{New: allocBuffer} 11 | 12 | func allocBuffer() interface{} { 13 | return &bytes.Buffer{} 14 | } 15 | 16 | func getBuffer() *bytes.Buffer { 17 | return bufferPool.Get().(*bytes.Buffer) 18 | } 19 | 20 | func releaseBuffer(v *bytes.Buffer) { 21 | v.Reset() 22 | v.Grow(0) 23 | bufferPool.Put(v) 24 | } 25 | 26 | // Serializer - generic serializer interface 27 | type Serializer interface { 28 | Encode(source interface{}) ([]byte, error) 29 | Decode(data []byte, target interface{}) error 30 | } 31 | 32 | // DefaultSerializer - returns default serializer 33 | func DefaultSerializer() Serializer { 34 | return &JSONSerializer{} 35 | } 36 | 37 | // GobSerializer - gob based serializer 38 | type GobSerializer struct{} 39 | 40 | // Encode - encodes source into bytes using Gob encoder 41 | func (s *GobSerializer) Encode(source interface{}) ([]byte, error) { 42 | buf := getBuffer() 43 | defer releaseBuffer(buf) 44 | enc := gob.NewEncoder(buf) 45 | err := enc.Encode(source) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return buf.Bytes(), nil 50 | } 51 | 52 | // Decode - decodes given bytes into target struct 53 | func (s *GobSerializer) Decode(data []byte, target interface{}) error { 54 | buf := bytes.NewBuffer(data) 55 | dec := gob.NewDecoder(buf) 56 | return dec.Decode(target) 57 | } 58 | 59 | // JSONSerializer - JSON based serializer 60 | type JSONSerializer struct{} 61 | 62 | // Encode - encodes source into bytes using JSON encoder 63 | func (s *JSONSerializer) Encode(source interface{}) ([]byte, error) { 64 | buf := getBuffer() 65 | defer releaseBuffer(buf) 66 | enc := json.NewEncoder(buf) 67 | err := enc.Encode(source) 68 | if err != nil { 69 | return nil, err 70 | } 71 | return buf.Bytes(), nil 72 | } 73 | 74 | // Decode - decodes given bytes into target struct 75 | func (s *JSONSerializer) Decode(data []byte, target interface{}) error { 76 | buf := bytes.NewBuffer(data) 77 | dec := json.NewDecoder(buf) 78 | return dec.Decode(target) 79 | } 80 | 81 | // Type - shows serializer type 82 | func (s *JSONSerializer) Type() string { 83 | return "JSON" 84 | } 85 | -------------------------------------------------------------------------------- /util/image/validation.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | ) 7 | 8 | var validHex = regexp.MustCompile(`^([a-f0-9]{64})$`) 9 | 10 | // ValidateID checks whether an ID string is a valid image ID. 11 | func ValidateID(id string) error { 12 | if ok := validHex.MatchString(id); !ok { 13 | return fmt.Errorf("image ID '%s' is invalid ", id) 14 | } 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /util/policies/policies.go: -------------------------------------------------------------------------------- 1 | package policies 2 | 3 | import ( 4 | "github.com/keel-hq/keel/types" 5 | ) 6 | 7 | // GetTriggerPolicy - checks for trigger label, if not set - returns 8 | // default trigger type 9 | func GetTriggerPolicy(labels map[string]string, annotations map[string]string) types.TriggerType { 10 | 11 | triggerAnn, ok := annotations[types.KeelTriggerLabel] 12 | if ok { 13 | return types.ParseTrigger(triggerAnn) 14 | } 15 | 16 | // checking labels 17 | trigger, ok := labels[types.KeelTriggerLabel] 18 | if ok { 19 | return types.ParseTrigger(trigger) 20 | } 21 | 22 | return types.TriggerTypeDefault 23 | } 24 | -------------------------------------------------------------------------------- /util/stopper/stopper.go: -------------------------------------------------------------------------------- 1 | package stopper 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | ) 8 | 9 | // Stopper eases the graceful termination of a group of goroutines 10 | type Stopper struct { 11 | ctx context.Context 12 | wg sync.WaitGroup 13 | stop chan struct{} 14 | } 15 | 16 | // NewStopper initializes a new Stopper instance 17 | func NewStopper(ctx context.Context) *Stopper { 18 | return &Stopper{ctx: ctx} 19 | } 20 | 21 | // Begin indicates that a new goroutine has started. 22 | func (s *Stopper) Begin() { 23 | s.wg.Add(1) 24 | } 25 | 26 | // End indicates that a goroutine has stopped. 27 | func (s *Stopper) End() { 28 | s.wg.Done() 29 | } 30 | 31 | // Chan returns the channel on which goroutines could listen to determine if 32 | // they should stop. The channel is closed when Stop() is called. 33 | func (s *Stopper) Chan() chan struct{} { 34 | return s.stop 35 | } 36 | 37 | // Sleep puts the current goroutine on sleep during a duration d 38 | // Sleep could be interrupted in the case the goroutine should stop itself, 39 | // in which case Sleep returns false. 40 | func (s *Stopper) Sleep(d time.Duration) bool { 41 | select { 42 | case <-time.After(d): 43 | return true 44 | case <-s.ctx.Done(): 45 | return false 46 | } 47 | } 48 | 49 | // Stop asks every goroutine to end. 50 | func (s *Stopper) Stop() { 51 | close(s.stop) 52 | s.wg.Wait() 53 | } 54 | -------------------------------------------------------------------------------- /util/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "text/template" 7 | ) 8 | 9 | // basicFunctions are the set of initial 10 | // functions provided to every template. 11 | var basicFunctions = template.FuncMap{ 12 | "json": func(v interface{}) string { 13 | a, _ := json.Marshal(v) 14 | return string(a) 15 | }, 16 | "split": strings.Split, 17 | "join": strings.Join, 18 | "title": strings.Title, 19 | "lower": strings.ToLower, 20 | "upper": strings.ToUpper, 21 | "pad": padWithSpace, 22 | "truncate": truncateWithLength, 23 | } 24 | 25 | // Parse creates a new anonymous template with the basic functions 26 | // and parses the given format. 27 | func Parse(format string) (*template.Template, error) { 28 | return NewParse("", format) 29 | } 30 | 31 | // NewParse creates a new tagged template with the basic functions 32 | // and parses the given format. 33 | func NewParse(tag, format string) (*template.Template, error) { 34 | return template.New(tag).Funcs(basicFunctions).Parse(format) 35 | } 36 | 37 | // padWithSpace adds whitespace to the input if the input is non-empty 38 | func padWithSpace(source string, prefix, suffix int) string { 39 | if source == "" { 40 | return source 41 | } 42 | return strings.Repeat(" ", prefix) + source + strings.Repeat(" ", suffix) 43 | } 44 | 45 | // truncateWithLength truncates the source string up to the length provided by the input 46 | func truncateWithLength(source string, length int) string { 47 | if len(source) < length { 48 | return source 49 | } 50 | return source[:length] 51 | } 52 | -------------------------------------------------------------------------------- /util/timeutil/backoff.go: -------------------------------------------------------------------------------- 1 | package timeutil 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // ExpBackoff - exponential backoff helper func 8 | func ExpBackoff(prev, max time.Duration) time.Duration { 9 | if prev == 0 { 10 | return time.Second 11 | } 12 | if prev > max/2 { 13 | return max 14 | } 15 | return 2 * prev 16 | } 17 | -------------------------------------------------------------------------------- /util/timeutil/backoff_test.go: -------------------------------------------------------------------------------- 1 | package timeutil 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestExpBackoff(t *testing.T) { 9 | tests := []struct { 10 | prev time.Duration 11 | max time.Duration 12 | want time.Duration 13 | }{ 14 | { 15 | prev: time.Duration(0), 16 | max: time.Minute, 17 | want: time.Second, 18 | }, 19 | { 20 | prev: time.Second, 21 | max: time.Minute, 22 | want: 2 * time.Second, 23 | }, 24 | { 25 | prev: 16 * time.Second, 26 | max: time.Minute, 27 | want: 32 * time.Second, 28 | }, 29 | { 30 | prev: 32 * time.Second, 31 | max: time.Minute, 32 | want: time.Minute, 33 | }, 34 | { 35 | prev: time.Minute, 36 | max: time.Minute, 37 | want: time.Minute, 38 | }, 39 | { 40 | prev: 2 * time.Minute, 41 | max: time.Minute, 42 | want: time.Minute, 43 | }, 44 | } 45 | 46 | for i, tt := range tests { 47 | got := ExpBackoff(tt.prev, tt.max) 48 | if tt.want != got { 49 | t.Errorf("case %d: want=%v got=%v", i, tt.want, got) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /util/timeutil/now.go: -------------------------------------------------------------------------------- 1 | package timeutil 2 | 3 | import "time" 4 | 5 | //Now utility, to replace for testing 6 | var Now = time.Now 7 | -------------------------------------------------------------------------------- /values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | repository: keelhq/keel 3 | tag: 0.17.0-rc1 4 | pullPolicy: Always 5 | 6 | # Enable insecure registries 7 | insecureRegistry: false 8 | 9 | # Polling is enabled by default, 10 | # you can disable it setting value below to false 11 | polling: 12 | enabled: true 13 | 14 | # Helm provider support 15 | helmProvider: 16 | version: "v3" 17 | enabled: true 18 | 19 | basicauth: 20 | enabled: true 21 | user: "admin" 22 | password: "password" 23 | 24 | service: 25 | enabled: true 26 | type: NodePort 27 | externalPort: 9300 28 | clusterIP: "" 29 | -------------------------------------------------------------------------------- /version/keel.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "runtime" 5 | 6 | "github.com/keel-hq/keel/types" 7 | ) 8 | 9 | // Generic tool info 10 | const ( 11 | ProductName string = "keel" 12 | APIVersion = "1" 13 | ) 14 | 15 | // Revision that was compiled. This will be filled in by the compiler. 16 | var Revision string 17 | 18 | // BuildDate is when the binary was compiled. This will be filled in by the 19 | // compiler. 20 | var BuildDate string 21 | 22 | // Version number that is being run at the moment. Version should use semver. 23 | var Version string 24 | 25 | // Experimental is intended to be used to enable alpha features. 26 | var Experimental string 27 | 28 | // GetKeelVersion returns version info. 29 | func GetKeelVersion() types.VersionInfo { 30 | v := types.VersionInfo{ 31 | Name: ProductName, 32 | Revision: Revision, 33 | BuildDate: BuildDate, 34 | Version: Version, 35 | APIVersion: APIVersion, 36 | GoVersion: runtime.Version(), 37 | OS: runtime.GOOS, 38 | Arch: runtime.GOARCH, 39 | } 40 | 41 | return v 42 | } 43 | --------------------------------------------------------------------------------