controllers/integrationsecret/integrationsecret_controller.go (259 lines of code) (raw):
package integrationsecret
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
corev1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
const (
integrationSecretLabel = "app.edp.epam.com/integration-secret"
integrationSecretTypeLabel = "app.edp.epam.com/secret-type"
integrationSecretConnectionAnnotation = "app.edp.epam.com/integration-secret-connected"
integrationSecretErrorAnnotation = "app.edp.epam.com/integration-secret-error"
successConnectionRequeueTime = time.Minute * 30
failConnectionRequeueTime = time.Minute * 1
logKeyUrl = "url"
)
type ReconcileIntegrationSecret struct {
client client.Client
}
func NewReconcileIntegrationSecret(k8sClient client.Client) *ReconcileIntegrationSecret {
return &ReconcileIntegrationSecret{client: k8sClient}
}
func (r *ReconcileIntegrationSecret) SetupWithManager(mgr ctrl.Manager) error {
p := predicate.Funcs{
CreateFunc: func(event event.CreateEvent) bool {
return hasIntegrationSecretLabelLabel(event.Object)
},
DeleteFunc: func(deleteEvent event.DeleteEvent) bool {
return false
},
UpdateFunc: func(updateEvent event.UpdateEvent) bool {
return hasIntegrationSecretLabelLabel(updateEvent.ObjectNew)
},
GenericFunc: func(genericEvent event.GenericEvent) bool {
return hasIntegrationSecretLabelLabel(genericEvent.Object)
},
}
err := ctrl.NewControllerManagedBy(mgr).
For(&corev1.Secret{}, builder.WithPredicates(p)).
Complete(r)
if err != nil {
return fmt.Errorf("failed to build IntegrationSecret controller: %w", err)
}
return nil
}
//+kubebuilder:rbac:groups="",namespace=placeholder,resources=secrets,verbs=get;list;watch;update;patch
// Reconcile reads secrets with integration-secret label and set connection status to the annotation.
func (r *ReconcileIntegrationSecret) Reconcile(ctx context.Context, request reconcile.Request) (reconcile.Result, error) {
secret := &corev1.Secret{}
if err := r.client.Get(ctx, request.NamespacedName, secret); err != nil {
if k8sErrors.IsNotFound(err) {
return reconcile.Result{}, nil
}
return reconcile.Result{}, fmt.Errorf("failed to get Secret: %w", err)
}
log := ctrl.LoggerFrom(ctx)
log.Info("Start checking connection")
err := checkConnection(ctx, secret)
reachable := err == nil
errMess := ""
if err != nil {
log.Info("Connection failed", "error", err.Error())
errMess = fmt.Sprintf("connection failed: %s", err.Error())
}
if err = r.updateConnectionAnnotation(ctx, secret, reachable, errMess); err != nil {
return reconcile.Result{}, err
}
requeue := successConnectionRequeueTime
if !reachable {
requeue = failConnectionRequeueTime
}
log.Info("Reconciling IntegrationSecret has been finished")
return reconcile.Result{
RequeueAfter: requeue,
}, nil
}
func (r *ReconcileIntegrationSecret) updateConnectionAnnotation(ctx context.Context, secret *corev1.Secret, reachable bool, errMess string) error {
log := ctrl.LoggerFrom(ctx)
if secret.GetAnnotations()[integrationSecretConnectionAnnotation] != strconv.FormatBool(reachable) ||
secret.GetAnnotations()[integrationSecretConnectionAnnotation] != errMess {
log.Info("Updating Secret connection status")
if secret.GetAnnotations() == nil {
secret.SetAnnotations(map[string]string{})
}
secret.GetAnnotations()[integrationSecretConnectionAnnotation] = strconv.FormatBool(reachable)
delete(secret.GetAnnotations(), integrationSecretErrorAnnotation)
if errMess != "" {
secret.GetAnnotations()[integrationSecretErrorAnnotation] = errMess
}
if err := r.client.Update(ctx, secret); err != nil {
return fmt.Errorf("failed to update Secret: %w", err)
}
}
return nil
}
func checkConnection(ctx context.Context, secret *corev1.Secret) error {
var (
path string
req *resty.Request
)
switch secret.GetLabels()[integrationSecretTypeLabel] {
case "sonar":
path = "/api/system/ping"
req = newRequest(ctx, string(secret.Data["url"])).SetBasicAuth(string(secret.Data["token"]), "")
case "nexus":
path = "/service/rest/v1/status"
req = newRequestWithAuth(ctx, secret)
case "dependency-track":
path = "/api/v1/team/self"
req = newRequest(ctx, string(secret.Data["url"])).SetHeader("X-Api-Key", string(secret.Data["token"]))
case "defectdojo":
path = "/api/v2/user_profile"
req = newRequest(ctx, string(secret.Data["url"])).SetHeader("Authorization", "Token "+string(secret.Data["token"]))
case "registry":
return checkRegistry(ctx, secret)
case "argocd":
path = "/api/v1/projects"
req = newRequest(ctx, string(secret.Data["url"])).SetHeader("Authorization", "Bearer "+string(secret.Data["token"]))
case "chat-assistant":
return checkCodemie(ctx, secret)
default:
path = "/"
req = newRequest(ctx, string(secret.Data["url"]))
}
log := ctrl.LoggerFrom(ctx).WithValues(logKeyUrl, req.URL+path)
log.Info("Making request")
resp, err := req.Get(path)
if err != nil {
return fmt.Errorf("%w", err)
}
if !resp.IsSuccess() {
return fmt.Errorf("http status code %s", resp.Status())
}
return nil
}
func newRequestWithAuth(ctx context.Context, secret *corev1.Secret) *resty.Request {
r := newRequest(ctx, string(secret.Data["url"]))
if _, ok := secret.Data["username"]; ok {
return r.SetBasicAuth(string(secret.Data["username"]), string(secret.Data["password"]))
}
return r.SetAuthToken(string(secret.Data["token"]))
}
func newRequest(ctx context.Context, url string) *resty.Request {
return resty.New().
SetTLSClientConfig(&tls.Config{InsecureSkipVerify: true}).
SetHostURL(url).
SetTimeout(time.Second * 5).
R().
SetContext(ctx)
}
type registryAuth struct {
Username string `json:"username"`
Password string `json:"password"`
}
type registryConfig struct {
Auths map[string]registryAuth `json:"auths"`
}
func checkRegistry(ctx context.Context, secret *corev1.Secret) error {
rawConf := secret.Data[".dockerconfigjson"]
if len(rawConf) == 0 {
return fmt.Errorf("no .dockerconfigjson key in secret %s", secret.Name)
}
var conf registryConfig
if err := json.Unmarshal(rawConf, &conf); err != nil {
return fmt.Errorf("failed to unmarshal .dockerconfigjson: %w", err)
}
for url, auth := range conf.Auths {
// for docker hub we need to use custom endpoint
// see https://github.com/GoogleContainerTools/kaniko/blob/v1.19.0/README.md?plain=1#L540
if url == "https://index.docker.io/v1/" {
return checkDockerHub(ctx, auth.Username, auth.Password)
}
if !strings.HasPrefix(url, "https://") {
url = "https://" + url
}
if strings.HasPrefix(url, "https://ghcr.io") {
return checkGitHubRegistry(ctx, auth, url)
}
log := ctrl.LoggerFrom(ctx).WithValues(logKeyUrl, url+"/v2/")
log.Info("Making request")
// docker registry specification endpoint https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#endpoints
resp, err := newRequest(ctx, url).SetBasicAuth(auth.Username, auth.Password).Get("/v2/")
if err != nil {
return fmt.Errorf("%w", err)
}
if !resp.IsSuccess() {
return fmt.Errorf("http status code %s", resp.Status())
}
return nil
}
return errors.New("no auths in .dockerconfigjson")
}
func checkDockerHub(ctx context.Context, username, password string) error {
log := ctrl.LoggerFrom(ctx).WithValues(logKeyUrl, "https://hub.docker.com/v2")
log.Info("Making request")
resp, err := newRequest(ctx, "https://hub.docker.com").
SetHeader("Content-Type", "application/json").
SetBody(map[string]string{
"username": username,
"password": password,
}).
Post("/v2/users/login")
if err != nil {
return fmt.Errorf("%w", err)
}
if !resp.IsSuccess() {
return fmt.Errorf("http status code %s", resp.Status())
}
return nil
}
func checkGitHubRegistry(ctx context.Context, auth registryAuth, url string) error {
log := ctrl.LoggerFrom(ctx).WithValues(logKeyUrl, url)
log.Info("Making request to GitHub registry")
resp, err := newRequest(ctx, url).
SetHeader("Content-Type", "application/json").
SetAuthToken(base64.StdEncoding.EncodeToString([]byte(auth.Password))).
Get("/v2/_catalog")
if err != nil {
return fmt.Errorf("failed to connect to GitHub registry %w", err)
}
if !resp.IsSuccess() {
return fmt.Errorf("GitHub registry http status code %s", resp.Status())
}
return nil
}
func hasIntegrationSecretLabelLabel(object client.Object) bool {
label := object.GetLabels()[integrationSecretLabel]
return label == "true"
}
func checkCodemie(ctx context.Context, secret *corev1.Secret) error {
log := ctrl.LoggerFrom(ctx).WithValues(logKeyUrl, string(secret.Data["apiUrl"])+"/v1/user")
log.Info("Making request to Codemie")
res := make(map[string]interface{})
resp, err := newRequest(ctx, string(secret.Data["apiUrl"])).
SetAuthToken(string(secret.Data["token"])).
ForceContentType("application/json").
SetResult(&res).
Get("/v1/user")
if err != nil {
log.Error(err, "Failed to connect to Codemie")
return fmt.Errorf("failed to connect to Codemie")
}
if !resp.IsSuccess() {
return fmt.Errorf("codemie http status code %s", resp.Status())
}
// If token is invalid, Codemie API returns 200 status code.
// For this reason, we need to check response body.
if _, ok := res["userId"]; !ok {
return errors.New("failed to connect to Codemie")
}
return nil
}