Webhook that injects secrets into pods

This commit is contained in:
jillianwilson
2021-10-14 13:06:07 -03:00
parent a5f4a7a0c1
commit a8e6a4a4f1
117 changed files with 30234 additions and 7078 deletions

View File

@@ -0,0 +1,30 @@
# Build the manager binary
FROM golang:1.13 as builder
WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# Copy the go source
COPY secret-injector/cmd/main.go secret-injector/main.go
COPY secret-injector/pkg/ secret-injector/pkg/
COPY vendor/ vendor/
# Build
ARG secret_injector_version=dev
RUN CGO_ENABLED=0 \
GO111MODULE=on \
go build \
-ldflags "-X \"github.com/1Password/onepassword-operator/operator/version.Version=$secret_injector_version\"" \
-mod vendor \
-a -o injector secret-injector/main.go
# Use distroless as minimal base image to package the secret-injector binary
# Refer to https://github.com/GoogleContainerTools/distroless for more details
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=builder /workspace/injector .
USER nonroot:nonroot
ENTRYPOINT ["/injector"]

98
secret-injector/Makefile Normal file
View File

@@ -0,0 +1,98 @@
# Image URL to use all building/pushing image targets;
# Use your own docker registry and image name for dev/test by overridding the
# IMAGE_REPO, IMAGE_NAME and IMAGE_TAG environment variable.
IMAGE_REPO ?= docker.io/morvencao
IMAGE_NAME ?= op-secret-injector
# Github host to use for checking the source tree;
# Override this variable ue with your own value if you're working on forked repo.
GIT_HOST ?= github.com/morvencao
PWD := $(shell pwd)
BASE_DIR := $(shell basename $(PWD))
# Keep an existing GOPATH, make a private one if it is undefined
GOPATH_DEFAULT := $(PWD)/.go
export GOPATH ?= $(GOPATH_DEFAULT)
TESTARGS_DEFAULT := "-v"
export TESTARGS ?= $(TESTARGS_DEFAULT)
DEST := $(GOPATH)/src/$(GIT_HOST)/$(BASE_DIR)
IMAGE_TAG ?= $(shell date +v%Y%m%d)-$(shell git describe --match=$(git rev-parse --short=8 HEAD) --tags --always --dirty)
LOCAL_OS := $(shell uname)
ifeq ($(LOCAL_OS),Linux)
TARGET_OS ?= linux
XARGS_FLAGS="-r"
else ifeq ($(LOCAL_OS),Darwin)
TARGET_OS ?= darwin
XARGS_FLAGS=
else
$(error "This system's OS $(LOCAL_OS) isn't recognized/supported")
endif
all: fmt lint test build image
ifeq (,$(wildcard go.mod))
ifneq ("$(realpath $(DEST))", "$(realpath $(PWD))")
$(error Please run 'make' from $(DEST). Current directory is $(PWD))
endif
endif
############################################################
# format section
############################################################
fmt:
@echo "Run go fmt..."
############################################################
# lint section
############################################################
lint:
@echo "Runing the golangci-lint..."
############################################################
# test section
############################################################
test:
@echo "Running the tests for $(IMAGE_NAME)..."
@go test $(TESTARGS) ./...
############################################################
# build section
############################################################
build:
@echo "Building the $(IMAGE_NAME) binary..."
@CGO_ENABLED=0 go build -o build/_output/bin/$(IMAGE_NAME) ./cmd/
build-linux:
@echo "Building the $(IMAGE_NAME) binary for Docker (linux)..."
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/_output/linux/bin/$(IMAGE_NAME) ./cmd/
############################################################
# image section
############################################################
image: build-image push-image
build-image: build-linux
@echo "Building the docker image: $(IMAGE_REPO)/$(IMAGE_NAME):$(IMAGE_TAG)..."
@docker build -t $(IMAGE_REPO)/$(IMAGE_NAME):$(IMAGE_TAG) -f build/Dockerfile .
push-image: build-image
@echo "Pushing the docker image for $(IMAGE_REPO)/$(IMAGE_NAME):$(IMAGE_TAG) and $(IMAGE_REPO)/$(IMAGE_NAME):latest..."
@docker tag $(IMAGE_REPO)/$(IMAGE_NAME):$(IMAGE_TAG) $(IMAGE_REPO)/$(IMAGE_NAME):latest
@docker push $(IMAGE_REPO)/$(IMAGE_NAME):$(IMAGE_TAG)
@docker push $(IMAGE_REPO)/$(IMAGE_NAME):latest
############################################################
# clean section
############################################################
clean:
@rm -rf build/_output
.PHONY: all fmt lint check test build image clean

84
secret-injector/README.md Normal file
View File

