Compare commits
67 Commits
Author | SHA1 | Date |
---|---|---|
|
d6eb2583af | |
|
3989c0d2e7 | |
|
8f3bad6bc6 | |
|
65260ae66f | |
|
d6e2295443 | |
|
3c8cb78603 | |
|
9a26fc5687 | |
|
45df99b788 | |
|
611ff29997 | |
|
11c1b68cfe | |
|
34566aa802 | |
|
10acf52919 | |
|
0473dc645a | |
|
a2ececac2b | |
|
d9bed83ff6 | |
|
0cf6619970 | |
|
e44b27a563 | |
|
a6281726ef | |
|
1ff565610e | |
|
0e16545055 | |
|
dcf23d5f9a | |
|
00cb03d2a9 | |
|
dc7e392681 | |
|
980f5d4d2c | |
|
3e261382bf | |
|
0cad29f17b | |
|
0462d1607d | |
|
9f0a7160bb | |
|
f4415cb5bb | |
|
8834bb81eb | |
|
6c9f9a0093 | |
|
3b34dec0ce | |
|
3ecd57c5cb | |
|
d0afbe482e | |
|
220a46fda4 | |
|
49a7edfa76 | |
|
1c6db11eb4 | |
|
a9c8ea8d06 | |
|
da5cfa1003 | |
|
bea3fb3511 | |
|
6defcfcad8 | |
|
ee938a7b82 | |
|
23bd0a1084 | |
|
52af7e9650 | |
|
21c273a77b | |
|
7ff089371b | |
|
e57f78d0dc | |
|
f8dabdc080 | |
|
665090b15c | |
|
a9c1a4c028 | |
|
5cd202fcef | |
|
70c1b892ae | |
|
f966c0f245 | |
|
675529874c | |
|
fb2bb892ea | |
|
5703f0444b | |
|
dd2d232edb | |
|
da098d8402 | |
|
1e76c363d1 | |
|
ed45f87f35 | |
|
bc5a4cbf0c | |
|
2315aced1b | |
|
a58d5a638d | |
|
2db63a3437 | |
|
5bd875a9a1 | |
|
e8b5953d82 | |
|
7d74d0a10e |
|
@ -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*
|
|
@ -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
|
||||
|
|
|
@ -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 }}"
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"]
|
5
Makefile
5
Makefile
|
@ -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 .
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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`.
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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: {}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'
|
|
@ -0,0 +1,7 @@
|
|||
services:
|
||||
keel_tests:
|
||||
image: ${IMG_KEEL_TESTS}
|
||||
container_name: keel_tests
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.tests
|
|
@ -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'
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
320
go.mod
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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{},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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{}) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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) {
|
||||
|
|
90
readme.md
90
readme.md
|
@ -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/)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in New Issue