Compare commits

...

67 Commits

Author SHA1 Message Date
benjamin3322 d6eb2583af
Update the deployment templates to comply with the slack bot changes (#766)
The latest slack bot changes introduce two environment variables
(bot token and app token). Take that changes into account for deployment
 manifests.

Co-authored-by: Benjamin Moermans <b.moermans@aegaeon-it.com>
2025-04-09 09:09:37 +02:00
Matthias Baur 3989c0d2e7
Decrease initialDelaySeconds of (liveness|readiness) Probe (#801) 2025-03-07 11:20:23 +01:00
Matthias Baur 8f3bad6bc6
Allow securityContext to be confiured (#795) 2025-02-18 18:22:22 +01:00
Karolis Rusenas 65260ae66f
Merge pull request #793 from zhaque44/fix-lib-cve-xcrypto
resolve: CVE-2024-45337
2025-02-07 19:31:33 +04:00
zhaque44 d6e2295443 fix CVE-2024-45337
Signed-off-by: zhaque44 <haque.zubair@gmail.com>
2025-02-07 09:22:39 -06:00
Afonso Garcia 3c8cb78603
Change README to reflect real default value of persistence.enabled (#791) 2025-01-07 08:37:50 +01:00
David 9a26fc5687
Update appVersion in chart to v0.20.0 (#788) 2024-12-22 20:45:55 +01:00
David 45df99b788
Bump packages versions in go.mod
* Bump versions in go.mod
* Use new version of docker/distribution under new path
* Fix failing TestOCIDigest due to upstream change
---------

Authored by: Jessica Ng <jessicawlng@google.com>
2024-12-10 16:38:04 +01:00
Imran Ismail 611ff29997
feat: Allow poll trigger to work with glob and regexp (#745) 2024-11-06 11:46:37 +01:00
David 11c1b68cfe
chore: remove refs to deprecated io/ioutil (#778)
Co-authored-by: guoguangwu <guoguangwu@magic-shield.com>
2024-11-06 10:46:52 +01:00
Chakrit Wichian 34566aa802
Fix github webhook payload detection logic (#728)
The correct value for "package_type" seems to be "CONTAINER" instead of
"docker"
2024-11-06 08:22:36 +01:00
David 10acf52919
New keel.sh/monitorContainers annotation to allow explicitly setting which containers should be monitored (#777)
* Added new keel.sh/monitorContainers annotation to allow providing an explicit regular expression that will filter which containers Keel will interact with. If left empy, it will preserve previous behaviour (all containers).
* Support for debug parameters: Context and MasterUrl
2024-11-05 19:20:03 +01:00
David 0473dc645a
Update GO to 1.22 and bump dependencies with vulnerabilities
Update GO to 1.22 and bump dependencies with vulnerabilities
2024-11-05 13:11:52 +01:00
deivid a2ececac2b Preserve keel chart name 2024-11-04 20:10:55 +01:00
David d9bed83ff6
Initial fixes
Minimal refactor and fixes:

* Add dockerignore file to re-use already built images
* Add Image Pull Secrets to Deployment Template (https://github.com/keel-hq/keel/pull/758)
* Update Go Version to 1.21 (https://github.com/keel-hq/keel/pull/751)
* Added Github Workflow to use the Github repository as a Helm Chart Repository through Github Pages
* Create debug container to allow GO remote debugging
* Add integration with Azure Pipelines
* Add compose files to setup local development containers
2024-11-04 20:02:58 +01:00
Karolis Rusenas 0cf6619970
Merge pull request #764 from benjamin3322/feature/slack_socketmode
Fix slack bot to use the new socket API.
2024-07-31 22:55:02 +01:00
Benjamin Moermans e44b27a563 Fix slack bot to use the new socket API.
The bot can now send interactive message for approval requests as well
as react to bot mention event.
The bot will try to update the interactive message when possible.
2024-07-31 21:22:05 +02:00
Karolis Rusenas a6281726ef
Merge pull request #755 from clem59170/feat/worload-identity-support-for-polling
Refactor GCR Credentials Handling to Support Workload Identity authentification
2024-07-07 19:40:26 +01:00
crafa 1ff565610e update index yaml with right url for tgz 2024-04-16 11:30:54 +02:00
crafa 0e16545055 Update Helm chart repository 2024-04-16 10:47:21 +02:00
crafa dcf23d5f9a Add Helm chart packages and index file 2024-04-16 10:44:44 +02:00
crafa 00cb03d2a9 update chart.yaml for fork 2024-04-16 10:41:38 +02:00
crafa dc7e392681 modifying chart for fork 2024-04-16 10:30:13 +02:00
crafa 980f5d4d2c Add Helm chart packages and index file 2024-04-16 10:27:07 +02:00
clem59170 3e261382bf
Update gcr.go
refactor indentation
2024-04-10 11:52:01 +02:00
crafa 0cad29f17b adding missing dependencies 2024-04-10 11:39:50 +02:00
crafa 0462d1607d Refactor GCR Credentials Handling to Support Workload Identity
This commit updates the GCR (Google Container Registry) credentials handling
in Keel's GCR extension to add support for Google Cloud's Workload Identity,
while maintaining compatibility with the existing authentication method via
the GOOGLE_APPLICATION_CREDENTIALS environment variable.

Changes include:
- Removed the `credentials` string field from the CredentialsHelper struct.
  Credentials are now determined dynamically based on the runtime environment.
- Added `readCredentialsFromFile()` and `getWorkloadIdentityTokenCredentials()`
  functions to abstract the credential reading and token obtaining processes.
- Updated `GetCredentials` method to try reading the GOOGLE_APPLICATION_CREDENTIALS
  file first, falling back to Workload Identity if necessary.
- Extended registry URL check in `GetCredentials` to support Google Container Registry (`gcr.io`) and
  Google Artifact Registry URLs (`pkg.dev`).
- Adding pubSub boolean check for activating pubSub.

These changes allow for the use of both JSON key files and Workload Identity
for GCP authentication when polling instead of using pubSub.
2024-04-10 11:15:02 +02:00
Karolis Rusenas 9f0a7160bb
Update Chart.yaml 2023-09-28 20:17:55 +01:00
Karolis Rusenas f4415cb5bb
Merge pull request #741 from bard/fix-update-github-webhook-handler
update github handler
2023-09-28 20:14:15 +01:00
Massimiliano Mirra 8834bb81eb fix: update test 2023-09-02 14:03:58 +01:00
Massimiliano Mirra 6c9f9a0093 fix: update test 2023-09-02 11:25:13 +01:00
Massimiliano Mirra 3b34dec0ce update github handler 2023-09-02 11:01:24 +01:00
Karolis Rusenas 3ecd57c5cb
Merge pull request #740 from keel-hq/bump_chart_0.19.1
bump chart to 0.19.1
2023-08-31 10:24:20 +01:00
Karolis Rusenas d0afbe482e
bump chart 2023-08-31 10:23:53 +01:00
Karolis Rusenas 220a46fda4
Merge pull request #738 from erickpeirson/master
Update Go to 1.20.5
2023-08-30 12:32:12 +01:00
Erick 49a7edfa76
Update Go to 1.20.5 2023-08-30 06:01:46 -04:00
Karolis Rusenas 1c6db11eb4
Merge pull request #736 from hotpheex/discord_notifications
Remove test logs from development
2023-08-11 17:03:02 +01:00
hotpheex a9c8ea8d06 Remove test logs 2023-08-10 16:45:07 -07:00
Karolis Rusenas da5cfa1003
Merge pull request #729 from blakebarnett/update_slack_bot_to_fix_approvals
Update to slack-go/slack v0.12.2
2023-08-09 10:25:52 +01:00
Karolis Rusenas bea3fb3511
Merge pull request #735 from hotpheex/discord_notifications
Discord notifications
2023-08-09 10:23:56 +01:00
Karolis Rusenas 6defcfcad8
Merge pull request #734 from joebowbeer/init-containers
feat: initContainers
2023-08-09 10:13:27 +01:00
Joe Bowbeer ee938a7b82 add unit tests 2023-08-09 03:46:49 +00:00
hotpheex 23bd0a1084 Update helm chart 2023-08-08 19:33:18 -07:00
hotpheex 52af7e9650 New notification sender for Discord webhooks 2023-08-08 19:31:15 -07:00
hotpheex 21c273a77b Add discord webhook sender 2023-08-06 08:38:02 +10:00
Joe Bowbeer 7ff089371b feat: initContainers 2023-08-01 05:13:33 -07:00
Blake Barnett e57f78d0dc Update to slack-go/slack v0.12.2
- Uses events rather than a select loop
- Removes the HTTP API usage
- Makes approvals work
- Still requires a legacy bot token
2023-06-27 16:25:32 -07:00
Karolis Rusenas f8dabdc080
Merge pull request #722 from keel-hq/feature/chart_update
bump release to 0.18.1
2023-04-21 11:52:21 +01:00
Karolis Rusenas 665090b15c
bump release 2023-04-21 11:40:49 +01:00
Karolis Rusenas a9c1a4c028
Merge pull request #721 from sjdaws/use-HEAD
Prefer HEAD over GET for manifest digest
2023-04-21 11:35:34 +01:00
Scott Dawson 5cd202fcef
Perform HEAD request, fall back to GET 2023-04-21 08:32:29 +10:00
Karolis Rusenas 70c1b892ae
Merge pull request #718 from sjdaws/test-additional-headers
Fix non-multiarch OCI images, reduce GET calls
2023-04-15 18:28:02 +01:00
Scott Dawson f966c0f245
Add manifest header for non-multiarch images, condense headers into single call 2023-04-15 14:31:48 +10:00
Karolis Rusenas 675529874c
Merge pull request #691 from PortableProgrammer/PortableProgrammer-595
DefaultUpdateTimeout is in `ns`, should be `s`
2023-04-11 23:09:35 +01:00
PortableProgrammer fb2bb892ea #595 - Import `time`
Hasty commit, forgot to import `time`
2023-04-11 16:00:43 -06:00
Nick Warner 5703f0444b
#595 - Use time.Duration instead of const ns
Rather than hard-code `300000000000`, use `5 * time.Minute` for readability.
2023-04-11 15:51:49 -06:00
Karolis Rusenas dd2d232edb
Merge pull request #709 from babs/allow_define_pollDefaultSchedule
Allow change of pollDefaultSchedule via POLL_DEFAULTSCHEDULE env var
2023-04-11 20:15:33 +01:00
Karolis Rusenas da098d8402
Merge pull request #717 from sjdaws/fix-oci-images
Add support for OCI images
2023-04-11 20:09:45 +01:00
Scott Dawson 1e76c363d1
Fix tag pagination 2023-04-11 16:33:34 +10:00
Scott Dawson ed45f87f35
Fix tag pagination 2023-04-11 16:32:48 +10:00
Scott Dawson bc5a4cbf0c
Fix grammar 2023-04-11 15:54:01 +10:00
Scott Dawson 2315aced1b
Add support for OCI images 2023-04-11 15:53:31 +10:00
Scott Dawson a58d5a638d
Update all depedencies, shift to kube v1.26.3 2023-04-11 11:51:15 +10:00
Damien Degois 2db63a3437 Allow change of pollDefaultSchedule via POLL_DEFAULTSCHEDULE env var
Also available on helm via polling.defaultSchedule
2023-03-07 03:30:27 +01:00
Karolis Rusenas 5bd875a9a1
chart version 2023-03-05 20:41:37 +00:00
Nick Warner e8b5953d82
Trigger CircleCI 2022-11-07 14:40:28 -07:00
Nick Warner 7d74d0a10e
DefaultUpdateTimeout is in `ns`, should be `s`
Per #595, the default timeout appears to be intended as `300s`, but the conversion to `time.Duration` reads it as `300ns`, causing helm3 chart updates to fail.
2022-10-24 13:13:10 -06:00
81 changed files with 3151 additions and 1525 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
**/.git
envsettings.ps1
envsettings.ps1.template
helpers.ps1
LICENSE
compose*
build.ps1
azure-pipelines.yml
.gitignore
.drone.yml
readme.md
serviceaccount/*
chart/*
Dockerfile*

View File

@ -7,17 +7,17 @@ workspace:
steps:
- name: unit-test
image: golang:1.20.1
image: golang:1.23.4
commands:
- make test
- name: build
image: golang:1.20.1
image: golang:1.23.4
commands:
- make install
- name: build-ui
image: node:9.11.1-alpine
image: node:16.20.2-alpine
commands:
- cd ui
- yarn

37
.github/workflows/releasecharts.yaml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Release Charts
on:
push:
tags:
- "chart-*"
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4.2.0
- name: Extract tag version
id: get_version
run: echo "version=${GITHUB_REF##*/chart-}" >> $GITHUB_ENV
- name: Update Chart.yaml version
run: |
sed -i "s/^version:.*/version: ${GITHUB_ENV_VERSION}/" chart/keel/Chart.yaml
env:
GITHUB_ENV_VERSION: ${{ env.version }}
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: chart
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

6
.gitignore vendored
View File

@ -8,4 +8,8 @@ hack/deployment-rbac.yaml
hack/deployment-norbac-helm.yaml
.vscode
.idea/
tests.out
tests.out
envsettings.ps1
serviceaccount/*
cmd/keel/keel.exe
test_results.xml

View File

@ -1,16 +1,16 @@
FROM golang:1.20.1
FROM golang:1.23.4
COPY . /go/src/github.com/keel-hq/keel
WORKDIR /go/src/github.com/keel-hq/keel
RUN make install
FROM node:9.11.1-alpine
FROM node:16.20.2-alpine
WORKDIR /app
COPY ui /app
RUN yarn
RUN yarn run lint --no-fix
RUN yarn run build
FROM alpine:latest
FROM alpine:3.20.3
RUN apk --no-cache add ca-certificates
VOLUME /data

View File

@ -1,4 +1,4 @@
FROM golang:1.14.2
FROM golang:1.23.4
COPY . /go/src/github.com/keel-hq/keel
WORKDIR /go/src/github.com/keel-hq/keel
RUN make build

29
Dockerfile.debug Normal file
View File

@ -0,0 +1,29 @@
FROM golang:1.23.4
COPY . /go/src/github.com/keel-hq/keel
WORKDIR /go/src/github.com/keel-hq/keel
RUN make install-debug
FROM node:16.20.2-alpine
WORKDIR /app
COPY ui /app
RUN yarn
RUN yarn run lint --no-fix
RUN yarn run build
FROM golang:1.22.8
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
VOLUME /data
ENV XDG_DATA_HOME /data
COPY --from=0 /go/bin/keel /bin/keel
COPY --from=1 /app/dist /www
COPY --from=0 /go/bin/dlv /
#ENTRYPOINT ["/bin/keel"]
ENTRYPOINT ["/dlv", "--listen=:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/bin/keel"]
EXPOSE 9300
EXPOSE 40000

11
Dockerfile.tests Normal file
View File

@ -0,0 +1,11 @@
FROM golang:1.23.4
# Install tparse and go-junit-report
RUN go install github.com/mfridman/tparse@latest && \
go install github.com/jstemmer/go-junit-report@latest
COPY . /go/src/github.com/keel-hq/keel
WORKDIR /go/src/github.com/keel-hq/keel
ENTRYPOINT ["tail", "-f", "/dev/null"]

View File

@ -64,6 +64,11 @@ install:
# CGO_ENABLED=0 GOOS=linux go install -ldflags "$(LDFLAGS)" github.com/keel-hq/keel/cmd/keel
GOOS=linux go install -ldflags "$(LDFLAGS)" github.com/keel-hq/keel/cmd/keel
install-debug:
@echo "++ Installing keel with debug flags"
go install github.com/go-delve/delve/cmd/dlv@latest
GOOS=linux go install -gcflags "all=-N -l" -ldflags "$(LDFLAGS)" github.com/keel-hq/keel/cmd/keel
image:
docker build -t keelhq/keel:alpha -f Dockerfile .

View File

@ -2,7 +2,6 @@ package approvals
import (
"context"
"io/ioutil"
"log"
"os"
"path/filepath"
@ -16,7 +15,7 @@ import (
)
func NewTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

71
azure-pipelines.yml Normal file
View File

@ -0,0 +1,71 @@
trigger:
branches:
include:
- '*'
tags:
include:
- '*'
pr:
branches:
exclude:
- '*'
resources:
- repo: self
variables:
tag: '$(Build.BuildId)'
vmImage: 'ubuntu-latest'
stages:
- stage: Build
displayName: Build
jobs:
- job: Build
timeoutInMinutes: 120
displayName: Build
pool:
vmImage: $(vmImageName)
name: Test-Rafael
steps:
- pwsh: .\build.ps1
name: build_images
displayName: 'Build images'
env:
REGISTRY_USER: $(REGISTRY_USER)
REGISTRY_PWD: $(REGISTRY_PWD)
REGISTRY_PATH: $(REGISTRY_PATH)
IMAGE_VERSION: $(Build.SourceBranchName)
- pwsh: .\build.ps1 -RunTests
name: run_tests
displayName: 'Run tests'
env:
REGISTRY_USER: $(REGISTRY_USER)
REGISTRY_PWD: $(REGISTRY_PWD)
REGISTRY_PATH: $(REGISTRY_PATH)
IMAGE_VERSION: $(Build.SourceBranchName)
TESTDIR: '$(System.DefaultWorkingDirectory)/Tests'
- task: PublishTestResults@2
name: publish_tests_results
displayName: 'Publish Test Results'
condition: and(not(canceled()), not(contains(variables['Build.SourceVersionMessage'], '[notest]')))
inputs:
testResultsFormat: 'JUnit' # 'JUnit' | 'NUnit' | 'VSTest' | 'XUnit' | 'CTest'. Alias: testRunner. Required. Test result format. Default: JUnit.
testResultsFiles: '**/*.xml' # string. Required. Test results files. Default: **/TEST-*.xml.
searchFolder: '$(System.DefaultWorkingDirectory)/Tests' # string. Search folder. Default: $(System.DefaultWorkingDirectory).
# mergeTestResults: true
failTaskOnFailedTests: true
failTaskOnFailureToPublishResults: true
failTaskOnMissingResultsFile: true
testRunTitle: Pester
# Advanced
#buildPlatform: # string. Alias: platform. Build Platform.
#buildConfiguration: # string. Alias: configuration. Build Configuration.
publishRunAttachments: true
- pwsh: .\build.ps1 -Push
name: push_containers
displayName: 'Push images'
# Only push if this is a tag, and the tests passed
condition: or(and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/')), contains(variables['Build.SourceVersionMessage'], '[push]'))
env:
REGISTRY_USER: $(REGISTRY_USER)
REGISTRY_PWD: $(REGISTRY_PWD)
REGISTRY_PATH: $(REGISTRY_PATH)
IMAGE_VERSION: $(Build.SourceBranchName)

View File

@ -1,7 +1,6 @@
package hipchat
import (
"io/ioutil"
"os"
"path/filepath"
"regexp"
@ -108,7 +107,7 @@ func init() {
}
func newTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

View File

@ -2,134 +2,175 @@ package slack
import (
"fmt"
"github.com/keel-hq/keel/bot"
log "github.com/sirupsen/logrus"
"strings"
"unicode"
"github.com/keel-hq/keel/types"
"github.com/nlopes/slack"
"github.com/slack-go/slack"
)
// Request - request approval
func (b *Bot) RequestApproval(req *types.Approval) error {
return b.postMessage(
"Approval required",
req.Message,
types.LevelSuccess.Color(),
[]slack.AttachmentField{
{
Title: "Approval required!",
Value: req.Message + "\n" + fmt.Sprintf("To vote for change type '%s approve %s' to reject it: '%s reject %s'.", b.name, req.Identifier, b.name, req.Identifier),
Short: false,
},
{
Title: "Votes",
Value: fmt.Sprintf("%d/%d", req.VotesReceived, req.VotesRequired),
Short: true,
},
{
Title: "Delta",
Value: req.Delta(),
Short: true,
},
{
Title: "Identifier",
Value: req.Identifier,
Short: true,
},
{
Title: "Provider",
Value: req.Provider.String(),
Short: true,
},
})
return b.postApprovalMessageBlock(
req.ID,
createBlockMessage("Approval required! :mega:", b.name, req),
)
}
func (b *Bot) ReplyToApproval(approval *types.Approval) error {
var title string
switch approval.Status() {
case types.ApprovalStatusPending:
b.postMessage(
"Vote received",
"All approvals received, thanks for voting!",
types.LevelInfo.Color(),
[]slack.AttachmentField{
{
Title: "vote received!",
Value: "Waiting for remaining votes.",
Short: false,
},
{
Title: "Votes",
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
Short: true,
},
{
Title: "Delta",
Value: approval.Delta(),
Short: true,
},
{
Title: "Identifier",
Value: approval.Identifier,
Short: true,
},
})
title = "Approval required! :mega:"
case types.ApprovalStatusRejected:
b.postMessage(
"Change rejected",
"Change was rejected",
types.LevelWarn.Color(),
[]slack.AttachmentField{
{
Title: "change rejected",
Value: "Change was rejected.",
Short: false,
},
{
Title: "Status",
Value: approval.Status().String(),
Short: true,
},
{
Title: "Votes",
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
Short: true,
},
{
Title: "Delta",
Value: approval.Delta(),
Short: true,
},
{
Title: "Identifier",
Value: approval.Identifier,
Short: true,
},
})
title = "Change rejected! :negative_squared_cross_mark:"
case types.ApprovalStatusApproved:
b.postMessage(
"approval received",
"All approvals received, thanks for voting!",
types.LevelSuccess.Color(),
[]slack.AttachmentField{
{
Title: "update approved!",
Value: "All approvals received, thanks for voting!",
Short: false,
},
{
Title: "Votes",
Value: fmt.Sprintf("%d/%d", approval.VotesReceived, approval.VotesRequired),
Short: true,
},
{
Title: "Delta",
Value: approval.Delta(),
Short: true,
},
{
Title: "Identifier",
Value: approval.Identifier,
Short: true,
},
})
title = "Change approved! :tada:"
}
b.upsertApprovalMessage(approval.ID, createBlockMessage(title, b.name, approval))
return nil
}
func createBlockMessage(title string, botName string, req *types.Approval) slack.Blocks {
if req.Expired() {
title = title + " (Expired)"
}
headerText := slack.NewTextBlockObject(
"plain_text",
title,
true,
false,
)
headerSection := slack.NewHeaderBlock(headerText)
messageSection := slack.NewTextBlockObject(
"mrkdwn",
req.Message,
false,
false,
)
messageBlock := slack.NewSectionBlock(messageSection, nil, nil)
votesField := slack.NewTextBlockObject(
"mrkdwn",
fmt.Sprintf("*Votes:*\n%d/%d", req.VotesReceived, req.VotesRequired),
false,
false,
)
deltaField := slack.NewTextBlockObject(
"mrkdwn",
"*Delta:*\n"+req.Delta(),
false,
false,
)
leftDetailSection := slack.NewSectionBlock(
nil,
[]*slack.TextBlockObject{
votesField,
deltaField,
},
nil,
)
identifierField := slack.NewTextBlockObject(
"mrkdwn",
"*Identifier:*\n"+req.Identifier,
false,
false,
)
providerField := slack.NewTextBlockObject(
"mrkdwn",
"*Provider:*\n"+req.Provider.String(),
false,
false,
)
rightDetailSection := slack.NewSectionBlock(nil, []*slack.TextBlockObject{identifierField, providerField}, nil)
commands := bot.BotEventTextToResponse["help"]
var commandTexts []slack.MixedElement
for i, cmd := range commands {
// -- avoid adding first line in commands which is the title.
if i == 0 {
continue
}
cmd = addBotMentionToCommand(cmd, botName)
commandTexts = append(commandTexts, slack.NewTextBlockObject("mrkdwn", cmd, false, false))
}
commandsBlock := slack.NewContextBlock("", commandTexts...)
header := commands[0]
blocks := []slack.Block{
headerSection,
messageBlock,
leftDetailSection,
rightDetailSection,
slack.NewDividerBlock(),
slack.NewContextBlock("", slack.NewTextBlockObject("mrkdwn", header, false, false)),
commandsBlock,
}
if req.VotesReceived < req.VotesRequired && !req.Expired() && !req.Rejected {
approveButton := slack.NewButtonBlockElement(
bot.ApprovalResponseKeyword,
req.Identifier,
slack.NewTextBlockObject(
"plain_text",
"Approve",
true,
false,
),
)
approveButton.Style = slack.StylePrimary
rejectButton := slack.NewButtonBlockElement(
bot.RejectResponseKeyword,
req.Identifier,
slack.NewTextBlockObject(
"plain_text",
"Reject",
true,
false,
),
)
rejectButton.Style = slack.StyleDanger
actionBlock := slack.NewActionBlock("", approveButton, rejectButton)
blocks = append(
blocks,
slack.NewDividerBlock(),
actionBlock,
)
}
return slack.Blocks{
BlockSet: blocks,
}
}
func addBotMentionToCommand(command string, botName string) string {
// -- retrieve the first letter of the command in order to insert bot mention
firstLetterPos := -1
for i, r := range command {
if unicode.IsLetter(r) {
firstLetterPos = i
break
}
}
if firstLetterPos < 0 {
log.Debugf("Unable to find the first letter of the command '%s', let the command without the bot mention.", command)
return command
}
return strings.Replace(
command[:firstLetterPos]+fmt.Sprintf("@%s ", botName)+command[firstLetterPos:],
"\"",
"`",
-1,
)
}

View File

@ -2,29 +2,20 @@ package slack
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/keel-hq/keel/bot"
"github.com/keel-hq/keel/constants"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
"os"
"strconv"
"strings"
"time"
"github.com/nlopes/slack"
"github.com/keel-hq/keel/bot"
"github.com/keel-hq/keel/constants"
"github.com/keel-hq/keel/version"
log "github.com/sirupsen/logrus"
)
// SlackImplementer - implementes slack HTTP functionality, used to
// send messages with attachments
type SlackImplementer interface {
PostMessage(channelID string, options ...slack.MsgOption) (string, string, error)
}
// Bot - main slack bot container
type Bot struct {
id string // bot id
@ -34,12 +25,13 @@ type Bot struct {
msgPrefix string
slackClient *slack.Client
slackRTM *slack.RTM
slackSocket *socketmode.Client
slackHTTPClient SlackImplementer
// the approval channel-name, provided by the bot configuration
approvalsChannel string
approvalsChannel string // slack approvals channel name
// the identifier of the approval channel, this is retrieved when the bot is starting
approvalChannelId string
ctx context.Context
botMessagesChannel chan *bot.BotMessage
@ -51,30 +43,51 @@ func init() {
}
func (b *Bot) Configure(approvalsRespCh chan *bot.ApprovalResponse, botMessagesChannel chan *bot.BotMessage) bool {
if os.Getenv(constants.EnvSlackToken) != "" {
botToken := os.Getenv(constants.EnvSlackBotToken)
b.name = "keel"
if bootName := os.Getenv(constants.EnvSlackBotName); bootName != "" {
b.name = bootName
}
token := os.Getenv(constants.EnvSlackToken)
client := slack.New(token)
b.approvalsChannel = "general"
if channel := os.Getenv(constants.EnvSlackApprovalsChannel); channel != "" {
b.approvalsChannel = strings.TrimPrefix(channel, "#")
}
b.slackClient = client
b.slackHTTPClient = client
b.approvalsRespCh = approvalsRespCh
b.botMessagesChannel = botMessagesChannel
return true
if !strings.HasPrefix(botToken, "xoxb-") {
log.Infof("bot.slack.Configure(): %s must have the prefix \"xoxb-\", skip bot configuration.", constants.EnvSlackBotToken)
return false
}
log.Info("bot.slack.Configure(): Slack approval bot is not configured")
return false
appToken := os.Getenv(constants.EnvSlackAppToken)
if !strings.HasPrefix(appToken, "xapp-") {
log.Infof("bot.slack.Configure(): %s must have the previf \"xapp-\".", constants.EnvSlackAppToken)
return false
}
botName, botNameConfigured := os.LookupEnv(constants.EnvSlackBotName)
if !botNameConfigured {
botName = "keel"
}
b.name = botName
channel, channelConfigured := os.LookupEnv(constants.EnvSlackApprovalsChannel)
if !channelConfigured {
channel = "general"
}
b.approvalsChannel = strings.TrimPrefix(channel, "#")
log.Debugf("Configuring slack with approval channel '%s' and bot '%s'", b.approvalsChannel, b.name)
debug, _ := strconv.ParseBool(os.Getenv("DEBUG"))
api := slack.New(
botToken,
slack.OptionDebug(debug),
slack.OptionAppLevelToken(appToken),
)
client := socketmode.New(
api,
socketmode.OptionDebug(debug),
)
b.slackSocket = client
b.approvalsRespCh = approvalsRespCh
b.botMessagesChannel = botMessagesChannel
return true
}
// Start - start bot
@ -82,158 +95,179 @@ func (b *Bot) Start(ctx context.Context) error {
// setting root context
b.ctx = ctx
users, err := b.slackClient.GetUsers()
users, err := b.slackSocket.GetUsers()
if err != nil {
return err
}
b.users = map[string]string{}
// -- retrieve the bot user identifier from the bot name
var foundBots []string
for _, user := range users {
switch user.Name {
case b.name:
if user.IsBot {
if user.IsBot {
foundBots = append(foundBots, user.Name)
if user.Name == b.name {
b.id = user.ID
break
}
default:
continue
}
}
if b.id == "" {
return errors.New("could not find bot in the list of names, check if the bot is called \"" + b.name + "\" ")
return errors.New("could not find bot in the list of names, check if the bot is called \"" + b.name + "\", found bots: " + strings.Join(foundBots[:], ", "))
}
// -- mentions and direct messages start with this message prefix. It is used from trimming the messages
b.msgPrefix = strings.ToLower("<@" + b.id + ">")
go b.startInternal()
// -- retrieve the channel identifier from the approval channel name
b.approvalChannelId, err = b.findChannelId(b.approvalsChannel)
if err != nil {
return err
}
go b.listenForSocketEvents()
return nil
}
func (b *Bot) startInternal() error {
b.slackRTM = b.slackClient.NewRTM()
func (b *Bot) findChannelId(channelName string) (string, error) {
var channelId string
var cursor string
go b.slackRTM.ManageConnection()
for {
select {
case <-b.ctx.Done():
return nil
// -- while the channel is not found, fetch pages
for channelId == "" {
channels, nextCursor, err := b.slackSocket.GetConversationsForUser(&slack.GetConversationsForUserParameters{ExcludeArchived: true, Cursor: cursor})
if err != nil {
return "", err
}
case msg := <-b.slackRTM.IncomingEvents:
switch ev := msg.Data.(type) {
case *slack.HelloEvent:
// Ignore hello
case *slack.ConnectedEvent:
// nothing to do
case *slack.MessageEvent:
b.handleMessage(ev)
case *slack.PresenceChangeEvent:
// nothing to do
case *slack.RTMError:
log.Errorf("Error: %s", ev.Error())
case *slack.InvalidAuthEvent:
log.Error("Invalid credentials")
return fmt.Errorf("invalid credentials")
default:
// Ignore other events..
// fmt.Printf("Unexpected: %v\n", msg.Data)
for _, channel := range channels {
if channel.Name == channelName {
channelId = channel.ID
break
}
}
}
}
func (b *Bot) postMessage(title, message, color string, fields []slack.AttachmentField) error {
params := slack.NewPostMessageParameters()
params.Username = b.name
params.IconURL = b.getBotUserIconURL()
attachements := []slack.Attachment{
{
Fallback: message,
Color: color,
Fields: fields,
Footer: fmt.Sprintf("https://keel.sh %s", version.GetKeelVersion().Version),
Ts: json.Number(strconv.Itoa(int(time.Now().Unix()))),
},
}
var mgsOpts []slack.MsgOption
mgsOpts = append(mgsOpts, slack.MsgOptionPostMessageParameters(params))
mgsOpts = append(mgsOpts, slack.MsgOptionAttachments(attachements...))
_, _, err := b.slackHTTPClient.PostMessage(b.approvalsChannel, mgsOpts...)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"approvals_channel": b.approvalsChannel,
}).Error("bot.postMessage: failed to send message")
}
return err
}
// checking if message was received in approvals channel
func (b *Bot) isApprovalsChannel(event *slack.MessageEvent) bool {
channel, err := b.slackClient.GetChannelInfo(event.Channel)
if err != nil {
// looking for private channel
conv, err := b.slackRTM.GetConversationInfo(event.Channel, true)
if err != nil {
log.Errorf("couldn't find amongst private conversations: %s", err)
} else if conv.Name == b.approvalsChannel {
return true
// -- channel not found on this page, check if there are more pages
if nextCursor == "" {
break
}
log.WithError(err).Errorf("channel with ID %s could not be retrieved", event.Channel)
return false
// -- continue to the next page
cursor = nextCursor
}
log.Debugf("checking if approvals channel: %s==%s", channel.Name, b.approvalsChannel)
if channel.Name == b.approvalsChannel {
return true
if channelId == "" {
return "", errors.New("Unable to retrieve the channel named \"" + channelName + "\". Check that the bot is invited to that channel and define the proper scope in the Slack app settings.")
} else {
return channelId, nil
}
log.Debugf("message was received not on approvals channel (%s)", channel.Name)
return false
}
func (b *Bot) handleMessage(event *slack.MessageEvent) {
if event.BotID != "" || event.User == "" || event.SubType == "bot_message" {
func (b *Bot) listenForSocketEvents() error {
go func() {
for evt := range b.slackSocket.Events {
switch evt.Type {
case socketmode.EventTypeConnecting:
log.Info("Connecting to Slack with Socket Mode...")
case socketmode.EventTypeConnectionError:
if "missing_scope" == evt.Data {
log.Error("The application token is missing scopes, verify to provide an application token with the scope 'connections:write'", evt.Data)
} else {
log.Error("Connection failed. Retrying later... ", evt.Data)
}
case socketmode.EventTypeConnected:
log.Info("Connected to Slack with Socket Mode.")
case socketmode.EventTypeInvalidAuth:
log.Error("Invalid authentication parameter provided.", evt.Data)
case socketmode.EventTypeDisconnect:
log.Info("Disconnected from Slack socket.")
case socketmode.EventTypeIncomingError:
log.Error("An error occurred while processing an incoming event.", evt.Data)
case socketmode.EventTypeErrorBadMessage:
log.Error("Bad message error.", evt.Data)
case socketmode.EventTypeErrorWriteFailed:
log.Error("Error while responding to a message.", evt.Data)
case socketmode.EventTypeSlashCommand:
// ignore slash commands
case socketmode.EventTypeEventsAPI:
// The bot can receive mention events only when the bot has the Event Subscriptions enabled
// AND has a subscription to "app_mention" events
eventsAPIEvent, isEventApiEvent := evt.Data.(slackevents.EventsAPIEvent)
if !isEventApiEvent {
continue
}
innerEvent := eventsAPIEvent.InnerEvent
mentionEvent, isAppMentionEvent := innerEvent.Data.(*slackevents.AppMentionEvent)
if isAppMentionEvent && eventsAPIEvent.Type == slackevents.CallbackEvent {
// -- the bot was mentioned in a message, try to process the command
b.handleMentionEvent(mentionEvent)
b.slackSocket.Ack(*evt.Request)
}
case socketmode.EventTypeInteractive:
callback, isInteractionCallback := evt.Data.(slack.InteractionCallback)
if !isInteractionCallback {
log.Debugf("Ignoring Event %+v\n", evt)
continue
}
if callback.Type == slack.InteractionTypeBlockActions {
if (len(callback.ActionCallback.BlockActions)) == 0 {
log.Error("No block actions found")
continue
}
// callback.ResponseURL
blockAction := callback.ActionCallback.BlockActions[0]
b.handleAction(callback.User.ID, blockAction)
b.slackSocket.Ack(*evt.Request)
}
}
}
}()
b.slackSocket.Run()
return fmt.Errorf("No more events?")
}
// handleMentionEvent - Handle a mention event. The bot will only receive its own mention event. No need to check that the message is for him.
func (b *Bot) handleMentionEvent(event *slackevents.AppMentionEvent) {
if event.BotID != "" || event.User == "" {
log.WithFields(log.Fields{
"event_bot_ID": event.BotID,
"event_user": event.User,
"msg": event.Text,
"event_subtype": event.SubType,
"event_bot_ID": event.BotID,
"event_user": event.User,
"msg": event.Text,
}).Debug("handleMessage: ignoring message")
return
}
// -- clean the text message to have only the action (approve or reject) followed by the resource identifier
// -- (e.g. approve k8s/project/repo:1.2.3)
eventText := strings.Trim(strings.ToLower(event.Text), " \n\r")
if !b.isBotMessage(event, eventText) {
return
}
eventText = b.trimBotName(eventText)
eventText = b.trimBot(eventText)
// -- first, try to handle the message as an approval response
approval, isAnApprovalResponse := bot.IsApproval(event.User, eventText)
approval, ok := bot.IsApproval(event.User, eventText)
// only accepting approvals from approvals channel
if ok && b.isApprovalsChannel(event) {
if isAnApprovalResponse && b.isEventFromApprovalsChannel(event) {
// -- the message is processed by bot\approvals.go in ProcessApprovalResponses
b.approvalsRespCh <- approval
return
} else if ok {
} else if isAnApprovalResponse {
log.WithFields(log.Fields{
"received_on": event.Channel,
"approvals_chan": b.approvalsChannel,
}).Warnf("message was received not in approvals channel: %s", event.Channel)
b.Respond(fmt.Sprintf("please use approvals channel '%s'", b.approvalsChannel), event.Channel)
}).Warnf("The message was not received in the approval channel: %s", event.Channel)
b.Respond(fmt.Sprintf("Please use approvals channel '%s'", b.approvalsChannel), event.Channel)
return
}
// -- the message is not an approval response, try to handle the message as a generic bot command
b.botMessagesChannel <- &bot.BotMessage{
Message: eventText,
User: event.User,
@ -242,49 +276,7 @@ func (b *Bot) handleMessage(event *slack.MessageEvent) {
}
}
func (b *Bot) Respond(text string, channel string) {
// if message is short, replying directly via slack RTM
if len(text) < 3000 {
b.slackRTM.SendMessage(b.slackRTM.NewOutgoingMessage(formatAsSnippet(text), channel))
return
}
// longer messages are getting uploaded as files
f := slack.FileUploadParameters{
Filename: "keel response",
Content: text,
Filetype: "text",
Channels: []string{channel},
}
_, err := b.slackClient.UploadFile(f)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Error("Respond: failed to send message")
}
}
func (b *Bot) isBotMessage(event *slack.MessageEvent, eventText string) bool {
prefixes := []string{
b.msgPrefix,
b.name,
// "kel",
}
for _, p := range prefixes {
if strings.HasPrefix(eventText, p) {
return true
}
}
// Direct message channels always starts with 'D'
return strings.HasPrefix(event.Channel, "D")
}
func (b *Bot) trimBot(msg string) string {
func (b *Bot) trimBotName(msg string) string {
msg = strings.Replace(msg, strings.ToLower(b.msgPrefix), "", 1)
msg = strings.TrimPrefix(msg, b.name)
msg = strings.Trim(msg, " :\n")
@ -292,18 +284,146 @@ func (b *Bot) trimBot(msg string) string {
return msg
}
// isEventFromApprovalsChannel - checking if message was received in approvals channel
func (b *Bot) isEventFromApprovalsChannel(event *slackevents.AppMentionEvent) bool {
if b.approvalChannelId == event.Channel {
return true
} else {
log.Debug("Message was not received on the approvals channel, ignoring")
return false
}
}
// handleAction - Handle an action performed by using the slack block action feature.
// The bot will only receive events coming from its own action blocks. Block action can only be used to approve
// or reject an approval request (other commands should be managed by user bot mentions).
func (b *Bot) handleAction(username string, blockAction *slack.BlockAction) {
eventText := fmt.Sprintf("%s %s", blockAction.ActionID, blockAction.Value)
approval, ok := bot.IsApproval(username, eventText)
if !ok {
// -- only react to approval requests (approve or reject actions)
log.WithFields(log.Fields{
"action_user": username,
"action_id": blockAction.ActionID,
"action_value": blockAction.Value,
}).Debug("handleAction: ignoring action, clicked on unknown button")
return
}
b.approvalsRespCh <- approval
}
// postApprovalMessageBlock - effectively post a message to the approval channel
func (b *Bot) postApprovalMessageBlock(approvalId string, blocks slack.Blocks) error {
channelID := b.approvalsChannel
_, _, err := b.slackSocket.PostMessage(
channelID,
slack.MsgOptionBlocks(blocks.BlockSet...),
createApprovalMetadata(approvalId),
)
return err
}
// Respond - This method sent the text message to the provided channel
func (b *Bot) Respond(text string, channel string) {
// if message is short, replying directly via socket
if len(text) < 3000 {
b.slackSocket.SendMessage(channel, slack.MsgOptionText(formatAsSnippet(text), true))
return
}
// longer messages are getting uploaded as files
f := slack.FileUploadParameters{
Filename: "keel response",
Content: text,
Filetype: "text",
Channels: []string{channel},
}
_, err := b.slackSocket.UploadFile(f)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Error("Respond: failed to send message")
}
}
// upsertApprovalMessage - update the approval message that was sent for the given resource identifier (deployment/default/wd:0.0.15).
// if the message is not found in the approval channel it will be created. That way even it the message is deleted,
// we will see the approval status
func (b *Bot) upsertApprovalMessage(approvalId string, blocks slack.Blocks) {
// Retrieve the message history
historyParams := &slack.GetConversationHistoryParameters{
ChannelID: b.approvalChannelId,
Limit: 250,
IncludeAllMetadata: true,
}
history, err := b.slackSocket.GetConversationHistory(historyParams)
if err != nil {
log.Debugf("Unable to get the conversation history to edit the message, post new one: %v", err)
b.postApprovalMessageBlock(approvalId, blocks)
}
// Find the message to update; the channel id and the message timestamp is the identifier of a message for slack
var messageTs string
for _, message := range history.Messages {
if isMessageOfApprovalRequest(message, approvalId) {
messageTs = message.Timestamp
break // Found the message
}
}
if messageTs == "" {
log.Debug("Unable to find the approval message for the identifier. Post a new message instead")
b.postApprovalMessageBlock(approvalId, blocks)
return
} else {
b.slackSocket.UpdateMessage(
b.approvalChannelId,
messageTs,
slack.MsgOptionBlocks(blocks.BlockSet...),
slack.MsgOptionAsUser(true),
createApprovalMetadata(approvalId),
)
}
}
// isMessageOfApprovalRequest - Check whether the given message is the approval message sent for the given approval identifier.
// Helps to identify the interactive message corresponding to the approval request in order to update the latest status of the approval.
// returns true if it is, false otherwise.
func isMessageOfApprovalRequest(message slack.Message, approvalId string) bool {
if message.Metadata.EventType != "approval" {
return false
}
if message.Metadata.EventPayload == nil {
return false
}
approvalID, ok := message.Metadata.EventPayload["approval_id"].(string)
if !ok {
return false
}
return approvalID == approvalId
}
// createApprovalMetadata - create message metadata, the metadata includes the approval identifier.
// That way, it is possible to identify clearly the approval message for a given approval request when looking into the
// history.
func createApprovalMetadata(approvalId string) slack.MsgOption {
return slack.MsgOptionMetadata(
slack.SlackMetadata{
EventType: "approval",
EventPayload: map[string]interface{}{
"approval_id": approvalId,
},
},
)
}
func formatAsSnippet(response string) string {
return "```" + response + "```"
}
func (b *Bot) getBotUserIconURL() string {
res, err := b.slackClient.GetUserInfo(b.id)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"bot_id": b.id,
}).Error("bot.postMessage: failed to retrieve bot user icon url")
}
return res.Profile.ImageOriginal
}

View File

@ -2,13 +2,12 @@ package slack
import (
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"time"
"github.com/nlopes/slack"
"github.com/slack-go/slack"
"github.com/keel-hq/keel/extension/approval"
"github.com/keel-hq/keel/pkg/store/sql"
@ -31,7 +30,7 @@ var approvalsRespCh chan *b.ApprovalResponse
func New(name, token, channel string,
k8sImplementer kubernetes.Implementer,
approvalsManager approvals.Manager, fi SlackImplementer) *Bot {
approvalsManager approvals.Manager) *Bot {
approvalsRespCh = make(chan *b.ApprovalResponse)
botMessagesChannel = make(chan *b.BotMessage)
@ -39,7 +38,6 @@ func New(name, token, channel string,
slack := &Bot{}
b.RegisterBot(name, slack)
b.Run(k8sImplementer, approvalsManager)
slack.slackHTTPClient = fi
return slack
}
@ -89,7 +87,7 @@ func (i *fakeSlackImplementer) PostMessage(channelID string, options ...slack.Ms
}
func newTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}
@ -109,12 +107,12 @@ func newTestingUtils() (*sql.SQLStore, func()) {
func TestBotRequest(t *testing.T) {
os.Setenv(constants.EnvSlackToken, "")
os.Setenv(constants.EnvSlackBotToken, "")
f8s := &testutil.FakeK8sImplementer{}
fi := &fakeSlackImplementer{}
token := os.Getenv(constants.EnvSlackToken)
token := os.Getenv(constants.EnvSlackBotToken)
if token == "" {
t.Skip()
}
@ -125,7 +123,7 @@ func TestBotRequest(t *testing.T) {
Store: store,
})
New("keel", token, "approvals", f8s, am, fi)
New("keel", token, "approvals", f8s, am)
defer b.Stop()
time.Sleep(1 * time.Second)
@ -156,12 +154,12 @@ func TestBotRequest(t *testing.T) {
func TestProcessApprovedResponse(t *testing.T) {
os.Setenv(constants.EnvSlackToken, "")
os.Setenv(constants.EnvSlackBotToken, "")
f8s := &testutil.FakeK8sImplementer{}
fi := &fakeSlackImplementer{}
token := os.Getenv(constants.EnvSlackToken)
token := os.Getenv(constants.EnvSlackBotToken)
if token == "" {
t.Skip()
}
@ -172,7 +170,7 @@ func TestProcessApprovedResponse(t *testing.T) {
Store: store,
})
New("keel", token, "approvals", f8s, am, fi)
New("keel", token, "approvals", f8s, am)
defer b.Stop()
time.Sleep(1 * time.Second)
@ -203,12 +201,12 @@ func TestProcessApprovedResponse(t *testing.T) {
func TestProcessApprovalReply(t *testing.T) {
os.Setenv(constants.EnvSlackToken, "")
os.Setenv(constants.EnvSlackBotToken, "")
f8s := &testutil.FakeK8sImplementer{}
fi := &fakeSlackImplementer{}
token := os.Getenv(constants.EnvSlackToken)
token := os.Getenv(constants.EnvSlackBotToken)
if token == "" {
t.Skip()
}
@ -239,7 +237,7 @@ func TestProcessApprovalReply(t *testing.T) {
t.Fatalf("unexpected error while creating : %s", err)
}
bot := New("keel", token, "approvals", f8s, am, fi)
bot := New("keel", token, "approvals", f8s, am)
defer b.Stop()
time.Sleep(1 * time.Second)
@ -274,12 +272,12 @@ func TestProcessApprovalReply(t *testing.T) {
func TestProcessRejectedReply(t *testing.T) {
os.Setenv(constants.EnvSlackToken, "")
os.Setenv(constants.EnvSlackBotToken, "")
f8s := &testutil.FakeK8sImplementer{}
fi := &fakeSlackImplementer{}
token := os.Getenv(constants.EnvSlackToken)
token := os.Getenv(constants.EnvSlackBotToken)
if token == "" {
t.Skip()
}
@ -309,7 +307,7 @@ func TestProcessRejectedReply(t *testing.T) {
t.Fatalf("unexpected error while creating : %s", err)
}
bot := New("keel", "random", "approvals", f8s, am, fi)
bot := New("keel", "random", "approvals", f8s, am)
defer b.Stop()
collector := approval.New()

114
build.ps1 Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/powershell -Command
##################################################
# Main build script, this is used both for local
# development and in continuous integration
# to build and push the images
##################################################
param (
[switch]$Push = $false,
[switch]$RunTests = $false,
[switch]$StartContainers = $false,
[switch]$StartDebugContainers = $false
)
$global:ErrorActionPreference = 'Stop'
. .\helpers.ps1
$TESTDIR = $Env:TESTDIR;
if ([string]::IsNullOrWhiteSpace($TESTDIR)) {
$TESTDIR = Get-Location;
}
# If there is a local environment envsettings
# file, load it. In pipelines, these are all comming
# from environment variables.
if (Test-Path "envsettings.ps1") {
.\envsettings.ps1;
}
# Ensure we are in LINUX containers
if (-not(Test-Path $Env:ProgramFiles\Docker\Docker\DockerCli.exe)) {
Get-Command docker
Write-Warning "Docker cli not found at $Env:ProgramFiles\Docker\Docker\DockerCli.exe"
}
else {
Write-Warning "Switching to Linux Engine"
& $Env:ProgramFiles\Docker\Docker\DockerCli.exe -SwitchLinuxEngine
}
$Env:SERVICEACCOUNT = Join-Path (split-path -parent $MyInvocation.MyCommand.Definition) "serviceaccount"
# Define the array of environment variable names to check
$envVarsToCheck = @(
"IMAGE_VERSION",
"REGISTRY_PATH"
)
foreach ($envVarName in $envVarsToCheck) {
$envVarValue = [System.Environment]::GetEnvironmentVariable($envVarName)
if ([string]::IsNullOrWhiteSpace($envVarValue)) {
throw "Environment variable '$envVarName' is empty or not set. Rename envsettings.ps1.template to envsettings.ps1 and complete the environment variables or set them for the current environment."
}
}
$version = $ENV:IMAGE_VERSION;
$containerregistry = $ENV:REGISTRY_PATH;
Write-Host "Environment IMAGE_VERSION: $($version)"
Write-Host "Environment REGISTRY_PATH: $($containerregistry)"
if (-not $containerregistry.EndsWith('/')) {
# Add a slash to the end of $containerregistry
$containerregistry = "$containerregistry/"
}
# Image names
$Env:IMG_KEEL = "$($containerregistry)keel:$($version)";
$Env:IMG_KEEL_DEBUG = "$($containerregistry)keel-debug:$($version)";
$Env:IMG_KEEL_TESTS = "$($containerregistry)keel-tests:$($version)";
docker compose build
ThrowIfError
if ($StartContainers) {
docker compose up
ThrowIfError
}
if ($StartDebugContainers) {
docker compose -f compose.debug.yml up
ThrowIfError
}
if ($RunTests -eq $true) {
Write-Host "Running tests..."
docker compose -f compose.tests.yml up -d --build
ThrowIfError
$testResultsFile = "/go/src/github.com/keel-hq/keel/test_results.json"
$localResultsPath = Join-Path $TESTDIR "test_results.xml"
$containerName = "keel_tests"
docker exec $containerName sh -c "make test"
# This one is to export the results
docker exec $containerName sh -c "go test -v `$(go list ./... | grep -v /tests) -cover 2>&1 | go-junit-report > $testResultsFile"
docker cp "$($containerName):$($testResultsFile)" $localResultsPath
Write-Host "Test results copied to $localResultsPath"
docker compose -f compose.tests.yml down
}
if ($Push) {
if ($Env:REGISTRY_USER -and $Env:REGISTRY_PWD) {
Write-Output "Container registry credentials through environment provided."
# Identify the registry
$registryHost = $ENV:REGISTRY_SERVER;
Write-Output "Remote registry host: $($registryHost)";
docker login "$($registryHost)" -u="$($Env:REGISTRY_USER)" -p="$($Env:REGISTRY_PWD)"
ThrowIfError
}
docker push "$($Env:IMG_KEEL)"
}

View File

@ -1,20 +1,17 @@
apiVersion: v1
name: keel
description: Open source, tool for automating Kubernetes deployment updates. Keel is stateless, robust and lightweight.
version: 1.0.0
# Note that we use appVersion to get images tag, so make sure this is correct.
appVersion: 0.16.1
description: Open source tool for automating Kubernetes deployment updates.
# The chart version number here is just a template. The actual version number is
# replaced during the chart build, see .github/workflows/releasechart.yaml
# The way to trigger a chart release is using a tag "chart-{CHART_VERSION}"
version: 1.0.5
appVersion: 0.20.0
keywords:
- kubernetes deployment
- helm release
- continuous deployment
home: https://keel.sh
home: https://github.com/keel-hq/keel
sources:
- https://github.com/keel-hq/keel
maintainers:
- name: rimusz
email: rmocius@gmail.com
- name: rusenask
email: karolis.rusenas@gmail.com
engine: gotpl
icon: https://raw.githubusercontent.com/keel-hq/keel/master/static/keel-logo.png

View File

@ -96,7 +96,8 @@ The following table lists has the main configurable parameters (polling, trigger
| `webhook.endpoint` | Remote webhook endpoint | |
| `slack.enabled` | Enable/disable Slack Notification | `false` |
| `slack.botName` | Name of the Slack bot | |
| `slack.token` | Slack token | |
| `slack.botToken` | Slack bot token | |
| `slack.appToken` | Slack application level token | |
| `slack.channel` | Slack channel | |
| `slack.approvalsChannel` | Slack channel for approvals | |
| `teams.enabled` | Enable/disable MS Teams Notification | `false` |
@ -144,9 +145,10 @@ The following table lists has the main configurable parameters (polling, trigger
| `dockerRegistry.key` | Docker registry secret key | |
| `secret.name` | Secret name | |
| `secret.create` | Create secret | `true` |
| `persistence.enabled` | Enable/disable audit log persistence | `true` |
| `persistence.enabled` | Enable/disable audit log persistence | `false` |
| `persistence.storageClass` | Storage Class for the Persistent Volume| `-` |
| `persistence.size` | Persistent Volume size | `1Gi` |
| `imagePullSecrets` | Image pull secrets | `[]` |
Specify each parameter using the `--set key=value[,key=value]` argument to `helm install`.

View File

@ -25,6 +25,14 @@ spec:
{{- end }}
spec:
serviceAccountName: {{ template "serviceAccount.name" . }}
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
{{- if .Values.extraContainers }}
{{ toYaml .Values.extraContainers | indent 8 }}
@ -34,6 +42,10 @@ spec:
image: "{{ .Values.image.repository }}:{{ default .Chart.AppVersion .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
command: ["/bin/keel"]
{{- with .Values.containerSecurityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
volumeMounts:
{{- if .Values.persistence.enabled }}
- name: storage-logs
@ -62,6 +74,11 @@ spec:
- name: POLL
value: "false"
{{- end }}
{{- if .Values.polling.defaultSchedule }}
# Set default poll schedule
- name: POLL_DEFAULTSCHEDULE
value: "{{ .Values.polling.defaultSchedule }}"
{{- end }}
{{- if .Values.helmProvider.enabled }}
{{- if eq .Values.helmProvider.version "v3" }}
# Enable/disable Helm provider
@ -81,8 +98,10 @@ spec:
# Enable GCR with pub/sub support
- name: PROJECT_ID
value: "{{ .Values.gcr.projectId }}"
{{- if .Values.gcr.pubSub.enabled }}
- name: PUBSUB
value: "true"
{{- end }}
{{- if .Values.gcr.clusterName }}
# Customize the cluster name, mainly useful when outside of GKE
- name: CLUSTER_NAME
@ -179,13 +198,13 @@ spec:
httpGet:
path: /healthz
port: 9300
initialDelaySeconds: 30
initialDelaySeconds: 5
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 9300
initialDelaySeconds: 30
initialDelaySeconds: 5
timeoutSeconds: 10
resources:
{{ toYaml .Values.resources | indent 12 }}

View File

@ -15,7 +15,8 @@ data:
AWS_SECRET_ACCESS_KEY: {{ .Values.ecr.secretAccessKey | b64enc }}
{{- end }}
{{- if .Values.slack.enabled }}
SLACK_TOKEN: {{ .Values.slack.token | b64enc }}
SLACK_BOT_TOKEN: {{ .Values.slack.botToken | b64enc }}
SLACK_APP_TOKEN: {{ .Values.slack.appToken | b64enc }}
{{- end }}
{{- if .Values.googleApplicationCredentials }}
google-application-credentials.json: {{ .Values.googleApplicationCredentials }}
@ -27,6 +28,9 @@ data:
{{- if .Values.teams.enabled }}
TEAMS_WEBHOOK_URL: {{ .Values.teams.webhookUrl | b64enc }}
{{- end }}
{{- if .Values.discord.enabled }}
DISCORD_WEBHOOK_URL: {{ .Values.discord.webhookUrl | b64enc }}
{{- end }}
{{- if and .Values.mail.enabled .Values.mail.smtp.pass }}
MAIL_SMTP_PASS: {{ .Values.mail.smtp.pass | b64enc }}
{{- end }}

View File

@ -7,6 +7,9 @@ image:
tag: null
pullPolicy: Always
# Image pull secrets
imagePullSecrets: []
# Enable insecure registries
insecureRegistry: false
@ -14,6 +17,7 @@ insecureRegistry: false
# you can disable it setting value below to false
polling:
enabled: true
defaultSchedule: "@every 1m"
# Extra Containers to run alongside Keel
# extraContainers:
@ -68,7 +72,8 @@ webhook:
slack:
enabled: false
botName: ""
token: ""
appToken: ""
botToken: ""
channel: ""
approvalsChannel: ""
@ -92,6 +97,11 @@ teams:
enabled: false
webhookUrl: ""
# Discord notifications
discord:
enabled: false
webhookUrl: ""
# Mail notifications
mail:
enabled: false
@ -241,3 +251,9 @@ persistence:
enabled: false
storageClass: "-"
size: 1Gi
# -- Pod security context (runAsUser, etc.)
podSecurityContext: {}
# -- Container security context (allowPrivilegeEscalation, etc.)
containerSecurityContext: {}

View File

@ -8,8 +8,9 @@ import (
"context"
kingpin "github.com/alecthomas/kingpin/v2"
"github.com/prometheus/client_golang/prometheus"
kingpin "gopkg.in/alecthomas/kingpin.v2"
kube "k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
@ -39,6 +40,7 @@ import (
// notification extensions
"github.com/keel-hq/keel/extension/notification/auditor"
_ "github.com/keel-hq/keel/extension/notification/discord"
_ "github.com/keel-hq/keel/extension/notification/hipchat"
_ "github.com/keel-hq/keel/extension/notification/mail"
_ "github.com/keel-hq/keel/extension/notification/mattermost"
@ -77,7 +79,9 @@ const (
// kubernetes config, if empty - will default to InCluster
const (
EnvKubernetesConfig = "KUBERNETES_CONFIG"
EnvKubernetesConfig = "KUBERNETES_CONFIG"
EnvKubernetesMasterUrl = "KUBERNETES_MASTERURL"
EnvKubernetesContext = "KUBERNETES_CONTEXT"
)
// EnvDebug - set to 1 or anything else to enable debug logging
@ -169,6 +173,14 @@ func main() {
k8sCfg.ConfigPath = os.Getenv(EnvKubernetesConfig)
}
if os.Getenv(EnvKubernetesMasterUrl) != "" {
k8sCfg.MasterUrl = os.Getenv(EnvKubernetesMasterUrl)
}
if os.Getenv(EnvKubernetesContext) != "" {
k8sCfg.CurrentContext = os.Getenv(EnvKubernetesContext)
}
k8sCfg.InCluster = *inCluster
implementer, err := kubernetes.NewKubernetesImplementer(k8sCfg)

22
compose.debug.yml Normal file
View File

@ -0,0 +1,22 @@
services:
keel-debug:
image: ${IMG_KEEL_DEBUG}
security_opt:
- apparmor=unconfined
cap_add:
- SYS_PTRACE
container_name: keel-debug
build:
context: .
dockerfile: Dockerfile.debug
environment:
- KUBERNETES_SERVICE_HOST=${KUBERNETES_SERVICE_HOST}
- KUBERNETES_SERVICE_PORT=${KUBERNETES_SERVICE_PORT}
- BASIC_AUTH_USER=admin
- BASIC_AUTH_PASSWORD=admin
volumes:
- ${SERVICEACCOUNT}:/var/run/secrets/kubernetes.io/serviceaccount
ports:
- '9301:9300'
- '8000:8000'
- '40000:40000'

7
compose.tests.yml Normal file
View File

@ -0,0 +1,7 @@
services:
keel_tests:
image: ${IMG_KEEL_TESTS}
container_name: keel_tests
build:
context: .
dockerfile: Dockerfile.tests

16
compose.yml Normal file
View File

@ -0,0 +1,16 @@
services:
keel:
image: ${IMG_KEEL}
container_name: keel
build:
context: .
dockerfile: Dockerfile
environment:
- KUBERNETES_SERVICE_HOST=${KUBERNETES_SERVICE_HOST}
- KUBERNETES_SERVICE_PORT=${KUBERNETES_SERVICE_PORT}
- BASIC_AUTH_USER=admin
- BASIC_AUTH_PASSWORD=admin
volumes:
- ${SERVICEACCOUNT}:/var/run/secrets/kubernetes.io/serviceaccount
ports:
- '9300:9300'

View File

@ -11,7 +11,8 @@ const WebhookEndpointEnv = "WEBHOOK_ENDPOINT"
// slack bot/token
const (
EnvSlackToken = "SLACK_TOKEN"
EnvSlackBotToken = "SLACK_BOT_TOKEN"
EnvSlackAppToken = "SLACK_APP_TOKEN"
EnvSlackBotName = "SLACK_BOT_NAME"
EnvSlackChannels = "SLACK_CHANNELS"
EnvSlackApprovalsChannel = "SLACK_APPROVALS_CHANNEL"
@ -34,6 +35,9 @@ const (
// MS Teams webhook url, see https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#setting-up-a-custom-incoming-webhook
EnvTeamsWebhookUrl = "TEAMS_WEBHOOK_URL"
// Discord webhook url, see https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
EnvDiscordWebhookUrl = "DISCORD_WEBHOOK_URL"
// Mail notification settings
EnvMailTo = "MAIL_TO"
EnvMailFrom = "MAIL_FROM"

View File

@ -185,8 +185,10 @@ spec:
# Enable MS Teams webhook endpoint
- name: TEAMS_WEBHOOK_URL
value: "{{ .teams_webhook_url }}"
- name: SLACK_TOKEN
value: "{{ .slack_token }}"
- name: SLACK_APP_TOKEN
value: "{{ .slack_app_token }}"
- name: SLACK_BOT_TOKEN
value: "{{ .slack_bot_token }}"
- name: SLACK_CHANNELS
value: "{{ .slack_channel | default "general" }}"
- name: SLACK_APPROVALS_CHANNEL

7
envsettings.ps1.template Normal file
View File

@ -0,0 +1,7 @@
$ENV:REGISTRY_PATH = "myregistry.azurecr.io/core/"
$ENV:IMAGE_VERSION = "1.0.32";
$ENV:REGISTRY_SERVER = ""
$ENV:REGISTRY_USER = ""
$ENV:REGISTRY_PWD = ""
$ENV:KUBERNETES_SERVICE_HOST = "xxxx.hcp.region.azmk8s.io"
$ENV:KUBERNETES_SERVICE_PORT = "443"

View File

@ -1,56 +1,82 @@
package gcr
import (
"errors"
"io/ioutil"
"os"
"github.com/keel-hq/keel/extension/credentialshelper"
"github.com/keel-hq/keel/types"
"context"
"errors"
"fmt"
"os"
"strings"
"cloud.google.com/go/storage"
"github.com/keel-hq/keel/extension/credentialshelper"
"github.com/keel-hq/keel/types"
"golang.org/x/oauth2/google"
)
func init() {
credentialshelper.RegisterCredentialsHelper("gcr", New())
credentialshelper.RegisterCredentialsHelper("gcr", New())
}
type CredentialsHelper struct {
enabled bool
credentials string
enabled bool
}
func New() *CredentialsHelper {
ch := &CredentialsHelper{}
credentialsFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
if !ok {
return ch
}
credentials, err := ioutil.ReadFile(credentialsFile)
if err != nil {
return ch
}
ch.enabled = true
ch.credentials = string(credentials)
return ch
return &CredentialsHelper{
enabled: true,
}
}
func (h *CredentialsHelper) IsEnabled() bool {
return h.enabled
return h.enabled
}
func (h *CredentialsHelper) GetCredentials(image *types.TrackedImage) (*types.Credentials, error) {
if !h.enabled {
return nil, errors.New("not initialised")
}
if image.Image.Registry() != "gcr.io" {
return nil, credentialshelper.ErrUnsupportedRegistry
}
return &types.Credentials{
Username: "_json_key",
Password: h.credentials,
}, nil
if !h.enabled {
return nil, errors.New("not initialised")
}
if !strings.HasPrefix(image.Image.Registry(), "gcr.io") && !strings.Contains(image.Image.Registry(), "pkg.dev") {
return nil, credentialshelper.ErrUnsupportedRegistry
}
if credentials, err := readCredentialsFromFile(); err == nil {
return credentials, nil
}
return getWorkloadIdentityTokenCredentials()
}
func readCredentialsFromFile() (*types.Credentials, error) {
credentialsFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
if !ok {
return nil, errors.New("GOOGLE_APPLICATION_CREDENTIALS environment variable not set")
}
credentials, err := os.ReadFile(credentialsFile)
if err != nil {
return nil, fmt.Errorf("failed to read credentials file: %w", err)
}
return &types.Credentials{
Username: "_json_key",
Password: string(credentials),
}, nil
}
func getWorkloadIdentityTokenCredentials() (*types.Credentials, error) {
ctx := context.Background()
tokenSource, err := google.DefaultTokenSource(ctx, storage.ScopeReadOnly)
if err != nil {
return nil, fmt.Errorf("failed to get default token source: %w", err)
}
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}
return &types.Credentials{
Username: "_token",
Password: token.AccessToken,
}, nil
}

View File

@ -0,0 +1,109 @@
package discord
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"time"
"github.com/keel-hq/keel/constants"
"github.com/keel-hq/keel/extension/notification"
"github.com/keel-hq/keel/types"
log "github.com/sirupsen/logrus"
)
const timeout = 5 * time.Second
type sender struct {
endpoint string
client *http.Client
}
// Config represents the configuration of a Discord Webhook Sender.
type Config struct {
Endpoint string
}
func init() {
notification.RegisterSender("discord", &sender{})
}
func (s *sender) Configure(config *notification.Config) (bool, error) {
// Get configuration
var httpConfig Config
if os.Getenv(constants.EnvDiscordWebhookUrl) != "" {
httpConfig.Endpoint = os.Getenv(constants.EnvDiscordWebhookUrl)
} else {
return false, nil
}
// Validate endpoint URL.
if httpConfig.Endpoint == "" {
return false, nil
}
if _, err := url.ParseRequestURI(httpConfig.Endpoint); err != nil {
return false, fmt.Errorf("could not parse endpoint URL: %s", err)
}
s.endpoint = httpConfig.Endpoint
// Setup HTTP client.
s.client = &http.Client{
Transport: http.DefaultTransport,
Timeout: timeout,
}
log.WithFields(log.Fields{
"name": "discord",
"endpoint": s.endpoint,
}).Info("extension.notification.discord: sender configured")
return true, nil
}
type DiscordMessage struct {
Username string `json:"username"`
Content string `json:"content"`
Embeds []Embed `json:"embeds"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Footer Footer `json:"footer"`
}
type Footer struct {
Text string `json:"text"`
}
func (s *sender) Send(event types.EventNotification) error {
discordMessage := DiscordMessage{
Username: "Keel",
Embeds: []Embed{
{
Title: fmt.Sprintf("%s: %s", event.Type.String(), event.Name),
Description: event.Message,
Footer: Footer{Text: event.Level.String()},
},
},
}
jsonMessage, err := json.Marshal(discordMessage)
if err != nil {
return fmt.Errorf("could not marshal: %s", err)
}
resp, err := s.client.Post(s.endpoint, "application/json", bytes.NewBuffer(jsonMessage))
if err != nil || resp == nil || (resp.StatusCode != 200 && resp.StatusCode != 204) {
if resp != nil {
return fmt.Errorf("got status %d, expected 200/204", resp.StatusCode)
}
return err
}
defer resp.Body.Close()
return nil
}

View File

@ -0,0 +1,58 @@
package discord
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/keel-hq/keel/types"
)
func TestDiscordWebhookRequest(t *testing.T) {
currentTime := time.Now()
handler := func(resp http.ResponseWriter, req *http.Request) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("failed to parse body: %s", err)
}
bodyStr := string(body)
if !strings.Contains(bodyStr, types.NotificationPreDeploymentUpdate.String()) {
t.Errorf("missing deployment type")
}
if !strings.Contains(bodyStr, "debug") {
t.Errorf("missing level")
}
if !strings.Contains(bodyStr, "update deployment") {
t.Errorf("missing name")
}
if !strings.Contains(bodyStr, "message here") {
t.Errorf("missing message")
}
t.Log(bodyStr)
}
// create test server with handler
ts := httptest.NewServer(http.HandlerFunc(handler))
defer ts.Close()
s := &sender{
endpoint: ts.URL,
client: &http.Client{},
}
s.Send(types.EventNotification{
Name: "update deployment",
Message: "message here",
CreatedAt: currentTime,
Type: types.NotificationPreDeploymentUpdate,
Level: types.LevelDebug,
})
}

View File

@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/nlopes/slack"
"github.com/slack-go/slack"
"github.com/keel-hq/keel/constants"
"github.com/keel-hq/keel/extension/notification"
@ -33,8 +33,8 @@ func init() {
func (s *sender) Configure(config *notification.Config) (bool, error) {
var token string
// Get configuration
if os.Getenv(constants.EnvSlackToken) != "" {
token = os.Getenv(constants.EnvSlackToken)
if os.Getenv(constants.EnvSlackBotToken) != "" {
token = os.Getenv(constants.EnvSlackBotToken)
} else {
return false, nil
}

View File

@ -1,7 +1,7 @@
package teams
import (
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"strings"
@ -24,7 +24,7 @@ func TestTrimLeftChar(t *testing.T) {
func TestTeamsRequest(t *testing.T) {
handler := func(resp http.ResponseWriter, req *http.Request) {
body, err := ioutil.ReadAll(req.Body)
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("failed to parse body: %s", err)
}

View File

@ -1,7 +1,7 @@
package webhook
import (
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"strings"
@ -14,7 +14,7 @@ import (
func TestWebhookRequest(t *testing.T) {
currentTime := time.Now()
handler := func(resp http.ResponseWriter, req *http.Request) {
body, err := ioutil.ReadAll(req.Body)
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("failed to parse body: %s", err)
}

320
go.mod
View File

@ -1,196 +1,232 @@
module github.com/keel-hq/keel
go 1.19
go 1.23
replace (
k8s.io/api => k8s.io/api v0.24.10
k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.24.10
k8s.io/apimachinery => k8s.io/apimachinery v0.24.10
k8s.io/apiserver => k8s.io/apiserver v0.24.10
k8s.io/cli-runtime => k8s.io/cli-runtime v0.24.10
k8s.io/client-go => k8s.io/client-go v0.24.10
k8s.io/cloud-provider => k8s.io/cloud-provider v0.24.10
k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.24.10
k8s.io/code-generator => k8s.io/code-generator v0.24.10
k8s.io/component-base => k8s.io/component-base v0.24.10
k8s.io/cri-api => k8s.io/cri-api v0.24.10
k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.24.10
k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.24.10
k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.24.10
k8s.io/kube-proxy => k8s.io/kube-proxy v0.24.10
k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.24.10
k8s.io/kubectl => k8s.io/kubectl v0.24.10
k8s.io/kubelet => k8s.io/kubelet v0.24.10
k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.24.10
k8s.io/metrics => k8s.io/metrics v0.24.10
k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.24.10
k8s.io/api => k8s.io/api v0.31.3
k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.31.3
k8s.io/apimachinery => k8s.io/apimachinery v0.31.3
k8s.io/apiserver => k8s.io/apiserver v0.31.3
k8s.io/cli-runtime => k8s.io/cli-runtime v0.31.3
k8s.io/client-go => k8s.io/client-go v0.31.3
k8s.io/cloud-provider => k8s.io/cloud-provider v0.31.3
k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.31.3
k8s.io/code-generator => k8s.io/code-generator v0.31.3
k8s.io/component-base => k8s.io/component-base v0.31.3
k8s.io/cri-api => k8s.io/cri-api v0.31.3
k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.31.3
k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.31.3
k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.31.3
k8s.io/kube-proxy => k8s.io/kube-proxy v0.31.3
k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.31.3
k8s.io/kubectl => k8s.io/kubectl v0.31.3
k8s.io/kubelet => k8s.io/kubelet v0.31.3
k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.31.3
k8s.io/metrics => k8s.io/metrics v0.31.3
k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.31.3
)
replace helm.sh/helm/v3 => helm.sh/helm/v3 v3.9.4
replace helm.sh/helm/v3 => helm.sh/helm/v3 v3.13.1
replace k8s.io/kubernetes => k8s.io/kubernetes v1.24.10
replace k8s.io/kubernetes => k8s.io/kubernetes v1.28.3
require (
cloud.google.com/go/pubsub v1.4.0
cloud.google.com/go/pubsub v1.45.1
github.com/Masterminds/semver v1.5.0
github.com/aws/aws-sdk-go v1.31.10
github.com/aws/aws-sdk-go v1.55.5
github.com/containerd/containerd v1.7.24 // indirect
github.com/daneharrigan/hipchat v0.0.0-20170512185232-835dc879394a
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/docker/distribution v2.8.1+incompatible
github.com/google/uuid v1.3.0
github.com/gorilla/mux v1.8.0
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v27.3.1+incompatible // indirect
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/jinzhu/gorm v1.9.16
github.com/nlopes/slack v0.6.0
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/opencontainers/go-digest v1.0.0
github.com/prometheus/client_golang v1.14.0
github.com/opencontainers/image-spec v1.1.0
github.com/prometheus/client_golang v1.20.5
github.com/rusenask/cron v1.1.0
github.com/rusenask/docker-registry-client v0.0.0-20200210164146-049272422097
github.com/ryanuber/go-glob v1.0.0
github.com/sirupsen/logrus v1.9.0
github.com/stretchr/testify v1.8.1
github.com/sirupsen/logrus v1.9.3
github.com/slack-go/slack v0.15.0
github.com/stretchr/testify v1.9.0
github.com/tbruyelle/hipchat-go v0.0.0-20170717082847-35aebc99209a
github.com/urfave/negroni v1.0.0
golang.org/x/net v0.5.0
google.golang.org/api v0.103.0
google.golang.org/grpc v1.51.0
gopkg.in/alecthomas/kingpin.v2 v2.2.6
helm.sh/helm/v3 v3.9.4
k8s.io/api v0.24.10
k8s.io/apimachinery v0.24.10
k8s.io/cli-runtime v0.24.10
k8s.io/client-go v0.24.10
sigs.k8s.io/yaml v1.3.0
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.31.0
google.golang.org/api v0.209.0
google.golang.org/grpc v1.68.0
k8s.io/api v0.31.3
k8s.io/apimachinery v0.31.3
k8s.io/cli-runtime v0.31.3
k8s.io/client-go v1.5.2
sigs.k8s.io/yaml v1.4.0
)
require (
cloud.google.com/go v0.107.0 // indirect
cloud.google.com/go/compute v1.12.1 // indirect
cloud.google.com/go/compute/metadata v0.2.1 // indirect
cloud.google.com/go/iam v0.8.0 // indirect
cloud.google.com/go/kms v1.8.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd // indirect
cloud.google.com/go/storage v1.47.0
github.com/alecthomas/kingpin/v2 v2.4.0
github.com/distribution/distribution/v3 v3.0.0-20230722181636-7b502560cad4
github.com/golang-jwt/jwt/v4 v4.5.1
golang.org/x/oauth2 v0.24.0
helm.sh/helm/v3 v3.16.3
)
require (
cel.dev/expr v0.18.0 // indirect
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.11.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect
cloud.google.com/go/compute/metadata v0.5.2 // indirect
cloud.google.com/go/iam v1.2.2 // indirect
cloud.google.com/go/monitoring v1.21.2 // indirect
dario.cat/mergo v1.0.1 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.49.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.1.1 // indirect
github.com/Masterminds/sprig/v3 v3.2.2 // indirect
github.com/Masterminds/squirrel v1.5.3 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/Microsoft/hcsshim v0.12.9 // indirect
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect
github.com/containerd/containerd v1.6.6 // indirect
github.com/cyphar/filepath-securejoin v0.2.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/docker/cli v20.10.17+incompatible // indirect
github.com/docker/docker v20.10.17+incompatible // indirect
github.com/docker/docker-credential-helpers v0.6.4 // indirect
github.com/docker/go-connections v0.4.0 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.3 // indirect
github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cyphar/filepath-securejoin v0.3.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v27.3.1+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.2 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-metrics v0.0.1 // indirect
github.com/docker/go-units v0.4.0 // indirect
github.com/docker/libtrust v0.0.0-20150114040149-fa567046d9b1 // indirect
github.com/emicklei/go-restful/v3 v3.8.0 // indirect
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/go-errors/errors v1.0.1 // indirect
github.com/go-gorp/gorp/v3 v3.0.2 // indirect
github.com/go-logr/logr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.5 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
github.com/emicklei/go-restful/v3 v3.12.1 // indirect
github.com/envoyproxy/go-control-plane v0.13.1 // indirect
github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect
github.com/evanphx/json-patch v5.9.0+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-gorp/gorp/v3 v3.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/gosuri/uitable v0.0.4 // indirect
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.3.0 // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.10.6 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mattn/go-sqlite3 v1.14.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/locker v1.0.1 // indirect
github.com/moby/spdystream v0.2.0 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rubenv/sql-migrate v1.2.0 // indirect
github.com/russross/blackfriday v1.5.2 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rubenv/sql-migrate v1.7.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xlab/treeprint v0.0.0-20181112141820-a009c3971eca // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 // indirect
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.4.0 // indirect
golang.org/x/term v0.4.0 // indirect
golang.org/x/text v0.6.0 // indirect
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c // indirect
google.golang.org/protobuf v1.28.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.32.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.57.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/sdk v1.32.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/grpc/stats/opentelemetry v0.0.0-20241028142157-ada6787961b3 // indirect
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.24.2 // indirect
k8s.io/apiserver v0.24.10 // indirect
k8s.io/component-base v0.24.10 // indirect
k8s.io/klog/v2 v2.60.1 // indirect
k8s.io/kube-openapi v0.0.0-20220627174259-011e075b9cb8 // indirect
k8s.io/kubectl v0.24.2 // indirect
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect
oras.land/oras-go v1.2.0 // indirect
sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
sigs.k8s.io/kustomize/api v0.11.4 // indirect
sigs.k8s.io/kustomize/kyaml v0.13.6 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
k8s.io/apiextensions-apiserver v0.31.3 // indirect
k8s.io/apiserver v0.31.3 // indirect
k8s.io/component-base v0.31.3 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241127205056-99599406b04f // indirect
k8s.io/kubectl v0.31.3 // indirect
k8s.io/utils v0.0.0-20241104163129-6fe5fd82f078 // indirect
oras.land/oras-go v1.2.5 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/kustomize/api v0.18.0 // indirect
sigs.k8s.io/kustomize/kyaml v0.18.1 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.3 // indirect
)

1228
go.sum

File diff suppressed because it is too large Load Diff

72
helpers.ps1 Normal file
View File

@ -0,0 +1,72 @@
function OutputLog {
param (
[string]$containerName
)
$logs = Invoke-Command -Script {
$ErrorActionPreference = "silentlycontinue"
docker logs $containerName --tail 250 2>&1
} -ErrorAction SilentlyContinue
Write-Host "---------------- LOGSTART"
Write-Host ($logs -join "`r`n")
Write-Host "---------------- LOGEND"
}
function WaitForLog {
param (
[string]$containerName,
[string]$logContains,
[switch]$extendedTimeout
)
$timeoutSeconds = 20;
if ($extendedTimeout) {
$timeoutSeconds = 60;
}
$timeout = New-TimeSpan -Seconds $timeoutSeconds
$sw = [System.Diagnostics.Stopwatch]::StartNew()
while ($sw.Elapsed -le $timeout) {
Start-Sleep -Seconds 1
$logs = Invoke-Command -Script {
$ErrorActionPreference = "silentlycontinue"
docker logs $containerName --tail 350 2>&1
} -ErrorAction SilentlyContinue
if ($logs -match $logContains) {
return;
}
}
Write-Host "---------------- LOGSTART"
Write-Host ($logs -join "`r`n")
Write-Host "---------------- LOGEND"
Write-Error "Timeout reached without detecting '$($logContains)' in logs after $($sw.Elapsed.TotalSeconds)s"
}
function ThrowIfError() {
if ($LASTEXITCODE -ne 0) {
Write-Error "Last exit code was NOT 0.";
}
}
function HoldBuild() {
# This method should create a file, and hold in a loop with a sleep
# until the file is deleted
# $Env:BUILD_TEMP this is the directory where the file should be crated
# Define the full path for the file
$filePath = Join-Path -Path $Env:BUILD_TEMP -ChildPath "holdbuild.txt"
# Create the file
New-Item -ItemType File -Path $filePath -Force
Write-Host "Created file: $filePath"
# Hold in a loop until the file is deleted
while (Test-Path $filePath) {
Start-Sleep -Seconds 10
Write-Host "Build held until file is deleted: $filePath "
}
Write-Host "File deleted: $filePath"
}

View File

@ -6,10 +6,12 @@ import (
core_v1 "k8s.io/api/core/v1"
)
func getContainerImages(containers []core_v1.Container) []string {
func getContainerImages(containers []core_v1.Container, filter ContainerFilter) []string {
var images []string
for _, c := range containers {
images = append(images, c.Image)
if filter == nil || filter(c) {
images = append(images, c.Image)
}
}
return images
@ -33,7 +35,12 @@ func updateDeploymentContainer(d *apps_v1.Deployment, index int, image string) {
d.Spec.Template.Spec.Containers[index].Image = image
}
func updateDeploymentInitContainer(d *apps_v1.Deployment, index int, image string) {
d.Spec.Template.Spec.InitContainers[index].Image = image
}
// stateful sets https://kubernetes.io/docs/tutorials/stateful-application/basic-stateful-set/
func getStatefulSetIdentifier(ss *apps_v1.StatefulSet) string {
return "statefulset/" + ss.Namespace + "/" + ss.Name
}
@ -42,6 +49,10 @@ func updateStatefulSetContainer(ss *apps_v1.StatefulSet, index int, image string
ss.Spec.Template.Spec.Containers[index].Image = image
}
func updateStatefulSetInitContainer(ss *apps_v1.StatefulSet, index int, image string) {
ss.Spec.Template.Spec.InitContainers[index].Image = image
}
// daemonsets
func getDaemonsetSetIdentifier(s *apps_v1.DaemonSet) string {
@ -52,6 +63,10 @@ func updateDaemonsetSetContainer(s *apps_v1.DaemonSet, index int, image string)
s.Spec.Template.Spec.Containers[index].Image = image
}
func updateDaemonsetSetInitContainer(s *apps_v1.DaemonSet, index int, image string) {
s.Spec.Template.Spec.InitContainers[index].Image = image
}
// cron
func getCronJobIdentifier(s *batch_v1.CronJob) string {
@ -61,3 +76,7 @@ func getCronJobIdentifier(s *batch_v1.CronJob) string {
func updateCronJobContainer(s *batch_v1.CronJob, index int, image string) {
s.Spec.JobTemplate.Spec.Template.Spec.Containers[index].Image = image
}
func updateCronJobInitContainer(s *batch_v1.CronJob, index int, image string) {
s.Spec.JobTemplate.Spec.Template.Spec.InitContainers[index].Image = image
}

View File

@ -23,6 +23,8 @@ type GenericResource struct {
type genericResource []*GenericResource
type ContainerFilter func(container core_v1.Container) bool
func (c genericResource) Len() int {
return len(c)
}
@ -59,7 +61,7 @@ func NewGenericResource(obj interface{}) (*GenericResource, error) {
}
func (r *GenericResource) String() string {
return fmt.Sprintf("%s/%s/%s images: %s", r.Kind(), r.Namespace, r.Name, strings.Join(r.GetImages(), ", "))
return fmt.Sprintf("%s/%s/%s images: %s", r.Kind(), r.Namespace, r.Name, strings.Join(r.GetImages(nil), ", "))
}
// DeepCopy uses an autogenerated deepcopy functions, copying the receiver, creating a new GenericResource
@ -261,16 +263,31 @@ func (r *GenericResource) GetImagePullSecrets() (secrets []string) {
}
// GetImages - returns images used by this resource
func (r *GenericResource) GetImages() (images []string) {
func (r *GenericResource) GetImages(filter ContainerFilter) (images []string) {
switch obj := r.obj.(type) {
case *apps_v1.Deployment:
return getContainerImages(obj.Spec.Template.Spec.Containers)
return getContainerImages(obj.Spec.Template.Spec.Containers, filter)
case *apps_v1.StatefulSet:
return getContainerImages(obj.Spec.Template.Spec.Containers)
return getContainerImages(obj.Spec.Template.Spec.Containers, filter)
case *apps_v1.DaemonSet:
return getContainerImages(obj.Spec.Template.Spec.Containers)
return getContainerImages(obj.Spec.Template.Spec.Containers, filter)
case *batch_v1.CronJob:
return getContainerImages(obj.Spec.JobTemplate.Spec.Template.Spec.Containers)
return getContainerImages(obj.Spec.JobTemplate.Spec.Template.Spec.Containers, filter)
}
return
}
// GetInitImages - returns init images used by this resource
func (r *GenericResource) GetInitImages(filter ContainerFilter) (images []string) {
switch obj := r.obj.(type) {
case *apps_v1.Deployment:
return getContainerImages(obj.Spec.Template.Spec.InitContainers, filter)
case *apps_v1.StatefulSet:
return getContainerImages(obj.Spec.Template.Spec.InitContainers, filter)
case *apps_v1.DaemonSet:
return getContainerImages(obj.Spec.Template.Spec.InitContainers, filter)
case *batch_v1.CronJob:
return getContainerImages(obj.Spec.JobTemplate.Spec.Template.Spec.InitContainers, filter)
}
return
}
@ -290,6 +307,21 @@ func (r *GenericResource) Containers() (containers []core_v1.Container) {
return
}
// InitContainers - returns init containers managed by this resource
func (r *GenericResource) InitContainers() (containers []core_v1.Container) {
switch obj := r.obj.(type) {
case *apps_v1.Deployment:
return obj.Spec.Template.Spec.InitContainers
case *apps_v1.StatefulSet:
return obj.Spec.Template.Spec.InitContainers
case *apps_v1.DaemonSet:
return obj.Spec.Template.Spec.InitContainers
case *batch_v1.CronJob:
return obj.Spec.JobTemplate.Spec.Template.Spec.InitContainers
}
return
}
// UpdateContainer - updates container image
func (r *GenericResource) UpdateContainer(index int, image string) {
switch obj := r.obj.(type) {
@ -304,6 +336,20 @@ func (r *GenericResource) UpdateContainer(index int, image string) {
}
}
// UpdateInitContainer - updates init container image
func (r *GenericResource) UpdateInitContainer(index int, image string) {
switch obj := r.obj.(type) {
case *apps_v1.Deployment:
updateDeploymentInitContainer(obj, index, image)
case *apps_v1.StatefulSet:
updateStatefulSetInitContainer(obj, index, image)
case *apps_v1.DaemonSet:
updateDaemonsetSetInitContainer(obj, index, image)
case *batch_v1.CronJob:
updateCronJobInitContainer(obj, index, image)
}
}
type Status struct {
// Total number of non-terminated pods targeted by this deployment (their labels match the selector).
// +optional

View File

@ -48,6 +48,56 @@ func TestDeployment(t *testing.T) {
}
}
func TestDeploymentInitContainer(t *testing.T) {
d := &apps_v1.Deployment{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Annotations: map[string]string{},
Labels: map[string]string{},
},
apps_v1.DeploymentSpec{
Template: core_v1.PodTemplateSpec{
Spec: core_v1.PodSpec{
Containers: []core_v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
},
InitContainers: []core_v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
}
gr, err := NewGenericResource(d)
if err != nil {
t.Fatalf("failed to create generic resource: %s", err)
}
gr.UpdateContainer(0, "hey/there")
gr.UpdateInitContainer(0, "over/here")
updated, ok := gr.GetResource().(*apps_v1.Deployment)
if !ok {
t.Fatalf("conversion failed")
}
if updated.Spec.Template.Spec.Containers[0].Image != "hey/there" {
t.Errorf("unexpected image: %s", updated.Spec.Template.Spec.Containers[0].Image)
}
if updated.Spec.Template.Spec.InitContainers[0].Image != "over/here" {
t.Errorf("unexpected image: %s", updated.Spec.Template.Spec.InitContainers[0].Image)
}
}
func TestDeploymentMultipleContainers(t *testing.T) {
d := &apps_v1.Deployment{
meta_v1.TypeMeta{},

View File

@ -12,7 +12,7 @@ type Translator struct {
KeelSelector string
}
func (t *Translator) OnAdd(obj interface{}) {
func (t *Translator) OnAdd(obj interface{}, isInInitialList bool) {
gr, err := NewGenericResource(obj)
if err != nil {
t.Errorf("OnAdd failed to add resource %T: %#v", obj, obj)

View File

@ -71,7 +71,8 @@ type buffer struct {
}
type addEvent struct {
obj interface{}
obj interface{}
isInInitialList bool
}
type updateEvent struct {
@ -102,7 +103,7 @@ func (b *buffer) loop(stop <-chan struct{}) {
case ev := <-b.ev:
switch ev := ev.(type) {
case *addEvent:
b.rh.OnAdd(ev.obj)
b.rh.OnAdd(ev.obj, ev.isInInitialList)
case *updateEvent:
b.rh.OnUpdate(ev.oldObj, ev.newObj)
case *deleteEvent:
@ -116,8 +117,8 @@ func (b *buffer) loop(stop <-chan struct{}) {
}
}
func (b *buffer) OnAdd(obj interface{}) {
b.send(&addEvent{obj})
func (b *buffer) OnAdd(obj interface{}, isInInitialList bool) {
b.send(&addEvent{obj, isInInitialList})
}
func (b *buffer) OnUpdate(oldObj, newObj interface{}) {

View File

@ -17,6 +17,10 @@ func (fp *ForcePolicy) ShouldUpdate(current, new string) (bool, error) {
return true, nil
}
func (fp *ForcePolicy) Filter(tags []string) []string {
return append([]string{}, tags...)
}
func (fp *ForcePolicy) Name() string {
return "force"
}

View File

@ -2,6 +2,7 @@ package policy
import (
"fmt"
"sort"
"strings"
"github.com/ryanuber/go-glob"
@ -30,5 +31,22 @@ func (p *GlobPolicy) ShouldUpdate(current, new string) (bool, error) {
return glob.Glob(p.pattern, new), nil
}
func (p *GlobPolicy) Filter(tags []string) []string {
filtered := []string{}
for _, tag := range tags {
if glob.Glob(p.pattern, tag) {
filtered = append(filtered, tag)
}
}
// sort desc alphabetically
sort.Slice(filtered, func(i, j int) bool {
return filtered[i] > filtered[j]
})
return filtered
}
func (p *GlobPolicy) Name() string { return p.policy }
func (p *GlobPolicy) Type() PolicyType { return PolicyTypeGlob }

View File

@ -22,6 +22,7 @@ type Policy interface {
ShouldUpdate(current, new string) (bool, error)
Name() string
Type() PolicyType
Filter(tags []string) []string
}
type NilPolicy struct{}
@ -29,6 +30,7 @@ type NilPolicy struct{}
func (np *NilPolicy) ShouldUpdate(c, n string) (bool, error) { return false, nil }
func (np *NilPolicy) Name() string { return "nil policy" }
func (np *NilPolicy) Type() PolicyType { return PolicyTypeNone }
func (np *NilPolicy) Filter(tags []string) []string { return append([]string{}, tags...) }
// GetPolicyFromLabelsOrAnnotations - gets policy from k8s labels or annotations
func GetPolicyFromLabelsOrAnnotations(labels map[string]string, annotations map[string]string) Policy {

View File

@ -3,6 +3,7 @@ package policy
import (
"fmt"
"regexp"
"sort"
"strings"
)
@ -36,5 +37,28 @@ func (p *RegexpPolicy) ShouldUpdate(current, new string) (bool, error) {
return p.regexp.MatchString(new), nil
}
func (p *RegexpPolicy) Filter(tags []string) []string {
filtered := []string{}
compare := p.regexp.SubexpIndex("compare")
for _, tag := range tags {
if p.regexp.MatchString(tag) {
filtered = append(filtered, tag)
}
}
sort.Slice(filtered, func(i, j int) bool {
if compare != -1 {
mi := p.regexp.FindStringSubmatch(filtered[i])
mj := p.regexp.FindStringSubmatch(filtered[j])
return mi[compare] > mj[compare]
} else {
return filtered[i] > filtered[j]
}
})
return filtered
}
func (p *RegexpPolicy) Name() string { return p.policy }
func (p *RegexpPolicy) Type() PolicyType { return PolicyTypeRegexp }

View File

@ -3,6 +3,7 @@ package policy
import (
"errors"
"fmt"
"sort"
"strings"
"github.com/Masterminds/semver"
@ -105,3 +106,29 @@ func shouldUpdate(spt SemverPolicyType, matchPreRelease bool, current, new strin
}
return false, nil
}
func (sp *SemverPolicy) Filter(tags []string) []string {
var versions []*semver.Version
var filtered []string
for _, t := range tags {
if len(strings.SplitN(t, ".", 3)) < 2 {
// Keep only X.Y.Z+ semver
continue
}
v, err := semver.NewVersion(t)
// Filter out non semver tags
if err != nil {
continue
}
versions = append(versions, v)
}
sort.Slice(versions, func(i, j int) bool { return versions[j].LessThan(versions[i]) })
for _, version := range versions {
filtered = append(filtered, version.Original())
}
return filtered
}

View File

@ -6,7 +6,7 @@ import (
"math/rand"
"time"
jwt "github.com/dgrijalva/jwt-go"
jwt "github.com/golang-jwt/jwt/v4"
log "github.com/sirupsen/logrus"
)

View File

@ -5,7 +5,7 @@ import (
"fmt"
"net/http"
request "github.com/dgrijalva/jwt-go/request"
request "github.com/golang-jwt/jwt/v4/request"
"github.com/keel-hq/keel/pkg/auth"
log "github.com/sirupsen/logrus"
)

View File

@ -69,7 +69,7 @@ func (s *TriggerServer) githubHandler(resp http.ResponseWriter, req *http.Reques
var imageName, imageTag string
switch hookEvent {
case "package_v2":
case "package":
payload := new(githubPackageV2Webhook)
if err := json.NewDecoder(req.Body).Decode(payload); err != nil {
log.WithFields(log.Fields{
@ -120,9 +120,9 @@ func (s *TriggerServer) githubHandler(resp http.ResponseWriter, req *http.Reques
return
}
if payload.RegistryPackage.PackageType != "docker" {
if payload.RegistryPackage.PackageType != "CONTAINER" {
resp.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(resp, "registry package type was not docker")
fmt.Fprintf(resp, "registry package type was not CONTAINER")
}
if payload.Repository.FullName == "" { // github package name
@ -143,8 +143,9 @@ func (s *TriggerServer) githubHandler(resp http.ResponseWriter, req *http.Reques
return
}
// XXX <jsonroot>.registry_package.package_version.package_url could work too but it ends with colon
imageName = strings.Join(
[]string{"docker.pkg.github.com", payload.Repository.FullName, payload.RegistryPackage.Name},
[]string{"ghcr.io", payload.Repository.FullName},
"/",
)
imageTag = payload.RegistryPackage.PackageVersion.Version

View File

@ -13,7 +13,7 @@ var fakeGithubPackageWebhook = `{
"registry_package": {
"id": 35087,
"name": "server",
"package_type": "docker",
"package_type": "CONTAINER",
"html_url": "https://github.com/DingGGu/UtaiteBOX/packages/35087",
"created_at": "2019-10-11T18:18:58Z",
"updated_at": "2019-10-11T18:18:58Z",
@ -119,7 +119,7 @@ var fakeGithubPackageWebhook = `{
"about_url": "https://help.github.com/about-github-package-registry",
"name": "GitHub docker registry",
"type": "docker",
"url": "https://docker.pkg.github.com/DingGGu/UtaiteBOX",
"url": "https://ghcr.io/DingGGu/UtaiteBOX",
"vendor": "GitHub Inc"
}
},
@ -361,8 +361,8 @@ func TestGithubPackageWebhookHandler(t *testing.T) {
t.Fatalf("unexpected number of events submitted: %d", len(fp.submitted))
}
if fp.submitted[0].Repository.Name != "docker.pkg.github.com/DingGGu/UtaiteBOX/server" {
t.Errorf("expected docker.pkg.github.com/DingGGu/UtaiteBOX/server but got %s", fp.submitted[0].Repository.Name)
if fp.submitted[0].Repository.Name != "ghcr.io/DingGGu/UtaiteBOX" {
t.Errorf("expected ghcr.io/DingGGu/UtaiteBOX but got %s", fp.submitted[0].Repository.Name)
}
if fp.submitted[0].Repository.Tag != "1.2.3" {
@ -380,7 +380,7 @@ func TestGithubContainerRegistryWebhookHandler(t *testing.T) {
if err != nil {
t.Fatalf("failed to create req: %s", err)
}
req.Header.Set("X-GitHub-Event", "package_v2")
req.Header.Set("X-GitHub-Event", "package")
//The response recorder used to record HTTP responses
rec := httptest.NewRecorder()

View File

@ -2,7 +2,6 @@ package http
import (
"bytes"
"io/ioutil"
"log"
"net/http"
"os"
@ -22,7 +21,7 @@ import (
)
func NewTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

View File

@ -5,6 +5,8 @@ import (
"github.com/keel-hq/keel/internal/k8s"
"github.com/keel-hq/keel/internal/policy"
"github.com/keel-hq/keel/provider/kubernetes"
)
type resource struct {
@ -29,6 +31,7 @@ func (s *TriggerServer) resourcesHandler(resp http.ResponseWriter, req *http.Req
for _, v := range vals {
p := policy.GetPolicyFromLabelsOrAnnotations(v.GetLabels(), v.GetAnnotations())
filterFunc := kubernetes.GetMonitorContainersFromMeta(v.GetLabels(), v.GetAnnotations())
res = append(res, resource{
Provider: "kubernetes",
@ -39,7 +42,7 @@ func (s *TriggerServer) resourcesHandler(resp http.ResponseWriter, req *http.Req
Policy: p.Name(),
Labels: v.GetLabels(),
Annotations: v.GetAnnotations(),
Images: v.GetImages(),
Images: v.GetImages(filterFunc),
Status: v.GetStatus(),
})
}

View File

@ -1,7 +1,6 @@
package helm3
import (
"io/ioutil"
"log"
"os"
"path/filepath"
@ -21,7 +20,7 @@ import (
)
func newTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

View File

@ -3,6 +3,7 @@ package helm3
import (
"os"
"strings"
"time"
"helm.sh/helm/v3/pkg/chart"
@ -19,7 +20,9 @@ import (
// * update to latest chart package
// * udpate the paramateres for the function
const DefaultUpdateTimeout = 300
// #595 - DefaultUpdateTimeout is in ns
// Per https://pkg.go.dev/helm.sh/helm/v3/pkg/action#Upgrade
const DefaultUpdateTimeout = 5 * time.Minute
// Implementer - generic helm implementer used to abstract actual implementation
type Implementer interface {

View File

@ -3,8 +3,9 @@ package kubernetes
import (
"context"
"fmt"
"github.com/keel-hq/keel/internal/k8s"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
apps_v1 "k8s.io/api/apps/v1"
batch_v1 "k8s.io/api/batch/v1"
@ -13,10 +14,8 @@ import (
"k8s.io/client-go/kubernetes"
core_v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
log "github.com/sirupsen/logrus"
"k8s.io/client-go/rest"
)
// Implementer - thing wrapper around currently used k8s APIs
@ -44,7 +43,12 @@ type Opts struct {
// if set - kube config options will be ignored
InCluster bool
ConfigPath string
Master string
// Override the API server URL
MasterUrl string
// If multiple context in config path, the context to use
CurrentContext string
// Unused, possibly legacy
Master string
}
// NewKubernetesImplementer - create new k8s implementer
@ -63,7 +67,10 @@ func NewKubernetesImplementer(opts *Opts) (*KubernetesImplementer, error) {
log.Info("provider.kubernetes: using in-cluster configuration")
} else if opts.ConfigPath != "" {
var err error
cfg, err = clientcmd.BuildConfigFromFlags("", opts.ConfigPath)
cfg, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
&clientcmd.ClientConfigLoadingRules{ExplicitPath: opts.ConfigPath},
&clientcmd.ConfigOverrides{CurrentContext: opts.CurrentContext, ClusterInfo: clientcmdapi.Cluster{Server: opts.MasterUrl}}).ClientConfig()
if err != nil {
log.WithFields(log.Fields{
"error": err,

View File

@ -145,11 +145,77 @@ func getImagePullSecretFromMeta(labels map[string]string, annotations map[string
return ""
}
func GetMonitorContainersFromMeta(labels map[string]string, annotations map[string]string) k8s.ContainerFilter {
monitorContainersRegex := getMonitorContainersFromMeta(labels, annotations)
filterFunc := func(container v1.Container) bool {
return monitorContainersRegex.MatchString(container.Name)
}
return filterFunc
}
/**
*
*/
func getMonitorContainersFromMeta(labels map[string]string, annotations map[string]string) *regexp.Regexp {
searchKey := strings.ToLower(types.KeelMonitorContainers)
for k, v := range labels {
if strings.ToLower(k) == searchKey {
result, err := regexp.Compile(v)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"regex": v,
}).Error("provider.kubernetes: failed to parse regular expression.")
continue
}
return result
}
}
for k, v := range annotations {
if strings.ToLower(k) == searchKey {
result, err := regexp.Compile(v)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"regex": v,
}).Error("provider.kubernetes: failed to parse regular expression.")
continue
}
return result
}
}
return regexp.MustCompile(".*") // Match all to preserve previous behavior
}
func getInitContainerTrackingFromMeta(labels map[string]string, annotations map[string]string) bool {
searchKey := strings.ToLower(types.KeelInitContainerAnnotation)
for k, v := range labels {
if strings.ToLower(k) == searchKey {
return v == "true"
}
}
for k, v := range annotations {
if strings.ToLower(k) == searchKey {
return v == "true"
}
}
return false
}
// TrackedImages returns a list of tracked images.
func (p *Provider) TrackedImages() ([]*types.TrackedImage, error) {
var trackedImages []*types.TrackedImage
for _, gr := range p.cache.Values() {
labels := gr.GetLabels()
annotations := gr.GetAnnotations()
@ -186,7 +252,13 @@ func (p *Provider) TrackedImages() ([]*types.TrackedImage, error) {
}
secrets = append(secrets, gr.GetImagePullSecrets()...)
images := gr.GetImages()
filterFunc := GetMonitorContainersFromMeta(annotations, labels)
images := gr.GetImages(filterFunc)
if getInitContainerTrackingFromMeta(labels, annotations) {
images = append(images, gr.GetInitImages(filterFunc)...)
}
for _, img := range images {
ref, err := image.Parse(img)
if err != nil {
@ -198,6 +270,7 @@ func (p *Provider) TrackedImages() ([]*types.TrackedImage, error) {
}).Error("provider.kubernetes: failed to parse image")
continue
}
svp := make(map[string]string)
semverTag, err := semver.NewVersion(ref.Tag())
@ -263,17 +336,26 @@ func (p *Provider) processEvent(event *types.Event) (updated []*k8s.GenericResou
func (p *Provider) updateDeployments(plans []*UpdatePlan) (updated []*k8s.GenericResource, err error) {
for _, plan := range plans {
resource := plan.Resource
annotations := resource.GetAnnotations()
labels := resource.GetLabels()
notificationChannels := types.ParseEventNotificationChannels(annotations)
containerFilterFunction := GetMonitorContainersFromMeta(labels, annotations)
trackInitContainers := getInitContainerTrackingFromMeta(labels, annotations)
images := resource.GetImages(containerFilterFunction)
if trackInitContainers {
images = append(images, resource.GetInitImages(containerFilterFunction)...)
}
p.sender.Send(types.EventNotification{
ResourceKind: resource.Kind(),
Identifier: resource.Identifier,
Name: "preparing to update resource",
Message: fmt.Sprintf("Preparing to update %s %s/%s %s->%s (%s)", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(resource.GetImages(), ", ")),
Message: fmt.Sprintf("Preparing to update %s %s/%s %s->%s (%s)", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(images, ", ")),
CreatedAt: time.Now(),
Type: types.NotificationPreDeploymentUpdate,
Level: types.LevelDebug,
@ -335,9 +417,9 @@ func (p *Provider) updateDeployments(plans []*UpdatePlan) (updated []*k8s.Generi
var msg string
releaseNotes := types.ParseReleaseNotesURL(resource.GetAnnotations())
if releaseNotes != "" {
msg = fmt.Sprintf("Successfully updated %s %s/%s %s->%s (%s). Release notes: %s", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(resource.GetImages(), ", "), releaseNotes)
msg = fmt.Sprintf("Successfully updated %s %s/%s %s->%s (%s). Release notes: %s", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(images, ", "), releaseNotes)
} else {
msg = fmt.Sprintf("Successfully updated %s %s/%s %s->%s (%s)", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(resource.GetImages(), ", "))
msg = fmt.Sprintf("Successfully updated %s %s/%s %s->%s (%s)", resource.Kind(), resource.Namespace, resource.Name, plan.CurrentVersion, plan.NewVersion, strings.Join(images, ", "))
}
err = p.sender.Send(types.EventNotification{

View File

@ -1,7 +1,6 @@
package kubernetes
import (
"io/ioutil"
"log"
"os"
"path/filepath"
@ -112,7 +111,7 @@ func (s *fakeSender) Send(event types.EventNotification) error {
}
func NewTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}
@ -296,6 +295,107 @@ func TestGetImpacted(t *testing.T) {
}
}
func TestGetImpactedInit(t *testing.T) {
fp := &fakeImplementer{}
fp.namespaces = &v1.NamespaceList{
Items: []v1.Namespace{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{Name: "xxxx"},
v1.NamespaceSpec{},
v1.NamespaceStatus{},
},
},
}
deps := []*apps_v1.Deployment{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Annotations: map[string]string{types.KeelInitContainerAnnotation: "true"},
Labels: map[string]string{types.KeelPolicyLabel: "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
},
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-2",
Namespace: "xxxx",
Annotations: map[string]string{types.KeelInitContainerAnnotation: "false"},
Labels: map[string]string{"whatever": "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
},
}
grs := MustParseGRS(deps)
grc := &k8s.GenericResourceCache{}
grc.Add(grs...)
approver, teardown := approver()
defer teardown()
provider, err := NewProvider(fp, &fakeSender{}, approver, grc)
if err != nil {
t.Fatalf("failed to get provider: %s", err)
}
// creating "new version" event
repo := &types.Repository{
Name: "gcr.io/v2-namespace/hello-world",
Tag: "1.1.2",
}
plans, err := provider.createUpdatePlans(repo)
if err != nil {
t.Errorf("failed to get deployments: %s", err)
}
if len(plans) != 1 {
t.Fatalf("expected to find 1 deployment update plan but found %d", len(plans))
}
found := false
for _, c := range plans[0].Resource.InitContainers() {
containerImageName := versionreg.ReplaceAllString(c.Image, "")
if containerImageName == repo.Name {
found = true
}
}
if !found {
t.Errorf("couldn't find expected deployment in impacted deployment list")
}
}
func TestGetImpactedPolicyAnnotations(t *testing.T) {
fp := &fakeImplementer{}
fp.namespaces = &v1.NamespaceList{
@ -1010,6 +1110,107 @@ func TestGetImpactedTwoContainersInSameDeployment(t *testing.T) {
}
// Test to check how many deployments are "impacted" if we have two init containers
func TestGetImpactedTwoInitContainersInSameDeployment(t *testing.T) {
fp := &fakeImplementer{}
fp.namespaces = &v1.NamespaceList{
Items: []v1.Namespace{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{Name: "xxxx"},
v1.NamespaceSpec{},
v1.NamespaceStatus{},
},
},
}
deps := []*apps_v1.Deployment{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Labels: map[string]string{types.KeelPolicyLabel: "all"},
Annotations: map[string]string{types.KeelInitContainerAnnotation: "true"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
{
Image: "gcr.io/v2-namespace/greetings-world:1.1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
},
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-2",
Namespace: "xxxx",
Labels: map[string]string{"whatever": "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
},
}
grs := MustParseGRS(deps)
grc := &k8s.GenericResourceCache{}
grc.Add(grs...)
approver, teardown := approver()
defer teardown()
provider, err := NewProvider(fp, &fakeSender{}, approver, grc)
if err != nil {
t.Fatalf("failed to get provider: %s", err)
}
// creating "new version" event
repo := &types.Repository{
Name: "gcr.io/v2-namespace/hello-world",
Tag: "1.1.2",
}
plans, err := provider.createUpdatePlans(repo)
if err != nil {
t.Errorf("failed to get deployments: %s", err)
}
if len(plans) != 1 {
t.Errorf("expected to find 1 deployment but found %d", len(plans))
}
found := false
for _, c := range plans[0].Resource.InitContainers() {
containerImageName := versionreg.ReplaceAllString(c.Image, "")
if containerImageName == repo.Name {
found = true
}
}
if !found {
t.Errorf("couldn't find expected deployment in impacted deployment list")
}
}
func TestGetImpactedTwoSameContainersInSameDeployment(t *testing.T) {
fp := &fakeImplementer{}
@ -1445,3 +1646,74 @@ func TestTrackedImagesWithSecrets(t *testing.T) {
t.Errorf("expected very-secret, got: %s", imgs[0].Secrets[1])
}
}
func TestTrackedInitImagesWithSecrets(t *testing.T) {
fp := &fakeImplementer{}
fp.namespaces = &v1.NamespaceList{
Items: []v1.Namespace{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{Name: "xxxx"},
v1.NamespaceSpec{},
v1.NamespaceStatus{},
},
},
}
deps := []*apps_v1.Deployment{
{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Labels: map[string]string{
types.KeelPolicyLabel: "all",
types.KeelImagePullSecretAnnotation: "foo-bar",
types.KeelInitContainerAnnotation: "true",
},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
Spec: v1.PodSpec{
ImagePullSecrets: []v1.LocalObjectReference{
{
Name: "very-secret",
},
},
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:1.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
},
}
grs := MustParseGRS(deps)
grc := &k8s.GenericResourceCache{}
grc.Add(grs...)
approver, teardown := approver()
defer teardown()
provider, err := NewProvider(fp, &fakeSender{}, approver, grc)
if err != nil {
t.Fatalf("failed to get provider: %s", err)
}
imgs, err := provider.TrackedImages()
if err != nil {
t.Errorf("failed to get image: %s", err)
}
if len(imgs) != 1 {
t.Errorf("expected to find 1 image, got: %d", len(imgs))
}
if imgs[0].Secrets[0] != "foo-bar" {
t.Errorf("expected foo-bar, got: %s", imgs[0].Secrets[0])
}
if imgs[0].Secrets[1] != "very-secret" {
t.Errorf("expected very-secret, got: %s", imgs[0].Secrets[1])
}
}

View File

@ -27,7 +27,78 @@ func checkForUpdate(plc policy.Policy, repo *types.Repository, resource *k8s.Gen
"policy": plc.Name(),
}).Debug("provider.kubernetes.checkVersionedDeployment: keel policy found, checking resource...")
shouldUpdateDeployment = false
containerFilterFunc := GetMonitorContainersFromMeta(resource.GetAnnotations(), resource.GetLabels())
if schedule, ok := resource.GetAnnotations()[types.KeelInitContainerAnnotation]; ok && schedule == "true" {
for idx, c := range resource.InitContainers() {
if !containerFilterFunc(c) {
continue
}
containerImageRef, err := image.Parse(c.Image)
if err != nil {
log.WithFields(log.Fields{
"error": err,
"image_name": c.Image,
}).Error("provider.kubernetes: failed to parse image name")
continue
}
log.WithFields(log.Fields{
"name": resource.Name,
"namespace": resource.Namespace,
"kind": resource.Kind(),
"parsed_image_name": containerImageRef.Remote(),
"target_image_name": repo.Name,
"target_tag": repo.Tag,
"policy": plc.Name(),
"image": c.Image,
}).Debug("provider.kubernetes: checking image")
if containerImageRef.Repository() != eventRepoRef.Repository() {
log.WithFields(log.Fields{
"parsed_image_name": containerImageRef.Remote(),
"target_image_name": repo.Name,
}).Debug("provider.kubernetes: images do not match, ignoring")
continue
}
shouldUpdateContainer, err := plc.ShouldUpdate(containerImageRef.Tag(), eventRepoRef.Tag())
if err != nil {
log.WithFields(log.Fields{
"error": err,
"parsed_image_name": containerImageRef.Remote(),
"target_image_name": repo.Name,
"policy": plc.Name(),
}).Error("provider.kubernetes: failed to check whether init container should be updated")
continue
}
if !shouldUpdateContainer {
continue
}
// updating spec template annotations
setUpdateTime(resource)
// updating image
if containerImageRef.Registry() == image.DefaultRegistryHostname {
resource.UpdateInitContainer(idx, fmt.Sprintf("%s:%s", containerImageRef.ShortName(), repo.Tag))
} else {
resource.UpdateInitContainer(idx, fmt.Sprintf("%s:%s", containerImageRef.Repository(), repo.Tag))
}
shouldUpdateDeployment = true
updatePlan.CurrentVersion = containerImageRef.Tag()
updatePlan.NewVersion = repo.Tag
updatePlan.Resource = resource
}
}
for idx, c := range resource.Containers() {
if !containerFilterFunc(c) {
continue
}
containerImageRef, err := image.Parse(c.Image)
if err != nil {
log.WithFields(log.Fields{

View File

@ -627,6 +627,111 @@ func TestProvider_checkForUpdate(t *testing.T) {
wantShouldUpdateDeployment: true,
wantErr: false,
},
{
name: "update init container if tracking is enabled",
args: args{
policy: policy.NewForcePolicy(false),
repo: &types.Repository{Name: "gcr.io/v2-namespace/hello-world", Tag: "latest"},
resource: MustParseGR(&apps_v1.Deployment{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Annotations: map[string]string{types.KeelInitContainerAnnotation: "true"},
Labels: map[string]string{types.KeelPolicyLabel: "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
ObjectMeta: meta_v1.ObjectMeta{
Annotations: map[string]string{
"this": "that",
},
},
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world",
},
},
},
},
},
apps_v1.DeploymentStatus{},
}),
},
wantUpdatePlan: &UpdatePlan{
Resource: MustParseGR(&apps_v1.Deployment{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Annotations: map[string]string{types.KeelInitContainerAnnotation: "true"},
Labels: map[string]string{types.KeelPolicyLabel: "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
ObjectMeta: meta_v1.ObjectMeta{
Annotations: map[string]string{
"this": "that",
},
},
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world:latest",
},
},
},
},
},
apps_v1.DeploymentStatus{},
}),
NewVersion: "latest",
CurrentVersion: "latest",
},
wantShouldUpdateDeployment: true,
wantErr: false,
},
{
name: "do not update init container if tracking is disabled (default)",
args: args{
policy: policy.NewForcePolicy(false),
repo: &types.Repository{Name: "gcr.io/v2-namespace/hello-world", Tag: "latest"},
resource: MustParseGR(&apps_v1.Deployment{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "dep-1",
Namespace: "xxxx",
Annotations: map[string]string{},
Labels: map[string]string{types.KeelPolicyLabel: "all"},
},
apps_v1.DeploymentSpec{
Template: v1.PodTemplateSpec{
ObjectMeta: meta_v1.ObjectMeta{
Annotations: map[string]string{
"this": "that",
},
},
Spec: v1.PodSpec{
InitContainers: []v1.Container{
{
Image: "gcr.io/v2-namespace/hello-world",
},
},
},
},
},
apps_v1.DeploymentStatus{},
}),
},
wantUpdatePlan: &UpdatePlan{
// Resource: &k8s.GenericResource{},
Resource: nil,
},
wantShouldUpdateDeployment: false,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -6,7 +6,7 @@
<a href="https://goreportcard.com/report/github.com/keel-hq/keel">
<img src="https://goreportcard.com/badge/github.com/keel-hq/keel" alt="Go Report">
</a>
<a href="https://img.shields.io/docker/pulls/keelhq/keel.svg">
<img src="https://img.shields.io/docker/pulls/keelhq/keel.svg" alt="Docker Pulls">
</a>
@ -59,7 +59,7 @@ Prerequisites:
You need to add this Chart repo to Helm:
```bash
helm repo add keel https://charts.keel.sh
helm repo add keel https://keel-hq.github.io/keel/
helm repo update
```
@ -81,6 +81,44 @@ To install for Helm v3, set helmProvider.version="v3" (default is "v2"):
helm install keel keel/keel --set helmProvider.version="v3"
```
To install using terraform:
```terraform
resource "helm_release" "keel" {
provider = helm.helm
name = "keel"
namespace = "keel"
repository = "https://keel-hq.github.io/keel"
chart = "keel"
version = "v1.0.4"
set {
name = "basicauth.enabled"
value = "true"
}
set {
name = "basicauth.user"
value = "admin"
}
set {
name = "basicauth.password"
value = "admin"
}
set {
name = "image.repository"
value = "keelhq/keel"
}
set {
name = "image.tag"
value = "0.19.1"
}
}
```
That's it, see [Configuration](https://github.com/keel-hq/keel#configuration) section now.
### Quick Start
@ -154,6 +192,24 @@ To test Keel while developing:
3. Build Keel from `cmd/keel` directory.
4. Start Keel with: `keel --no-incluster`. This will use Kubeconfig from your home.
### Debugging Keel on Windows
```powershell
# Ensure we have gcc and go
choco upgrade mingw -y
choco upgrade golang -y
# Move and build
cd cmd/keel
go build
$Env:XDG_DATA_HOME = $Env:APPDATA; # Data volume for the local database
$Env:HOME = $Env:USERPROFILE; # This is where the .kube/config will be read from
$Env:KUBERNETES_CONTEXT = "mycontext" #Use if you have more than one context in .kube/config
.\keel --no-incluster
```
### Running unit tests
Get a test parser (makes output nice):
@ -180,3 +236,33 @@ Once prerequisites are ready:
```bash
make e2e
```
### Debugging keel inside the container against your remote cluster (Windows)
The repository contains a debug version of keel container ready for remote debugging.
You can start the debug container with powershell (docker desktop needed):
```powershell
.\build.ps1 -StartDebugContainers
```
To connect to your cluster, copy the authentication details from within the keel pod in your cluster from:
```
/var/run/secrets/kubernetes.io/serviceaccount
```
to the serviceaccount folder at the root of the repository and make sure you set the environment variable for the K8S management API endpoint:
```powershell
# This can be configured in envesttings.ps1 to be picked up automatically by the build script
$ENV:KUBERNETES_SERVICE_HOST = "mycluster-o5ff3caf.hcp.myregion.azmk8s.io"
$ENV:KUBERNETES_SERVICE_PORT = "443"
```
And make sure your API server is accesible from your client (VPN, IP whitelisting or whatever you use to secure your cluster management API).
Once started, simply use the debug option in a Go debugger, such as Jetbrains GoLand:
[Debugging a Go application inside a Docker container | The GoLand Blog](https://blog.jetbrains.com/go/2020/05/06/debugging-a-go-application-inside-a-docker-container/)

58
registry/docker/json.go Normal file
View File

@ -0,0 +1,58 @@
package docker
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
)
var ErrNoMorePages = errors.New("no more pages")
// Matches an RFC 5988 (https://tools.ietf.org/html/rfc5988#section-5)
// Link header. For example,
//
// <http://registry.example.com/v2/_catalog?n=5&last=tag5>; type="application/json"; rel="next"
//
// The URL is _supposed_ to be wrapped by angle brackets `< ... >`,
// but e.g., quay.io does not include them. Similarly, params like
// `rel="next"` may not have quoted values in the wild.
var nextLinkRE = regexp.MustCompile(`^ *<?([^;>]+)>? *(?:;[^;]*)*; *rel="?next"?(?:;.*)?`)
func getNextLink(resp *http.Response) (string, error) {
for _, link := range resp.Header[http.CanonicalHeaderKey("Link")] {
parts := nextLinkRE.FindStringSubmatch(link)
if parts != nil {
return parts[1], nil
}
}
return "", ErrNoMorePages
}
// getPaginatedJSON accepts a string and a pointer, and returns the
// next page URL while updating pointed-to variable with a parsed JSON
// value. When there are no more pages it returns `ErrNoMorePages`.
func (r *Registry) getPaginatedJSON(url string, response interface{}) (string, error) {
resp, err := r.Client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
err = decoder.Decode(response)
if err != nil {
return "", err
}
next, err := getNextLink(resp)
if err != nil {
return "", err
}
if !strings.HasPrefix(next, r.URL) {
next = r.URL + next
}
return next, nil
}

View File

@ -0,0 +1,59 @@
package docker
import (
"io"
"net/http"
"strings"
manifestv2 "github.com/distribution/distribution/v3/manifest/schema2"
"github.com/opencontainers/go-digest"
oci "github.com/opencontainers/image-spec/specs-go/v1"
)
// ManifestDigest - get manifest digest
func (r *Registry) ManifestDigest(repository, reference string) (digest.Digest, error) {
url := r.url("/v2/%s/manifests/%s", repository, reference)
r.Logf("registry.manifest.head url=%s repository=%s reference=%s", url, repository, reference)
// Try HEAD request first because it's free
resp, err := r.request("HEAD", url)
if err != nil {
return "", err
}
if hdr := resp.Header.Get("Docker-Content-Digest"); hdr != "" {
return digest.Parse(hdr)
}
// HEAD request didn't return a digest, attempt to fetch digest from body
r.Logf("registry.manifest.get url=%s repository=%s reference=%s", url, repository, reference)
resp, err = r.request("GET", url)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Try to get digest from body instead, should be equal to what would be presented
// in Docker-Content-Digest
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return digest.FromBytes(body), nil
}
// request performs a request against a url
func (r *Registry) request(method string, url string) (*http.Response, error) {
req, err := http.NewRequest(method, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", strings.Join([]string{manifestv2.MediaTypeManifest, oci.MediaTypeImageIndex, oci.MediaTypeImageManifest}, ","))
resp, err := r.Client.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}

View File

@ -0,0 +1,48 @@
package docker
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
manifestV2 "github.com/distribution/distribution/v3/manifest/schema2"
)
func TestGetDigest(t *testing.T) {
req, err := http.NewRequest("GET", "https://registry.opensource.zalan.do/v2/teapot/external-dns/manifests/v0.4.8", nil)
if err != nil {
t.Fatalf("failed to create request: %s", err)
}
req.Header.Set("Accept", manifestV2.MediaTypeManifest)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("failed to request: %s", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("content-type", "application/vnd.docker.distribution.manifest.v2+json; charset=ISO-8859-1")
io.Copy(w, resp.Body)
// Reset body for additional calls
resp.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}))
defer ts.Close()
reg := New(ts.URL, "", "")
digest, err := reg.ManifestDigest(ts.URL, "notimportant")
if err != nil {
t.Errorf("failed to get digest")
}
if digest.String() != "sha256:7aa5175f39a7e8a4172972524302c9a8196f681e40d6ee5d2f6bf0ab7d600fee" {
t.Errorf("unexpected digest: %s", digest.String())
}
}

111
registry/docker/registry.go Normal file
View File

@ -0,0 +1,111 @@
package docker
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
drc "github.com/rusenask/docker-registry-client/registry"
)
type LogfCallback func(format string, args ...interface{})
type Registry struct {
URL string
Client *http.Client
Logf LogfCallback
}
/*
* Pass log messages along to Go's "log" module.
*/
func Log(format string, args ...interface{}) {
log.Printf(format, args...)
}
/*
* Create a new Registry with the given URL and credentials, then Ping()s it
* before returning it to verify that the registry is available.
*
* You can, alternately, construct a Registry manually by populating the fields.
* This passes http.DefaultTransport to WrapTransport when creating the
* http.Client.
*/
func New(registryURL, username, password string) *Registry {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return newFromTransport(registryURL, username, password, transport, Log)
}
/*
* Create a new Registry, as with New, using an http.Transport that disables
* SSL certificate verification.
*/
func NewInsecure(registryURL, username, password string) *Registry {
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return newFromTransport(registryURL, username, password, transport, Log)
}
func newFromTransport(registryURL, username, password string, transport *http.Transport, logf LogfCallback) *Registry {
url := strings.TrimSuffix(registryURL, "/")
registry := &Registry{
URL: url,
Client: &http.Client{
Transport: drc.WrapTransport(transport, url, username, password),
},
Logf: logf,
}
return registry
}
func (r *Registry) Ping() error {
url := r.url("/v2/")
r.Logf("registry.ping url=%s", url)
resp, err := r.Client.Get(url)
if resp != nil {
defer resp.Body.Close()
}
return err
}
type tagsResponse struct {
Tags []string `json:"tags"`
}
func (r *Registry) url(pathTemplate string, args ...interface{}) string {
pathSuffix := fmt.Sprintf(pathTemplate, args...)
url := fmt.Sprintf("%s%s", r.URL, pathSuffix)
return url
}

21
registry/docker/tags.go Normal file
View File

@ -0,0 +1,21 @@
package docker
func (r *Registry) Tags(repository string) (tags []string, err error) {
url := r.url("/v2/%s/tags/list", repository)
var response tagsResponse
for {
r.Logf("registry.tags url=%s repository=%s", url, repository)
url, err = r.getPaginatedJSON(url, &response)
switch err {
case ErrNoMorePages:
tags = append(tags, response.Tags...)
return tags, nil
case nil:
tags = append(tags, response.Tags...)
continue
default:
return nil, err
}
}
}

View File

@ -0,0 +1,18 @@
package docker
import (
"testing"
)
func TestGetDigestDockerHub(t *testing.T) {
client := New("https://index.docker.io", "", "")
tags, err := client.Tags("karolisr/keel")
if err != nil {
t.Errorf("failed to get tags, error: %s", err)
}
if len(tags) == 0 {
t.Errorf("no tags?")
}
}

View File

@ -7,7 +7,7 @@ import (
"strings"
"sync"
"github.com/rusenask/docker-registry-client/registry"
"github.com/keel-hq/keel/registry/docker"
log "github.com/sirupsen/logrus"
)
@ -40,7 +40,7 @@ func New() *DefaultClient {
}
return &DefaultClient{
mu: &sync.Mutex{},
registries: make(map[uint32]*registry.Registry),
registries: make(map[uint32]*docker.Registry),
insecure: insecure,
}
}
@ -49,7 +49,7 @@ func New() *DefaultClient {
type DefaultClient struct {
// a map of registries to reuse for polling
mu *sync.Mutex
registries map[uint32]*registry.Registry
registries map[uint32]*docker.Registry
insecure bool
}
@ -71,11 +71,11 @@ func hash(s string) uint32 {
return h.Sum32()
}
func (c *DefaultClient) getRegistryClient(registryAddress, username, password string) (*registry.Registry, error) {
func (c *DefaultClient) getRegistryClient(registryAddress, username, password string) (*docker.Registry, error) {
c.mu.Lock()
defer c.mu.Unlock()
var r *registry.Registry
var r *docker.Registry
h := hash(registryAddress + username + password)
r, ok := c.registries[h]
@ -85,9 +85,9 @@ func (c *DefaultClient) getRegistryClient(registryAddress, username, password st
url := strings.TrimSuffix(registryAddress, "/")
if os.Getenv(EnvInsecure) == "true" {
r = registry.NewInsecure(url, username, password)
r = docker.NewInsecure(url, username, password)
} else {
r = registry.New(url, username, password)
r = docker.New(url, username, password)
}
r.Logf = LogFormatter

View File

@ -1,17 +1,15 @@
package registry
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/keel-hq/keel/constants"
"github.com/rusenask/docker-registry-client/registry"
"fmt"
"os"
"testing"
"github.com/keel-hq/keel/registry/docker"
)
func TestDigest(t *testing.T) {
@ -32,6 +30,24 @@ func TestDigest(t *testing.T) {
}
}
func TestOCIDigest(t *testing.T) {
client := New()
digest, err := client.Digest(Opts{
Registry: "https://index.docker.io",
Name: "vaultwarden/server",
Tag: "1.32.5",
})
if err != nil {
t.Errorf("error while getting digest: %s", err)
}
if digest != "sha256:84015c9306cc58f4be8b09c1adc62cfc3b2648b1430e9c15901482f3d870bd14" {
t.Errorf("unexpected digest: %s", digest)
}
}
func TestGet(t *testing.T) {
client := New()
repo, err := client.Get(Opts{
@ -306,7 +322,7 @@ var tagsResp = `{
}`
func TestGetDockerHubManyTags(t *testing.T) {
client := registry.New("https://quay.io", "", "")
client := docker.New("https://quay.io", "", "")
tags, err := client.Tags("coreos/prometheus-operator")
if err != nil {
t.Errorf("error while getting repo: %s", err)

View File

@ -90,8 +90,9 @@ func TestPollingSemverUpdate(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -150,8 +151,9 @@ func TestPollingSemverUpdate(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -207,8 +209,9 @@ func TestPollingSemverUpdate(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -220,6 +223,64 @@ func TestPollingSemverUpdate(t *testing.T) {
t.Errorf("update failed: %s", err)
}
})
t.Run("UpdateThroughDockerHubPollingD", func(t *testing.T) {
testNamespace := createNamespaceForTest()
defer deleteTestNamespace(testNamespace)
dep := &apps_v1.Deployment{
meta_v1.TypeMeta{},
meta_v1.ObjectMeta{
Name: "deployment-3",
Namespace: testNamespace,
Labels: map[string]string{
types.KeelPolicyLabel: "patch",
types.KeelTriggerLabel: "poll",
},
Annotations: map[string]string{
types.KeelPollScheduleAnnotation: "@every 2s",
},
},
apps_v1.DeploymentSpec{
Selector: &meta_v1.LabelSelector{
MatchLabels: map[string]string{
"app": "wd-1",
},
},
Template: v1.PodTemplateSpec{
ObjectMeta: meta_v1.ObjectMeta{
Labels: map[string]string{
"app": "wd-1",
"release": "1",
},
},
Spec: v1.PodSpec{
Containers: []v1.Container{
{
Name: "wd-1",
Image: "vaultwarden/server:1.25.1",
},
},
},
},
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
err = waitFor(ctx, kcs, testNamespace, dep.ObjectMeta.Name, "vaultwarden/server:1.25.2")
if err != nil {
t.Errorf("update failed: %s", err)
}
})
}
func TestPollingPrivateRegistry(t *testing.T) {
@ -294,8 +355,9 @@ func TestPollingPrivateRegistry(t *testing.T) {
".dockerconfigjson": []byte(payload),
},
}
createOptions := meta_v1.CreateOptions{}
_, err = kcs.CoreV1().Secrets(testNamespace).Create(secret)
_, err = kcs.CoreV1().Secrets(testNamespace).Create(context.Background(), secret, createOptions)
if err != nil {
t.Fatalf("failed to create secret: %s", err)
}
@ -346,8 +408,9 @@ func TestPollingPrivateRegistry(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions = meta_v1.CreateOptions{}
_, err = kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err = kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -405,8 +468,9 @@ func TestPollingPrivateRegistry(t *testing.T) {
".dockerconfigjson": []byte(payload),
},
}
createOptions := meta_v1.CreateOptions{}
_, err = kcs.CoreV1().Secrets(testNamespace).Create(secret)
_, err = kcs.CoreV1().Secrets(testNamespace).Create(context.Background(), secret, createOptions)
if err != nil {
t.Fatalf("failed to create secret: %s", err)
} else {
@ -458,8 +522,9 @@ func TestPollingPrivateRegistry(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions = meta_v1.CreateOptions{}
_, err = kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err = kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}

View File

@ -111,8 +111,9 @@ func TestWebhooksSemverUpdate(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -215,8 +216,9 @@ func TestWebhookHighIntegerUpdate(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -349,8 +351,9 @@ func TestApprovals(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}
@ -490,8 +493,9 @@ func TestApprovalsWithAuthentication(t *testing.T) {
},
apps_v1.DeploymentStatus{},
}
createOptions := meta_v1.CreateOptions{}
_, err := kcs.AppsV1().Deployments(testNamespace).Create(dep)
_, err := kcs.AppsV1().Deployments(testNamespace).Create(context.Background(), dep, createOptions)
if err != nil {
t.Fatalf("failed to create deployment: %s", err)
}

View File

@ -55,7 +55,8 @@ func createNamespaceForTest() string {
GenerateName: "keel-e2e-test-",
},
}
cns, err := clientset.CoreV1().Namespaces().Create(ns)
createOptions := meta_v1.CreateOptions{}
cns, err := clientset.CoreV1().Namespaces().Create(context.Background(), ns, createOptions)
if err != nil {
panic(err)
}
@ -70,7 +71,7 @@ func deleteTestNamespace(namespace string) error {
defer log.Infof("test namespace '%s' deleted", namespace)
_, clientset := getKubernetesClient()
deleteOptions := meta_v1.DeleteOptions{}
return clientset.CoreV1().Namespaces().Delete(namespace, &deleteOptions)
return clientset.CoreV1().Namespaces().Delete(context.Background(), namespace, deleteOptions)
}
type KeelCmd struct {
@ -117,7 +118,7 @@ func waitFor(ctx context.Context, kcs *kubernetes.Clientset, namespace, name str
case <-ctx.Done():
return fmt.Errorf("expected '%s', got: '%s'", desired, last)
default:
updated, err := kcs.AppsV1().Deployments(namespace).Get(name, meta_v1.GetOptions{})
updated, err := kcs.AppsV1().Deployments(namespace).Get(context.Background(), name, meta_v1.GetOptions{})
if err != nil {
time.Sleep(500 * time.Millisecond)
continue

View File

@ -2,7 +2,6 @@ package poll
import (
"context"
"io/ioutil"
"log"
"os"
"path/filepath"
@ -30,7 +29,7 @@ func (g *FakeSecretsGetter) Get(image *types.TrackedImage) (*types.Credentials,
}
func newTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

View File

@ -1,10 +1,6 @@
package poll
import (
"sort"
"strings"
"github.com/Masterminds/semver"
"github.com/keel-hq/keel/extension/credentialshelper"
"github.com/keel-hq/keel/provider"
"github.com/keel-hq/keel/registry"
@ -94,43 +90,30 @@ func (j *WatchRepositoryTagsJob) computeEvents(tags []string) ([]types.Event, er
events := []types.Event{}
// Keep only semver tags, sorted desc (to optimize process)
versions := semverSort(tags)
if j.details.trackedImage.Policy != nil {
tags = j.details.trackedImage.Policy.Filter(tags)
}
for _, trackedImage := range getRelatedTrackedImages(j.details.trackedImage, trackedImages) {
// Current version tag might not be a valid semver one
currentVersion, invalidCurrentVersion := semver.NewVersion(trackedImage.Image.Tag())
// matches, going through tags
for _, version := range versions {
if invalidCurrentVersion == nil && (currentVersion.GreaterThan(version) || currentVersion.Equal(version)) {
// Current tag is a valid semver, and is bigger than currently tested one
// -> we can stop now, nothing will be worth upgrading in the rest of the sorted list
break
}
update, err := trackedImage.Policy.ShouldUpdate(trackedImage.Image.Tag(), version.Original())
// log.WithFields(log.Fields{
// "current_tag": j.details.trackedImage.Image.Tag(),
// "image_name": j.details.trackedImage.Image.Remote(),
// }).Debug("trigger.poll.WatchRepositoryTagsJob: tag: ", version.Original(), "; update: ", update, "; err:", err)
for _, tag := range tags {
update, err := trackedImage.Policy.ShouldUpdate(trackedImage.Image.Tag(), tag)
if err != nil {
continue
}
if update && !exists(version.Original(), events) {
if update && !exists(tag, events) {
event := types.Event{
Repository: types.Repository{
Name: j.details.trackedImage.Image.Repository(),
Tag: version.Original(),
Name: trackedImage.Image.Repository(),
Tag: tag,
},
TriggerName: types.TriggerTypePoll.String(),
}
events = append(events, event)
// Only keep first match per image (should be the highest usable version)
break
}
}
}
log.WithFields(log.Fields{
"current_tag": j.details.trackedImage.Image.Tag(),
"image_name": j.details.trackedImage.Image.Remote(),
@ -148,26 +131,6 @@ func exists(tag string, events []types.Event) bool {
return false
}
// Filter and sort tags according to semver, desc
func semverSort(tags []string) []*semver.Version {
var versions []*semver.Version
for _, t := range tags {
if len(strings.SplitN(t, ".", 3)) < 2 {
// Keep only X.Y.Z+ semver
continue
}
v, err := semver.NewVersion(t)
// Filter out non semver tags
if err != nil {
continue
}
versions = append(versions, v)
}
// Sort desc, following semver
sort.Slice(versions, func(i, j int) bool { return versions[j].LessThan(versions[i]) })
return versions
}
func getRelatedTrackedImages(ours *types.TrackedImage, all []*types.TrackedImage) []*types.TrackedImage {
b := all[:0]
for _, x := range all {

View File

@ -2,12 +2,10 @@ package poll
import (
"errors"
"reflect"
"strconv"
"strings"
"testing"
"github.com/Masterminds/semver"
"github.com/stretchr/testify/assert"
"github.com/keel-hq/keel/approvals"
@ -185,6 +183,33 @@ func TestWatchAllTagsMixed(t *testing.T) {
testRunHelper(testCases, availableTags, t)
}
func TestWatchGlobTagsMixed(t *testing.T) {
availableTags := []string{"1.3.0-dev", "build-1694132169", "build-1696801785", "build-1695801785"}
policy, _ := policy.NewGlobPolicy("glob:build-*")
testCases := []runTestCase{
{"1.0.0", "build-1696801785", policy},
}
testRunHelper(testCases, availableTags, t)
}
func TestWatchRegexpTagsCompareMixed(t *testing.T) {
availableTags := []string{"1.3.0-dev", "build-2a3560ef-1694132169", "build-1a3560ef-1696801785", "build-3a3560ef-1695801785"}
policy, _ := policy.NewRegexpPolicy("regexp:^build-.*-(?P<compare>.+)$")
testCases := []runTestCase{
{"1.0.0", "build-1a3560ef-1696801785", policy},
}
testRunHelper(testCases, availableTags, t)
}
func TestWatchRegexpTagsMixed(t *testing.T) {
availableTags := []string{"1.3.0-dev", "build-2a3560ef-1694132169", "build-1a3560ef-1696801785", "build-3a3560ef-1695801785"}
policy, _ := policy.NewRegexpPolicy("regexp:^build-.*$")
testCases := []runTestCase{
{"1.0.0", "build-3a3560ef-1695801785", policy},
}
testRunHelper(testCases, availableTags, t)
}
func TestWatchAllTagsMixedPolicyAll(t *testing.T) {
availableTags := []string{"1.3.0-dev", "1.5.0", "1.8.0-alpha"}
testCases := []runTestCase{
@ -193,21 +218,6 @@ func TestWatchAllTagsMixedPolicyAll(t *testing.T) {
testRunHelper(testCases, availableTags, t)
}
func Test_semverSort(t *testing.T) {
tags := []string{"1.3.0", "aa1.0.0", "zzz", "1.3.0-dev", "1.5.0", "2.0.0-alpha", "1.3.0-dev1", "1.8.0-alpha", "1.3.1-dev", "123", "1.2.3-rc.1.2+meta"}
expectedTags := []string{"2.0.0-alpha", "1.8.0-alpha", "1.5.0", "1.3.1-dev", "1.3.0", "1.3.0-dev1", "1.3.0-dev", "1.2.3-rc.1.2+meta"}
expectedVersions := make([]*semver.Version, len(expectedTags))
for i, tag := range expectedTags {
v, _ := semver.NewVersion(tag)
expectedVersions[i] = v
}
sortedTags := semverSort(tags)
if !reflect.DeepEqual(sortedTags, expectedVersions) {
t.Errorf("Invalid sorted tags; expected: %s; got: %s", expectedVersions, sortedTags)
}
}
type testingCredsHelper struct {
err error // err to return
credentials *types.Credentials // creds to return

View File

@ -160,7 +160,7 @@ func (w *RepositoryWatcher) unwatch(tracked map[string]bool) {
"job_name": key,
"image": details.trackedImage.String(),
"schedule": details.schedule,
}).Info("trigger.poll.RepositoryWatcher: image no tracked anymore, removing watcher")
}).Info("trigger.poll.RepositoryWatcher: image no longer tracked, removing watcher")
w.cron.DeleteJob(key)
delete(w.watched, key)
}

View File

@ -1,7 +1,6 @@
package pubsub
import (
"io/ioutil"
"log"
"os"
"path/filepath"
@ -20,7 +19,7 @@ import (
)
func newTestingUtils() (*sql.SQLStore, func()) {
dir, err := ioutil.TempDir("", "whstoretest")
dir, err := os.MkdirTemp("", "whstoretest")
if err != nil {
log.Fatal(err)
}

View File

@ -1,7 +1,7 @@
package pubsub
import (
"io/ioutil"
"io"
"net/http"
"regexp"
@ -43,7 +43,7 @@ func getClusterName(metadataEndpoint string) (string, error) {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}

View File

@ -30,6 +30,7 @@ type TrackedImage struct {
type Policy interface {
ShouldUpdate(current, new string) (bool, error)
Name() string
Filter(tags []string) []string
}
func (i TrackedImage) String() string {

View File

@ -1,4 +1,5 @@
// Package types holds most of the types used across Keel
//
//go:generate jsonenums -type=Notification
//go:generate jsonenums -type=Level
//go:generate jsonenums -type=TriggerType
@ -11,6 +12,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
)
@ -38,8 +40,18 @@ const KeelMatchPreReleaseAnnotation = "keel.sh/matchPreRelease"
// KeelPollScheduleAnnotation - optional variable to setup custom schedule for polling, defaults to @every 10m
const KeelPollScheduleAnnotation = "keel.sh/pollSchedule"
// KeelInitContainerAnnotation - label or annotation to track init containers, defaults to false for backward compatibility
const KeelInitContainerAnnotation = "keel.sh/initContainers"
// KeelMonitorContainers - you can only have one keel settings per object type, but some of them might have multiple containers. Use this setting to
// specify with a regular expression which containers should be monitored. If empty, all containers will be monitored.
// It is currently a limitation that all containers in the same object will share the same configuration (pollSchedule, etc.).
// Support a per-container configuration would require quite a refactor that would impact the frontend and the current implementation.
// Future proposal for this would be to have namespaced annotations such as keel.sh/mycontainer/poolSchedule
const KeelMonitorContainers = "keel.sh/monitorContainers"
// KeelPollDefaultSchedule - defaul polling schedule
const KeelPollDefaultSchedule = "@every 1m"
var KeelPollDefaultSchedule = "@every 1m"
// KeelDigestAnnotation - digest annotation
const KeelDigestAnnotation = "keel.sh/digest"
@ -63,6 +75,13 @@ const KeelApprovalDeadlineDefault = 24
// KeelReleasePage - optional release notes URL passed on with notification
const KeelReleaseNotesURL = "keel.sh/releaseNotes"
func init() {
value, found := os.LookupEnv("POLL_DEFAULTSCHEDULE")
if found {
KeelPollDefaultSchedule = value
}
}
// Repository - represents main docker repository fields that
// keel cares about
type Repository struct {

View File

@ -6,7 +6,7 @@ import (
"strings"
// "github.com/docker/distribution/digest"
"github.com/docker/distribution/reference"
"github.com/distribution/distribution/v3/reference"
"github.com/opencontainers/go-digest"
)