@@ -0,0 +1,84 @@
## Deploy
1. Create namespace `op-secret-injector` in which the 1Password secret injector webhook is deployed:
```
# kubectl create ns op-secret-injector
```
2. Create a signed cert/key pair and store it in a Kubernetes `secret` that will be consumed by 1Password secret injector deployment:
```
# ./deploy/webhook-create-signed-cert.sh \
--service op-secret-injector-webhook-svc \
--secret op-secret-injector-webhook-certs \
--namespace op-secret-injector
```
3. Patch the `MutatingWebhookConfiguration` by set `caBundle` with correct value from Kubernetes cluster:
```
# cat deploy/mutatingwebhook.yaml | \
deploy/webhook-patch-ca-bundle.sh > \
deploy/mutatingwebhook-ca-bundle.yaml
```
4. Deploy resources:
```
# kubectl create -f deploy/deployment.yaml
# kubectl create -f deploy/service.yaml
# kubectl create -f deploy/mutatingwebhook-ca-bundle.yaml
```
## Verify
1. The sidecar inject webhook should be in running state:
```
# kubectl -n sidecar-injector get pod
NAME READY STATUS RESTARTS AGE
sidecar-injector-webhook-deployment-7c8bc5f4c9-28c84 1/1 Running 0 30s
# kubectl -n sidecar-injector get deploy
NAME READY UP-TO-DATE AVAILABLE AGE
sidecar-injector-webhook-deployment 1/1 1 1 67s
```
2. Create new namespace `injection` and label it with `sidecar-injector=enabled`:
```
# kubectl create ns injection
# kubectl label namespace injection sidecar-injection=enabled
# kubectl get namespace -L sidecar-injection
NAME STATUS AGE SIDECAR-INJECTION
default Active 26m
injection Active 13s enabled
kube-public Active 26m
kube-system Active 26m
sidecar-injector Active 17m
```
3. Deploy an app in Kubernetes cluster, take `alpine` app as an example
```
# kubectl run alpine --image=alpine --restart=Never -n injection --overrides='{"apiVersion":"v1","metadata":{"annotations":{"sidecar-injector-webhook.morven.me/inject":"yes"}}}' --command -- sleep infinity
```
4. Verify sidecar container is injected:
```
# kubectl get pod
NAME READY STATUS RESTARTS AGE
alpine 2/2 Running 0 1m
# kubectl -n injection get pod alpine -o jsonpath="{.spec.containers[*].name}"
alpine sidecar-nginx
```
## Troubleshooting
Sometimes you may find that pod is injected with sidecar container as expected, check the following items:
1. The sidecar-injector webhook is in running state and no error logs.
2. The namespace in which application pod is deployed has the correct labels as configured in `mutatingwebhookconfiguration`.
3. Check the `caBundle` is patched to `mutatingwebhookconfiguration` object by checking if `caBundle` fields is empty.
4. Check if the application pod has annotation `sidecar-injector-webhook.morven.me/inject":"yes"`.

View File

@@ -0,0 +1,52 @@
apiVersion: v1
kind: Service
metadata:
name: app-example
spec:
type: NodePort
selector:
app: app-example
ports:
- port: 5000
name: app-example
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-example
spec:
selector:
matchLabels:
app: app-example
template:
metadata:
annotations:
operator.1password.io/inject: "app-example"
labels:
app: app-example
spec:
containers:
- name: app-example
command: ["./example"]
image: connect-app-example:latest
imagePullPolicy: Never
resources:
limits:
memory: "128Mi"
cpu: "0.2"
ports:
- containerPort: 5000
env:
- name: OP_VAULT
value: ApplicationConfiguration
- name: APP_TITLE
value: op://ApplicationConfiguration/Webapp/title
- name: BUTTON_TEXT
value: op://ApplicationConfiguration/Webapp/action
- name: OP_CONNECT_HOST
value: http://onepassword-connect:8080/
- name: OP_CONNECT_TOKEN
valueFrom:
secretKeyRef:
name: onepassword-token
key: token

View File

