feat: reload execution and observability

This commit is contained in:
TheiLLeniumStudios
2025-12-28 08:47:52 +01:00
parent ce1e7dfafb
commit b5df945fff
8 changed files with 1827 additions and 21 deletions
+283
View File
@@ -0,0 +1,283 @@
// Package metadata provides metadata ConfigMap creation for Reloader.
// The metadata ConfigMap contains build info, configuration options, and deployment info.
package metadata
import (
"context"
"encoding/json"
"fmt"
"os"
"runtime"
"time"
"github.com/sirupsen/logrus"
"github.com/stakater/Reloader/internal/pkg/config"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)
const (
// ConfigMapName is the name of the metadata ConfigMap.
ConfigMapName = "reloader-meta-info"
// ConfigMapLabelKey is the label key for the metadata ConfigMap.
ConfigMapLabelKey = "reloader.stakater.com/meta-info"
// ConfigMapLabelValue is the label value for the metadata ConfigMap.
ConfigMapLabelValue = "reloader-oss"
// FieldManager is the field manager name for server-side apply.
FieldManager = "reloader"
// Environment variables for deployment info.
EnvReloaderNamespace = "RELOADER_NAMESPACE"
EnvReloaderDeploymentName = "RELOADER_DEPLOYMENT_NAME"
)
// Version, Commit, and BuildDate are set during the build process
// using the -X linker flag to inject these values into the binary.
var (
Version = "dev"
Commit = "unknown"
BuildDate = "unknown"
)
// MetaInfo contains comprehensive metadata about the Reloader instance.
type MetaInfo struct {
// BuildInfo contains information about the build version, commit, and compilation details.
BuildInfo BuildInfo `json:"buildInfo"`
// ReloaderOptions contains all the configuration options used by this Reloader instance.
ReloaderOptions ReloaderOptions `json:"reloaderOptions"`
// DeploymentInfo contains metadata about the Kubernetes deployment of this instance.
DeploymentInfo DeploymentInfo `json:"deploymentInfo"`
}
// BuildInfo contains information about the build and version of the Reloader binary.
type BuildInfo struct {
// GoVersion is the version of Go used to compile the binary.
GoVersion string `json:"goVersion"`
// ReleaseVersion is the version tag or branch of the Reloader release.
ReleaseVersion string `json:"releaseVersion"`
// CommitHash is the Git commit hash of the source code used to build this binary.
CommitHash string `json:"commitHash"`
// CommitTime is the timestamp of the Git commit used to build this binary.
CommitTime time.Time `json:"commitTime"`
}
// DeploymentInfo contains metadata about the Reloader deployment.
type DeploymentInfo struct {
// Name is the name of the Reloader deployment.
Name string `json:"name"`
// Namespace is the namespace where Reloader is deployed.
Namespace string `json:"namespace"`
}
// ReloaderOptions contains the configuration options for Reloader.
// This is a subset of config.Config that's relevant for the metadata ConfigMap.
type ReloaderOptions struct {
// AutoReloadAll enables automatic reloading of all resources.
AutoReloadAll bool `json:"autoReloadAll"`
// ReloadStrategy specifies the strategy used to trigger resource reloads.
ReloadStrategy string `json:"reloadStrategy"`
// IsArgoRollouts indicates whether support for Argo Rollouts is enabled.
IsArgoRollouts bool `json:"isArgoRollouts"`
// ReloadOnCreate indicates whether to trigger reloads when resources are created.
ReloadOnCreate bool `json:"reloadOnCreate"`
// ReloadOnDelete indicates whether to trigger reloads when resources are deleted.
ReloadOnDelete bool `json:"reloadOnDelete"`
// SyncAfterRestart indicates whether to sync add events after Reloader restarts.
SyncAfterRestart bool `json:"syncAfterRestart"`
// EnableHA indicates whether High Availability mode is enabled.
EnableHA bool `json:"enableHA"`
// WebhookURL is the URL to send webhook notifications to.
WebhookURL string `json:"webhookUrl"`
// LogFormat specifies the log format to use.
LogFormat string `json:"logFormat"`
// LogLevel specifies the log level to use.
LogLevel string `json:"logLevel"`
// ResourcesToIgnore is a list of resource types to ignore.
ResourcesToIgnore []string `json:"resourcesToIgnore"`
// WorkloadTypesToIgnore is a list of workload types to ignore.
WorkloadTypesToIgnore []string `json:"workloadTypesToIgnore"`
// NamespacesToIgnore is a list of namespaces to ignore.
NamespacesToIgnore []string `json:"namespacesToIgnore"`
// NamespaceSelectors is a list of namespace label selectors.
NamespaceSelectors []string `json:"namespaceSelectors"`
// ResourceSelectors is a list of resource label selectors.
ResourceSelectors []string `json:"resourceSelectors"`
// Annotations
ConfigmapUpdateOnChangeAnnotation string `json:"configmapUpdateOnChangeAnnotation"`
SecretUpdateOnChangeAnnotation string `json:"secretUpdateOnChangeAnnotation"`
ReloaderAutoAnnotation string `json:"reloaderAutoAnnotation"`
ConfigmapReloaderAutoAnnotation string `json:"configmapReloaderAutoAnnotation"`
SecretReloaderAutoAnnotation string `json:"secretReloaderAutoAnnotation"`
IgnoreResourceAnnotation string `json:"ignoreResourceAnnotation"`
ConfigmapExcludeReloaderAnnotation string `json:"configmapExcludeReloaderAnnotation"`
SecretExcludeReloaderAnnotation string `json:"secretExcludeReloaderAnnotation"`
AutoSearchAnnotation string `json:"autoSearchAnnotation"`
SearchMatchAnnotation string `json:"searchMatchAnnotation"`
RolloutStrategyAnnotation string `json:"rolloutStrategyAnnotation"`
PauseDeploymentAnnotation string `json:"pauseDeploymentAnnotation"`
PauseDeploymentTimeAnnotation string `json:"pauseDeploymentTimeAnnotation"`
}
// NewBuildInfo creates a new BuildInfo with current build information.
func NewBuildInfo() BuildInfo {
return BuildInfo{
GoVersion: runtime.Version(),
ReleaseVersion: Version,
CommitHash: Commit,
CommitTime: parseUTCTime(BuildDate),
}
}
// NewReloaderOptions creates ReloaderOptions from a Config.
func NewReloaderOptions(cfg *config.Config) ReloaderOptions {
return ReloaderOptions{
AutoReloadAll: cfg.AutoReloadAll,
ReloadStrategy: string(cfg.ReloadStrategy),
IsArgoRollouts: cfg.ArgoRolloutsEnabled,
ReloadOnCreate: cfg.ReloadOnCreate,
ReloadOnDelete: cfg.ReloadOnDelete,
SyncAfterRestart: cfg.SyncAfterRestart,
EnableHA: cfg.EnableHA,
WebhookURL: cfg.WebhookURL,
LogFormat: cfg.LogFormat,
LogLevel: cfg.LogLevel,
ResourcesToIgnore: cfg.IgnoredResources,
WorkloadTypesToIgnore: cfg.IgnoredWorkloads,
NamespacesToIgnore: cfg.IgnoredNamespaces,
NamespaceSelectors: cfg.NamespaceSelectorStrings,
ResourceSelectors: cfg.ResourceSelectorStrings,
ConfigmapUpdateOnChangeAnnotation: cfg.Annotations.ConfigmapReload,
SecretUpdateOnChangeAnnotation: cfg.Annotations.SecretReload,
ReloaderAutoAnnotation: cfg.Annotations.Auto,
ConfigmapReloaderAutoAnnotation: cfg.Annotations.ConfigmapAuto,
SecretReloaderAutoAnnotation: cfg.Annotations.SecretAuto,
IgnoreResourceAnnotation: cfg.Annotations.Ignore,
ConfigmapExcludeReloaderAnnotation: cfg.Annotations.ConfigmapExclude,
SecretExcludeReloaderAnnotation: cfg.Annotations.SecretExclude,
AutoSearchAnnotation: cfg.Annotations.Search,
SearchMatchAnnotation: cfg.Annotations.Match,
RolloutStrategyAnnotation: cfg.Annotations.RolloutStrategy,
PauseDeploymentAnnotation: cfg.Annotations.PausePeriod,
PauseDeploymentTimeAnnotation: cfg.Annotations.PausedAt,
}
}
// NewMetaInfo creates a new MetaInfo from configuration.
func NewMetaInfo(cfg *config.Config) *MetaInfo {
return &MetaInfo{
BuildInfo: NewBuildInfo(),
ReloaderOptions: NewReloaderOptions(cfg),
DeploymentInfo: DeploymentInfo{
Name: os.Getenv(EnvReloaderDeploymentName),
Namespace: os.Getenv(EnvReloaderNamespace),
},
}
}
// ToConfigMap converts MetaInfo to a Kubernetes ConfigMap.
func (m *MetaInfo) ToConfigMap() *corev1.ConfigMap {
return &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: ConfigMapName,
Namespace: m.DeploymentInfo.Namespace,
Labels: map[string]string{
ConfigMapLabelKey: ConfigMapLabelValue,
},
},
Data: map[string]string{
"buildInfo": toJSON(m.BuildInfo),
"reloaderOptions": toJSON(m.ReloaderOptions),
"deploymentInfo": toJSON(m.DeploymentInfo),
},
}
}
// Publisher handles creating and updating the metadata ConfigMap.
type Publisher struct {
client client.Client
cfg *config.Config
}
// NewPublisher creates a new Publisher.
func NewPublisher(c client.Client, cfg *config.Config) *Publisher {
return &Publisher{
client: c,
cfg: cfg,
}
}
// Publish creates or updates the metadata ConfigMap.
// Returns an error if the operation fails, or nil on success.
// If RELOADER_NAMESPACE is not set, this is a no-op.
func (p *Publisher) Publish(ctx context.Context) error {
namespace := os.Getenv(EnvReloaderNamespace)
if namespace == "" {
logrus.Warn("RELOADER_NAMESPACE is not set, skipping meta info configmap creation")
return nil
}
metaInfo := NewMetaInfo(p.cfg)
configMap := metaInfo.ToConfigMap()
// Try to get existing ConfigMap
existing := &corev1.ConfigMap{}
err := p.client.Get(ctx, client.ObjectKey{
Name: ConfigMapName,
Namespace: namespace,
}, existing)
if err != nil {
if !errors.IsNotFound(err) {
return fmt.Errorf("failed to get existing meta info configmap: %w", err)
}
// ConfigMap doesn't exist, create it
logrus.Info("Creating meta info configmap")
if err := p.client.Create(ctx, configMap, client.FieldOwner(FieldManager)); err != nil {
return fmt.Errorf("failed to create meta info configmap: %w", err)
}
logrus.Info("Meta info configmap created successfully")
return nil
}
// ConfigMap exists, update it
logrus.Info("Meta info configmap already exists, updating it")
existing.Data = configMap.Data
existing.Labels = configMap.Labels
if err := p.client.Update(ctx, existing, client.FieldOwner(FieldManager)); err != nil {
return fmt.Errorf("failed to update meta info configmap: %w", err)
}
logrus.Info("Meta info configmap updated successfully")
return nil
}
// PublishMetaInfoConfigMap is a convenience function that creates a Publisher and calls Publish.
// This provides a simple API similar to the v1 PublishMetaInfoConfigmap function.
func PublishMetaInfoConfigMap(ctx context.Context, c client.Client, cfg *config.Config) error {
publisher := NewPublisher(c, cfg)
return publisher.Publish(ctx)
}
// toJSON marshals data to JSON string. Returns empty string on error.
func toJSON(data interface{}) string {
jsonData, err := json.Marshal(data)
if err != nil {
return ""
}
return string(jsonData)
}
// parseUTCTime parses a time string in RFC3339 format.
// Returns zero time if value is empty or parsing fails.
func parseUTCTime(value string) time.Time {
if value == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}
}
return t
}
+330
View File
@@ -0,0 +1,330 @@
package metadata
import (
"context"
"encoding/json"
"os"
"testing"
"github.com/stakater/Reloader/internal/pkg/config"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func TestNewBuildInfo(t *testing.T) {
// Set build variables for testing
oldVersion := Version
oldCommit := Commit
oldBuildDate := BuildDate
defer func() {
Version = oldVersion
Commit = oldCommit
BuildDate = oldBuildDate
}()
Version = "1.0.0"
Commit = "abc123"
BuildDate = "2024-01-01T12:00:00Z"
info := NewBuildInfo()
if info.ReleaseVersion != "1.0.0" {
t.Errorf("ReleaseVersion = %s, want 1.0.0", info.ReleaseVersion)
}
if info.CommitHash != "abc123" {
t.Errorf("CommitHash = %s, want abc123", info.CommitHash)
}
if info.GoVersion == "" {
t.Error("GoVersion should not be empty")
}
if info.CommitTime.IsZero() {
t.Error("CommitTime should not be zero")
}
}
func TestNewReloaderOptions(t *testing.T) {
cfg := config.NewDefault()
cfg.AutoReloadAll = true
cfg.ReloadStrategy = config.ReloadStrategyAnnotations
cfg.ArgoRolloutsEnabled = true
cfg.ReloadOnCreate = true
cfg.ReloadOnDelete = true
cfg.EnableHA = true
cfg.WebhookURL = "https://example.com/webhook"
cfg.LogFormat = "json"
cfg.LogLevel = "debug"
cfg.IgnoredResources = []string{"configmaps"}
cfg.IgnoredWorkloads = []string{"jobs"}
cfg.IgnoredNamespaces = []string{"kube-system"}
opts := NewReloaderOptions(cfg)
if !opts.AutoReloadAll {
t.Error("AutoReloadAll should be true")
}
if opts.ReloadStrategy != "annotations" {
t.Errorf("ReloadStrategy = %s, want annotations", opts.ReloadStrategy)
}
if !opts.IsArgoRollouts {
t.Error("IsArgoRollouts should be true")
}
if !opts.ReloadOnCreate {
t.Error("ReloadOnCreate should be true")
}
if !opts.ReloadOnDelete {
t.Error("ReloadOnDelete should be true")
}
if !opts.EnableHA {
t.Error("EnableHA should be true")
}
if opts.WebhookURL != "https://example.com/webhook" {
t.Errorf("WebhookURL = %s, want https://example.com/webhook", opts.WebhookURL)
}
if opts.LogFormat != "json" {
t.Errorf("LogFormat = %s, want json", opts.LogFormat)
}
if opts.LogLevel != "debug" {
t.Errorf("LogLevel = %s, want debug", opts.LogLevel)
}
if len(opts.ResourcesToIgnore) != 1 || opts.ResourcesToIgnore[0] != "configmaps" {
t.Errorf("ResourcesToIgnore = %v, want [configmaps]", opts.ResourcesToIgnore)
}
if len(opts.WorkloadTypesToIgnore) != 1 || opts.WorkloadTypesToIgnore[0] != "jobs" {
t.Errorf("WorkloadTypesToIgnore = %v, want [jobs]", opts.WorkloadTypesToIgnore)
}
if len(opts.NamespacesToIgnore) != 1 || opts.NamespacesToIgnore[0] != "kube-system" {
t.Errorf("NamespacesToIgnore = %v, want [kube-system]", opts.NamespacesToIgnore)
}
// Check annotations
if opts.ReloaderAutoAnnotation != "reloader.stakater.com/auto" {
t.Errorf("ReloaderAutoAnnotation = %s, want reloader.stakater.com/auto", opts.ReloaderAutoAnnotation)
}
}
func TestMetaInfo_ToConfigMap(t *testing.T) {
// Set environment variables
os.Setenv(EnvReloaderNamespace, "reloader-ns")
os.Setenv(EnvReloaderDeploymentName, "reloader-deploy")
defer func() {
os.Unsetenv(EnvReloaderNamespace)
os.Unsetenv(EnvReloaderDeploymentName)
}()
cfg := config.NewDefault()
metaInfo := NewMetaInfo(cfg)
cm := metaInfo.ToConfigMap()
if cm.Name != ConfigMapName {
t.Errorf("Name = %s, want %s", cm.Name, ConfigMapName)
}
if cm.Namespace != "reloader-ns" {
t.Errorf("Namespace = %s, want reloader-ns", cm.Namespace)
}
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
t.Errorf("Label = %s, want %s", cm.Labels[ConfigMapLabelKey], ConfigMapLabelValue)
}
// Check data fields exist
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing")
}
if _, ok := cm.Data["reloaderOptions"]; !ok {
t.Error("reloaderOptions data key missing")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing")
}
// Verify buildInfo is valid JSON
var buildInfo BuildInfo
if err := json.Unmarshal([]byte(cm.Data["buildInfo"]), &buildInfo); err != nil {
t.Errorf("buildInfo is not valid JSON: %v", err)
}
// Verify deploymentInfo contains expected values
var deployInfo DeploymentInfo
if err := json.Unmarshal([]byte(cm.Data["deploymentInfo"]), &deployInfo); err != nil {
t.Errorf("deploymentInfo is not valid JSON: %v", err)
}
if deployInfo.Namespace != "reloader-ns" {
t.Errorf("DeploymentInfo.Namespace = %s, want reloader-ns", deployInfo.Namespace)
}
if deployInfo.Name != "reloader-deploy" {
t.Errorf("DeploymentInfo.Name = %s, want reloader-deploy", deployInfo.Name)
}
}
func TestPublisher_Publish_NoNamespace(t *testing.T) {
// Ensure RELOADER_NAMESPACE is not set
os.Unsetenv(EnvReloaderNamespace)
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg)
err := publisher.Publish(context.Background())
if err != nil {
t.Errorf("Publish() with no namespace should not error, got: %v", err)
}
}
func TestPublisher_Publish_CreateNew(t *testing.T) {
// Set environment variables
os.Setenv(EnvReloaderNamespace, "test-ns")
os.Setenv(EnvReloaderDeploymentName, "test-deploy")
defer func() {
os.Unsetenv(EnvReloaderNamespace)
os.Unsetenv(EnvReloaderDeploymentName)
}()
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg)
ctx := context.Background()
err := publisher.Publish(ctx)
if err != nil {
t.Errorf("Publish() error = %v", err)
}
// Verify ConfigMap was created
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get created ConfigMap: %v", err)
}
if cm.Name != ConfigMapName {
t.Errorf("ConfigMap.Name = %s, want %s", cm.Name, ConfigMapName)
}
}
func TestPublisher_Publish_UpdateExisting(t *testing.T) {
// Set environment variables
os.Setenv(EnvReloaderNamespace, "test-ns")
os.Setenv(EnvReloaderDeploymentName, "test-deploy")
defer func() {
os.Unsetenv(EnvReloaderNamespace)
os.Unsetenv(EnvReloaderDeploymentName)
}()
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
// Create existing ConfigMap with old data
existingCM := &corev1.ConfigMap{}
existingCM.Name = ConfigMapName
existingCM.Namespace = "test-ns"
existingCM.Data = map[string]string{
"buildInfo": `{"goVersion":"old"}`,
}
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(existingCM).
Build()
cfg := config.NewDefault()
publisher := NewPublisher(fakeClient, cfg)
ctx := context.Background()
err := publisher.Publish(ctx)
if err != nil {
t.Errorf("Publish() error = %v", err)
}
// Verify ConfigMap was updated
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get updated ConfigMap: %v", err)
}
// Check that all data keys are present
if _, ok := cm.Data["buildInfo"]; !ok {
t.Error("buildInfo data key missing after update")
}
if _, ok := cm.Data["reloaderOptions"]; !ok {
t.Error("reloaderOptions data key missing after update")
}
if _, ok := cm.Data["deploymentInfo"]; !ok {
t.Error("deploymentInfo data key missing after update")
}
// Verify labels were added
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey])
}
}
func TestPublishMetaInfoConfigMap(t *testing.T) {
// Set environment variables
os.Setenv(EnvReloaderNamespace, "test-ns")
defer os.Unsetenv(EnvReloaderNamespace)
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
cfg := config.NewDefault()
ctx := context.Background()
err := PublishMetaInfoConfigMap(ctx, fakeClient, cfg)
if err != nil {
t.Errorf("PublishMetaInfoConfigMap() error = %v", err)
}
// Verify ConfigMap was created
cm := &corev1.ConfigMap{}
err = fakeClient.Get(ctx, client.ObjectKey{Name: ConfigMapName, Namespace: "test-ns"}, cm)
if err != nil {
t.Errorf("Failed to get created ConfigMap: %v", err)
}
}
func TestParseUTCTime(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "valid RFC3339 time",
input: "2024-01-01T12:00:00Z",
wantErr: false,
},
{
name: "empty string",
input: "",
wantErr: true, // returns zero time
},
{
name: "invalid format",
input: "not-a-time",
wantErr: true, // returns zero time
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseUTCTime(tt.input)
if tt.wantErr {
if !result.IsZero() {
t.Errorf("parseUTCTime(%s) should return zero time", tt.input)
}
} else {
if result.IsZero() {
t.Errorf("parseUTCTime(%s) should not return zero time", tt.input)
}
}
})
}
}