@@ -0,0 +1,85 @@
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/1Password/onepassword-operator/secret-injector/pkg/webhook"
"github.com/golang/glog"
)
const (
connectTokenSecretKeyEnv = "OP_CONNECT_TOKEN_KEY"
connectTokenSecretNameEnv = "OP_CONNECT_TOKEN_NAME"
connectHostEnv = "OP_CONNECT_HOST"
)
func main() {
var parameters webhook.WebhookServerParameters
glog.Info("Starting webhook")
// get command line parameters
flag.IntVar(&parameters.Port, "port", 8443, "Webhook server port.")
flag.StringVar(&parameters.CertFile, "tlsCertFile", "/etc/webhook/certs/cert.pem", "File containing the x509 Certificate for HTTPS.")
flag.StringVar(&parameters.KeyFile, "tlsKeyFile", "/etc/webhook/certs/key.pem", "File containing the x509 private key to --tlsCertFile.")
flag.Parse()
pair, err := tls.LoadX509KeyPair(parameters.CertFile, parameters.KeyFile)
if err != nil {
glog.Errorf("Failed to load key pair: %v", err)
}
connectHost, present := os.LookupEnv(connectHostEnv)
if !present {
glog.Error("")
}
connectTokenName, present := os.LookupEnv(connectTokenSecretNameEnv)
if !present {
glog.Error("")
}
connectTokenKey, present := os.LookupEnv(connectTokenSecretKeyEnv)
if !present {
glog.Error("")
}
webhookConfig := webhook.Config{
ConnectHost: connectHost,
ConnectTokenName: connectTokenName,
ConnectTokenKey: connectTokenKey,
}
webhookServer := &webhook.WebhookServer{
Config: webhookConfig,
Server: &http.Server{
Addr: fmt.Sprintf(":%v", parameters.Port),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{pair}},
},
}
// define http server and server handler
mux := http.NewServeMux()
mux.HandleFunc("/inject", webhookServer.Serve)
webhookServer.Server.Handler = mux
// start webhook server in new rountine
go func() {
if err := webhookServer.Server.ListenAndServeTLS("", ""); err != nil {
glog.Errorf("Failed to listen and serve webhook server: %v", err)
}
}()
// listening OS shutdown singal
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)
<-signalChan
glog.Infof("Got OS shutdown signal, shutting down webhook server gracefully...")
webhookServer.Server.Shutdown(context.Background())
}

View File

@@ -0,0 +1,42 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: op-secret-injector-webhook-deployment
namespace: op-secret-injector
labels:
app: op-secret-injector
spec:
replicas: 1
selector:
matchLabels:
app: op-secret-injector
template:
metadata:
labels:
app: op-secret-injector
spec:
containers:
- name: op-secret-injector
image: local/onepassword-secrets-injector:v1.1.0
imagePullPolicy: Never
args:
- -tlsCertFile=/etc/webhook/certs/cert.pem
- -tlsKeyFile=/etc/webhook/certs/key.pem
- -alsologtostderr
- -v=4
- 2>&1
env:
- name: OP_CONNECT_HOST
value: http://onepassword-connect:8080/
- name: OP_CONNECT_TOKEN_NAME
value: onepassword-token
- name: OP_CONNECT_TOKEN_KEY
value: token
volumeMounts:
- name: webhook-certs
mountPath: /etc/webhook/certs
readOnly: true
volumes:
- name: webhook-certs
secret:
secretName: op-secret-injector-webhook-certs

View File

@@ -0,0 +1,22 @@
apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
name: op-secret-injector-webhook-cfg
labels:
app: op-secret-injector
webhooks:
- name: op-secret-injector.morven.me
clientConfig:
service:
name: op-secret-injector-webhook-svc
namespace: op-secret-injector
path: "/inject"
caBundle: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCakNDQWU2Z0F3SUJBZ0lCQVRBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwdGFXNXAKYTNWaVpVTkJNQjRYRFRJd01Ea3lOekl3TkRjMU9Wb1hEVE13TURreU5qSXdORGMxT1Zvd0ZURVRNQkVHQTFVRQpBeE1LYldsdWFXdDFZbVZEUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTmlICjZzZVJsZG9CWlRpRVJMeVhwbXFCU3ZOcmJyMWFMcVhpZVVWcXdCcytOUUora1hsazBIRWFldnJRU2QvNnVqY2UKSHpuNFR6Smh3Qk9pYU5BSDN6QUZkeXZxRGwwZVFzNm50R2pDbVFFK0xrUU5PQlVXYmk3WEc2am1tdDA5aFFUVwpTOXg2UDdpai9lUUtLRUJFQTFlRWYvTFZibDZPMVBqa0lXV2E0SjFRMEZoQUtnSjdxUmVJaEg1dkRoVHF3TXVzClZLTEF2bU9xRk03aDNmZ1UzWVltZldpMUFoVnF0VklMYmhkOS8xbzFYM2ZESitFK0dESGMyb0NKK1QvQkxJTmsKOWhTWEhWOTdONFhib1BUWktzZXJFa3JQTnlFYkY4alpvWndBc3FuRVhBNW5sem5vTlJnTFNqSEE0NFZXOGZyawo1RWtJdFNPdkFMMHM1K0FDMnFzQ0F3RUFBYU5oTUY4d0RnWURWUjBQQVFIL0JBUURBZ0trTUIwR0ExVWRKUVFXCk1CUUdDQ3NHQVFVRkJ3TUNCZ2dyQmdFRkJRY0RBVEFQQmdOVkhSTUJBZjhFQlRBREFRSC9NQjBHQTFVZERnUVcKQkJTSG5DcFRMSDRQeDRkai9oWTVNWEx6TndNeWhqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFvamR6ZzlySgpZTjlJSTJjaUNRS0djZEZGbWtHcGxiandRczBVMVdKY0plZWs3WDh3WWdPMnI3UFhLRklDTEM3aGM5bkUxYnluCkxha2YwMzhUNzRBQzlQOHZXUUJSb2lFMlBKV1BGMjhGTFJWeWgwTWdYQ3dZU20zeitIRDR5TjViWFpNSmJ4WlYKamRza0IzeVpHSW9jZ2RBSk1rU0ptQTN6RkowaHpsY09EZTNOTVA0Ujl4Z0VWczU4bHV5bjl6bm5sL2FDODlHdwpuVnVPRkk0S0dwOFF5NXFjQUxKZndiRGNrNzBjbnRQUEhBN2trT1JtUG41Z2hNSFJPZGxsamxmdXYxVE5RcVo2CjhjUENRRW1zc1ZyajFrVEh6Y3FOUXpqOWVMK2VPMGtyRWw2dzZMcm5YY0dleUxIZVc1cHF6YUY2bWZrTitEZEQKSENjV0U2V1pvTUp2UFE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
namespaceSelector:
matchLabels:
op-secret-injection: enabled

View File

@@ -0,0 +1,22 @@
apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
name: op-secret-injector-webhook-cfg
labels:
app: op-secret-injector
webhooks:
- name: op-secret-injector.morven.me
clientConfig:
service:
name: op-secret-injector-webhook-svc
namespace: op-secret-injector
path: "/inject"
caBundle: ${CA_BUNDLE}
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
namespaceSelector:
matchLabels:
op-secret-injection: enabled

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: op-secret-injector-webhook-svc
namespace: op-secret-injector
labels:
app: op-secret-injector
spec:
ports:
- port: 443
targetPort: 8443
selector:
app: op-secret-injector

View File

@@ -0,0 +1,131 @@
#!/bin/bash
set -e
usage() {
cat <<EOF
Generate certificate suitable for use with an op-secret-injector webhook service.
This script uses k8s' CertificateSigningRequest API to a generate a
certificate signed by k8s CA suitable for use with op-secret-injector webhook
services. This requires permissions to create and approve CSR. See
https://kubernetes.io/docs/tasks/tls/managing-tls-in-a-cluster for
detailed explanation and additional instructions.
The server key/cert k8s CA cert are stored in a k8s secret.
usage: ${0} [OPTIONS]
The following flags are required.
--service Service name of webhook.
--namespace Namespace where webhook service and secret reside.
--secret Secret name for CA certificate and server certificate/key pair.
EOF
exit 1
}
while [[ $# -gt 0 ]]; do
case ${1} in
--service)
service="$2"
shift
;;
--secret)
secret="$2"
shift
;;
--namespace)
namespace="$2"
shift
;;
*)
usage
;;
esac
shift
done
[ -z "${service}" ] && service=op-secret-injector-webhook-svc
[ -z "${secret}" ] && secret=op-secret-injector-webhook-certs
[ -z "${namespace}" ] && namespace=default
if [ ! -x "$(command -v openssl)" ]; then
echo "openssl not found"
exit 1
fi
csrName=${service}.${namespace}
tmpdir=$(mktemp -d)
echo "creating certs in tmpdir ${tmpdir} "
cat <<EOF >> "${tmpdir}"/csr.conf
[req]
req_extensions = v3_req
distinguished_name = req_distinguished_name
[req_distinguished_name]
[ v3_req ]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${service}
DNS.2 = ${service}.${namespace}
DNS.3 = ${service}.${namespace}.svc
EOF
openssl genrsa -out "${tmpdir}"/server-key.pem 2048
openssl req -new -key "${tmpdir}"/server-key.pem -subj "/CN=${service}.${namespace}.svc" -out "${tmpdir}"/server.csr -config "${tmpdir}"/csr.conf
# clean-up any previously created CSR for our service. Ignore errors if not present.
kubectl delete csr ${csrName} 2>/dev/null || true
# create server cert/key CSR and send to k8s API
cat <<EOF | kubectl create -f -
apiVersion: certificates.k8s.io/v1beta1
kind: CertificateSigningRequest
metadata:
name: ${csrName}
spec:
groups:
- system:authenticated
request: $(< "${tmpdir}"/server.csr base64 | tr -d '\n')
usages:
- digital signature
- key encipherment
- server auth
EOF
# verify CSR has been created
while true; do
if kubectl get csr ${csrName}; then
break
else
sleep 1
fi
done
# approve and fetch the signed certificate
kubectl certificate approve ${csrName}
# verify certificate has been signed
for _ in $(seq 10); do
serverCert=$(kubectl get csr ${csrName} -o jsonpath='{.status.certificate}')
if [[ ${serverCert} != '' ]]; then
break
fi
sleep 1
done
if [[ ${serverCert} == '' ]]; then
echo "ERROR: After approving csr ${csrName}, the signed certificate did not appear on the resource. Giving up after 10 attempts." >&2
exit 1
fi
echo "${serverCert}" | openssl base64 -d -A -out "${tmpdir}"/server-cert.pem
# create the secret with CA cert and server cert/key
kubectl create secret generic ${secret} \
--from-file=key.pem="${tmpdir}"/server-key.pem \
--from-file=cert.pem="${tmpdir}"/server-cert.pem \
--dry-run -o yaml |
kubectl -n ${namespace} apply -f -

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -o errexit
set -o nounset
set -o pipefail
CA_BUNDLE=$(kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}')
if [ -z "${CA_BUNDLE}" ]; then
CA_BUNDLE=$(kubectl get secrets -o jsonpath="{.items[?(@.metadata.annotations['kubernetes\.io/service-account\.name']=='default')].data.ca\.crt}")
fi
export CA_BUNDLE
if command -v envsubst >/dev/null 2>&1; then
envsubst
else
sed -e "s|\${CA_BUNDLE}|${CA_BUNDLE}|g"
fi

View File

@@ -0,0 +1,214 @@
service:
# When updating this, also update the version stored in docker/build-tools/Dockerfile in the multicloudlab/tools repo.
golangci-lint-version: 1.18.x # use the fixed version to not introduce new linters unexpectedly
run:
# timeout for analysis, e.g. 30s, 5m, default is 1m
deadline: 20m
# which dirs to skip: they won't be analyzed;
# can use regexp here: generated.*, regexp is applied on full path;
# default value is empty list, but next dirs are always skipped independently
# from this option's value:
# vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
skip-dirs:
- genfiles$
- vendor$
# which files to skip: they will be analyzed, but issues from them
# won't be reported. Default value is empty list, but there is
# no need to include all autogenerated files, we confidently recognize
# autogenerated files. If it's not please let us know.
skip-files:
- ".*\\.pb\\.go"
- ".*\\.gen\\.go"
linters:
# please, do not use `enable-all`: it's deprecated and will be removed soon.
# inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint
disable-all: true
enable:
- deadcode
- errcheck
- gocyclo
- gofmt
- goimports
- golint
- gosec
- gosimple
- govet
- ineffassign
- interfacer
- lll
- misspell
- staticcheck
- structcheck
- typecheck
- unconvert
- unparam
- unused
- varcheck
# don't enable:
# - gocritic
# - bodyclose
# - depguard
# - dogsled
# - dupl
# - funlen
# - gochecknoglobals
# - gochecknoinits
# - gocognit
# - godox
# - maligned
# - nakedret
# - prealloc
# - scopelint
# - whitespace
# - stylecheck
linters-settings:
errcheck:
# report about not checking of errors in type assetions: `a := b.(MyStruct)`;
# default is false: such cases aren't reported by default.
check-type-assertions: false
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
# default is false: such cases aren't reported by default.
check-blank: false
govet:
# report about shadowed variables
check-shadowing: false
golint:
# minimal confidence for issues, default is 0.8
min-confidence: 0.0
gofmt:
# simplify code: gofmt with `-s` option, true by default
simplify: true
goimports:
# put imports beginning with prefix after 3rd-party packages;
# it's a comma-separated list of prefixes
local-prefixes: github.com/IBM/
maligned:
# print struct with more effective memory layout or not, false by default
suggest-new: true
misspell:
# Correct spellings using locale preferences for US or UK.
# Default is to use a neutral variety of English.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
locale: US
ignore-words:
- cancelled
lll:
# max line length, lines longer will be reported. Default is 120.
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
line-length: 300
# tab width in spaces. Default to 1.
tab-width: 1
unused:
# treat code as a program (not a library) and report unused exported identifiers; default is false.
# XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find funcs usages. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
unparam:
# call graph construction algorithm (cha, rta). In general, use cha for libraries,
# and rta for programs with main packages. Default is cha.
algo: cha
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
gocritic:
enabled-checks:
- appendCombine
- argOrder
- assignOp
- badCond
- boolExprSimplify
- builtinShadow
- captLocal
- caseOrder
- codegenComment
- commentedOutCode
- commentedOutImport
- defaultCaseOrder
- deprecatedComment
- docStub
- dupArg
- dupBranchBody
- dupCase
- dupSubExpr
- elseif
- emptyFallthrough
- equalFold
- flagDeref
- flagName
- hexLiteral
- indexAlloc
- initClause
- methodExprCall
- nilValReturn
- octalLiteral
- offBy1
- rangeExprCopy
- regexpMust
- sloppyLen
- stringXbytes
- switchTrue
- typeAssertChain
- typeSwitchVar
- typeUnparen
- underef
- unlambda
- unnecessaryBlock
- unslice
- valSwap
- weakCond
# Unused
# - yodaStyleExpr
# - appendAssign
# - commentFormatting
# - emptyStringTest
# - exitAfterDefer
# - ifElseChain
# - hugeParam
# - importShadow
# - nestingReduce
# - paramTypeCombine
# - ptrToRefParam
# - rangeValCopy
# - singleCaseSwitch
# - sloppyReassign
# - unlabelStmt
# - unnamedResult
# - wrapperFunc
issues:
# List of regexps of issue texts to exclude, empty list by default.
# But independently from this option we use default exclude patterns,
# it can be disabled by `exclude-use-default: false`. To list all
# excluded by default patterns execute `golangci-lint run --help`
exclude:
- composite literal uses unkeyed fields
exclude-rules:
# Exclude some linters from running on test files.
- path: _test\.go$|^tests/|^samples/
linters:
- errcheck
- maligned
# Independently from option `exclude` we use default exclude patterns,
# it can be disabled by this option. To list all
# excluded by default patterns execute `golangci-lint run --help`.
# Default value for this option is true.
exclude-use-default: true
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
max-per-linter: 0
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
max-same-issues: 0

View File

@@ -0,0 +1,469 @@
package webhook
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"strings"
"github.com/golang/glog"
"k8s.io/api/admission/v1beta1"
admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1"
v1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
)
const (
// binVolumeName is the name of the volume where the OP CLI binary is stored.
binVolumeName = "op-bin"
// binVolumeMountPath is the mount path where the OP CLI binary can be found.
binVolumeMountPath = "/op/bin/"
)
// binVolume is the shared, in-memory volume where the OP CLI binary lives.
var binVolume = corev1.Volume{
Name: binVolumeName,
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{
Medium: corev1.StorageMediumMemory,
},
},
}
// binVolumeMount is the shared volume mount where the OP CLI binary lives.
var binVolumeMount = corev1.VolumeMount{
Name: binVolumeName,
MountPath: binVolumeMountPath,
ReadOnly: true,
}
var (
runtimeScheme = runtime.NewScheme()
codecs = serializer.NewCodecFactory(runtimeScheme)
deserializer = codecs.UniversalDeserializer()
// (https://github.com/kubernetes/kubernetes/issues/57982)
defaulter = runtime.ObjectDefaulter(runtimeScheme)
)
var ignoredNamespaces = []string{
metav1.NamespaceSystem,
metav1.NamespacePublic,
}
const (
injectionStatus = "operator.1password.io/status"
injectAnnotation = "operator.1password.io/inject"
versionAnnotation = "operator.1password.io/version"
)
type WebhookServer struct {
Config Config
Server *http.Server
}
// Webhook Server parameters
type WebhookServerParameters struct {
Port int // webhook server port
CertFile string // path to the x509 certificate for https
KeyFile string // path to the x509 private key matching `CertFile`
}
type Config struct {
ConnectHost string
ConnectTokenName string
ConnectTokenKey string
}
type patchOperation struct {
Op string `json:"op"`
Path string `json:"path"`
Value interface{} `json:"value,omitempty"`
}
func init() {
_ = corev1.AddToScheme(runtimeScheme)
_ = admissionregistrationv1beta1.AddToScheme(runtimeScheme)
_ = v1.AddToScheme(runtimeScheme)
}
func applyDefaultsWorkaround(containers []corev1.Container, volumes []corev1.Volume) {
defaulter.Default(&corev1.Pod{
Spec: corev1.PodSpec{
Containers: containers,
Volumes: volumes,
},
})
}
// Check whether the target resoured need to be mutated
func mutationRequired(ignoredList []string, metadata *metav1.ObjectMeta) bool {
// skip special kubernete system namespaces
for _, namespace := range ignoredList {
if metadata.Namespace == namespace {
glog.Infof("Skip mutation for %v for it's in special namespace:%v", metadata.Name, metadata.Namespace)
return false
}
}
annotations := metadata.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
status := annotations[injectionStatus]
_, enabled := annotations[injectAnnotation]
// determine whether to perform mutation based on annotation for the target resource
required := false
if strings.ToLower(status) != "injected" && enabled {
required = true
}
glog.Infof("Mutation policy for %v/%v: status: %q required:%v", metadata.Namespace, metadata.Name, status, required)
return required
}
func addContainers(target, added []corev1.Container, basePath string) (patch []patchOperation) {
first := len(target) == 0
var value interface{}
for _, add := range added {
value = add
path := basePath
if first {
first = false
value = []corev1.Container{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
func addVolume(target, added []corev1.Volume, basePath string) (patch []patchOperation) {
first := len(target) == 0
var value interface{}
for _, add := range added {
value = add
path := basePath
if first {
first = false
value = []corev1.Volume{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
func updateAnnotation(target map[string]string, added map[string]string) (patch []patchOperation) {
for key, value := range added {
if target == nil || target[key] == "" {
target = map[string]string{}
patch = append(patch, patchOperation{
Op: "add",
Path: "/metadata/annotations",
Value: map[string]string{
key: value,
},
})
} else {
patch = append(patch, patchOperation{
Op: "replace",
Path: "/metadata/annotations/" + key,
Value: value,
})
}
}
return patch
}
// main mutation process
func (whsvr *WebhookServer) mutate(ar *v1beta1.AdmissionReview) *v1beta1.AdmissionResponse {
ctx := context.Background()
req := ar.Request
var pod corev1.Pod
if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
glog.Errorf("Could not unmarshal raw object: %v", err)
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
glog.Infof("AdmissionReview for Kind=%v, Namespace=%v Name=%v (%v) UID=%v patchOperation=%v UserInfo=%v",
req.Kind, req.Namespace, req.Name, pod.Name, req.UID, req.Operation, req.UserInfo)
// determine whether to perform mutation
if !mutationRequired(ignoredNamespaces, &pod.ObjectMeta) {
glog.Infof("Skipping mutation for %s/%s due to policy check", pod.Namespace, pod.Name)
return &v1beta1.AdmissionResponse{
Allowed: true,
}
}
containersStr := pod.Annotations[injectAnnotation]
containers := map[string]struct{}{}
for _, container := range strings.Split(containersStr, ",") {
containers[container] = struct{}{}
}
version, ok := pod.Annotations[versionAnnotation]
if !ok {
version = "latest"
}
mutated := false
var patch []patchOperation
for i, c := range pod.Spec.InitContainers {
_, mutate := containers[c.Name]
if !mutate {
continue
}
c, didMutate, initContainerPatch, err := whsvr.mutateContainer(ctx, &c, i)
if err != nil {
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
if didMutate {
mutated = true
pod.Spec.InitContainers[i] = *c
}
patch = append(patch, initContainerPatch...)
}
for i, c := range pod.Spec.Containers {
_, mutate := containers[c.Name]
if !mutate {
continue
}
c, didMutate, containerPatch, err := whsvr.mutateContainer(ctx, &c, i)
if err != nil {
glog.Errorf("Error occured mutating container: ", err)
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
patch = append(patch, containerPatch...)
if didMutate {
mutated = true
pod.Spec.Containers[i] = *c
}
}
// binInitContainer is the container that pulls the OP CLI
// into a shared volume mount.
var binInitContainer = corev1.Container{
Name: "copy-op-bin",
Image: "op-example" + ":" + version,
ImagePullPolicy: corev1.PullIfNotPresent,
Command: []string{"sh", "-c",
fmt.Sprintf("cp /usr/local/bin/op %s", binVolumeMountPath)},
VolumeMounts: []corev1.VolumeMount{
{
Name: binVolumeName,
MountPath: binVolumeMountPath,
},
},
}
if !mutated {
glog.Infof("No mutations made for %s/%s", pod.Namespace, pod.Name)
return &v1beta1.AdmissionResponse{
Allowed: true,
}
}
annotations := map[string]string{injectionStatus: "injected"}
patchBytes, err := createOPCLIPatch(&pod, annotations, []corev1.Container{binInitContainer}, patch)
if err != nil {
return &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
}
glog.Infof("AdmissionResponse: patch=%v\n", string(patchBytes))
return &v1beta1.AdmissionResponse{
Allowed: true,
Patch: patchBytes,
PatchType: func() *v1beta1.PatchType {
pt := v1beta1.PatchTypeJSONPatch
return &pt
}(),
}
}
// create mutation patch for resoures
func createOPCLIPatch(pod *corev1.Pod, annotations map[string]string, containers []corev1.Container, patch []patchOperation) ([]byte, error) {
patch = append(patch, addVolume(pod.Spec.Volumes, []corev1.Volume{binVolume}, "/spec/volumes")...)
patch = append(patch, addContainers(pod.Spec.InitContainers, containers, "/spec/initContainers")...)
patch = append(patch, updateAnnotation(pod.Annotations, annotations)...)
return json.Marshal(patch)
}
func createOPConnectPatch(container *corev1.Container, containerIndex int, host, tokenSecretName, tokenSecretKey string) []patchOperation {
var patch []patchOperation
connectHostEnvVar := corev1.EnvVar{
Name: "OP_CONNECT_HOST",
Value: host,
}
connectTokenEnvVar := corev1.EnvVar{
Name: "OP_CONNECT_TOKEN",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
Key: tokenSecretKey,
LocalObjectReference: corev1.LocalObjectReference{
Name: tokenSecretName,
},
},
},
}
envs := []corev1.EnvVar{
connectHostEnvVar,
connectTokenEnvVar,
}
patch = append(patch, setEnvironment(*container, containerIndex, envs, "/spec/containers")...)
return patch
}
func (whsvr *WebhookServer) mutateContainer(_ context.Context, container *corev1.Container, containerIndex int) (*corev1.Container, bool, []patchOperation, error) {
// Because we are running a command in the pod before starting the container app,
// we need to prepend the pod comand with the op run command
if len(container.Command) == 0 {
return container, false, nil, fmt.Errorf("not attaching OP to the container %s: the podspec does not define a command", container.Name)
}
// Prepend the command with op run --
container.Command = append([]string{binVolumeMountPath + "op", "run", "--"}, container.Command...)
var patch []patchOperation
// adding the cli to the container using a volume mount
path := fmt.Sprintf("%s/%d/volumeMounts", "/spec/containers", containerIndex)
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: []corev1.VolumeMount{binVolumeMount},
})
// replacing the container command with a command prepended with op run
path = fmt.Sprintf("%s/%d/command", "/spec/containers", containerIndex)
patch = append(patch, patchOperation{
Op: "replace",
Path: path,
Value: container.Command,
})
//creating patch for adding conenct environment variables to container
patch = append(patch, createOPConnectPatch(container, containerIndex, whsvr.Config.ConnectHost, whsvr.Config.ConnectTokenName, whsvr.Config.ConnectTokenKey)...)
return container, true, patch, nil
}
func setEnvironment(container corev1.Container, containerIndex int, addedEnv []corev1.EnvVar, basePath string) (patch []patchOperation) {
first := len(container.Env) == 0
var value interface{}
for _, add := range addedEnv {
path := fmt.Sprintf("%s/%d/env", basePath, containerIndex)
value = add
if first {
first = false
value = []corev1.EnvVar{add}
} else {
path = path + "/-"
}
patch = append(patch, patchOperation{
Op: "add",
Path: path,
Value: value,
})
}
return patch
}
// Serve method for webhook server
func (whsvr *WebhookServer) Serve(w http.ResponseWriter, r *http.Request) {
var body []byte
if r.Body != nil {
if data, err := ioutil.ReadAll(r.Body); err == nil {
body = data
}
}
if len(body) == 0 {
glog.Error("empty body")
http.Error(w, "empty body", http.StatusBadRequest)
return
}
// verify the content type is accurate
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
glog.Errorf("Content-Type=%s, expect application/json", contentType)
http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType)
return
}
var admissionResponse *v1beta1.AdmissionResponse
ar := v1beta1.AdmissionReview{}
if _, _, err := deserializer.Decode(body, nil, &ar); err != nil {
glog.Errorf("Can't decode body: %v", err)
admissionResponse = &v1beta1.AdmissionResponse{
Result: &metav1.Status{
Message: err.Error(),
},
}
} else {
admissionResponse = whsvr.mutate(&ar)
}
admissionReview := v1beta1.AdmissionReview{}
if admissionResponse != nil {
admissionReview.Response = admissionResponse
if ar.Request != nil {
admissionReview.Response.UID = ar.Request.UID
}
}
resp, err := json.Marshal(admissionReview)
if err != nil {
glog.Errorf("Can't encode response: %v", err)
http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError)
}
glog.Infof("Ready to write reponse ...")
if _, err := w.Write(resp); err != nil {
glog.Errorf("Can't write response: %v", err)
http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,9 @@
FROM ubuntu:latest
ARG VERSION
RUN apt-get update && apt-get install -y curl unzip jq && \
curl -o 1password.zip https://bucket.agilebits.com/cli-private-beta/v2/op_linux_amd64_v2-alpha2.zip && \
unzip 1password.zip -d /usr/local/bin && \
rm 1password.zip
CMD ["op"]