mirror of
https://github.com/stakater/Reloader.git
synced 2026-08-23 22:16:45 +00:00
refactor: expose decision engine in public pkg
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// Package metadata provides metadata ConfigMap creation for Reloader.
|
||||
// The metadata ConfigMap contains build info, configuration options, and deployment info.
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/stakater/Reloader/pkg/config"
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
// 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"`
|
||||
// Config contains all the configuration options used by this Reloader instance.
|
||||
Config *config.Config `json:"config"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// NewBuildInfo creates a new BuildInfo with current build information.
|
||||
func NewBuildInfo() BuildInfo {
|
||||
return BuildInfo{
|
||||
GoVersion: runtime.Version(),
|
||||
ReleaseVersion: Version,
|
||||
CommitHash: Commit,
|
||||
CommitTime: parseUTCTime(BuildDate),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMetaInfo creates a new MetaInfo from configuration.
|
||||
func NewMetaInfo(cfg *config.Config) *MetaInfo {
|
||||
return &MetaInfo{
|
||||
BuildInfo: NewBuildInfo(),
|
||||
Config: 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),
|
||||
"config": toJSON(m.Config),
|
||||
"deploymentInfo": toJSON(m.DeploymentInfo),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func toJSON(data interface{}) string {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(jsonData)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
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"
|
||||
|
||||
"github.com/stakater/Reloader/pkg/config"
|
||||
)
|
||||
|
||||
// testLogger returns a no-op logger for testing.
|
||||
func testLogger() logr.Logger {
|
||||
return logr.Discard()
|
||||
}
|
||||
|
||||
func TestNewBuildInfo(t *testing.T) {
|
||||
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 TestNewMetaInfo(t *testing.T) {
|
||||
t.Setenv(EnvReloaderNamespace, "test-ns")
|
||||
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
|
||||
|
||||
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"}
|
||||
|
||||
metaInfo := NewMetaInfo(cfg)
|
||||
|
||||
if !metaInfo.Config.AutoReloadAll {
|
||||
t.Error("AutoReloadAll should be true")
|
||||
}
|
||||
if metaInfo.Config.ReloadStrategy != config.ReloadStrategyAnnotations {
|
||||
t.Errorf("ReloadStrategy = %s, want annotations", metaInfo.Config.ReloadStrategy)
|
||||
}
|
||||
if !metaInfo.Config.ArgoRolloutsEnabled {
|
||||
t.Error("ArgoRolloutsEnabled should be true")
|
||||
}
|
||||
if !metaInfo.Config.ReloadOnCreate {
|
||||
t.Error("ReloadOnCreate should be true")
|
||||
}
|
||||
if !metaInfo.Config.ReloadOnDelete {
|
||||
t.Error("ReloadOnDelete should be true")
|
||||
}
|
||||
if !metaInfo.Config.EnableHA {
|
||||
t.Error("EnableHA should be true")
|
||||
}
|
||||
if metaInfo.Config.WebhookURL != "https://example.com/webhook" {
|
||||
t.Errorf("WebhookURL = %s, want https://example.com/webhook", metaInfo.Config.WebhookURL)
|
||||
}
|
||||
|
||||
if metaInfo.DeploymentInfo.Namespace != "test-ns" {
|
||||
t.Errorf("DeploymentInfo.Namespace = %s, want test-ns", metaInfo.DeploymentInfo.Namespace)
|
||||
}
|
||||
if metaInfo.DeploymentInfo.Name != "test-deploy" {
|
||||
t.Errorf("DeploymentInfo.Name = %s, want test-deploy", metaInfo.DeploymentInfo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaInfo_ToConfigMap(t *testing.T) {
|
||||
t.Setenv(EnvReloaderNamespace, "reloader-ns")
|
||||
t.Setenv(EnvReloaderDeploymentName, "reloader-deploy")
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if _, ok := cm.Data["buildInfo"]; !ok {
|
||||
t.Error("buildInfo data key missing")
|
||||
}
|
||||
if _, ok := cm.Data["config"]; !ok {
|
||||
t.Error("config 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)
|
||||
}
|
||||
|
||||
var parsedConfig config.Config
|
||||
if err := json.Unmarshal([]byte(cm.Data["config"]), &parsedConfig); err != nil {
|
||||
t.Errorf("config 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) {
|
||||
t.Setenv(EnvReloaderNamespace, "")
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
publisher := NewPublisher(fakeClient, cfg, testLogger())
|
||||
|
||||
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) {
|
||||
t.Setenv(EnvReloaderNamespace, "test-ns")
|
||||
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
publisher := NewPublisher(fakeClient, cfg, testLogger())
|
||||
|
||||
ctx := context.Background()
|
||||
err := publisher.Publish(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Publish() error = %v", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
t.Setenv(EnvReloaderNamespace, "test-ns")
|
||||
t.Setenv(EnvReloaderDeploymentName, "test-deploy")
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
|
||||
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, testLogger())
|
||||
|
||||
ctx := context.Background()
|
||||
err := publisher.Publish(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Publish() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if _, ok := cm.Data["buildInfo"]; !ok {
|
||||
t.Error("buildInfo data key missing after update")
|
||||
}
|
||||
if _, ok := cm.Data["config"]; !ok {
|
||||
t.Error("config data key missing after update")
|
||||
}
|
||||
if _, ok := cm.Data["deploymentInfo"]; !ok {
|
||||
t.Error("deploymentInfo data key missing after update")
|
||||
}
|
||||
|
||||
if cm.Labels[ConfigMapLabelKey] != ConfigMapLabelValue {
|
||||
t.Errorf("Label not updated: %s", cm.Labels[ConfigMapLabelKey])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMetaInfoConfigMap(t *testing.T) {
|
||||
t.Setenv(EnvReloaderNamespace, "test-ns")
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
_ = corev1.AddToScheme(scheme)
|
||||
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
|
||||
cfg := config.NewDefault()
|
||||
ctx := context.Background()
|
||||
|
||||
err := PublishMetaInfoConfigMap(ctx, fakeClient, cfg, testLogger())
|
||||
if err != nil {
|
||||
t.Errorf("PublishMetaInfoConfigMap() error = %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/stakater/Reloader/internal/pkg/workload"
|
||||
"github.com/stakater/Reloader/pkg/config"
|
||||
)
|
||||
|
||||
// Publisher handles creating and updating the metadata ConfigMap.
|
||||
type Publisher struct {
|
||||
client client.Client
|
||||
cfg *config.Config
|
||||
log logr.Logger
|
||||
}
|
||||
|
||||
// NewPublisher creates a new Publisher.
|
||||
func NewPublisher(c client.Client, cfg *config.Config, log logr.Logger) *Publisher {
|
||||
return &Publisher{
|
||||
client: c,
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Publish creates or updates the metadata ConfigMap.
|
||||
func (p *Publisher) Publish(ctx context.Context) error {
|
||||
namespace := os.Getenv(EnvReloaderNamespace)
|
||||
if namespace == "" {
|
||||
p.log.Info("RELOADER_NAMESPACE is not set, skipping meta info configmap creation")
|
||||
return nil
|
||||
}
|
||||
|
||||
metaInfo := NewMetaInfo(p.cfg)
|
||||
configMap := metaInfo.ToConfigMap()
|
||||
|
||||
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)
|
||||
}
|
||||
p.log.Info("Creating meta info configmap")
|
||||
if err := p.client.Create(ctx, configMap, client.FieldOwner(workload.FieldManager)); err != nil {
|
||||
return fmt.Errorf("failed to create meta info configmap: %w", err)
|
||||
}
|
||||
p.log.Info("Meta info configmap created successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
p.log.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(workload.FieldManager)); err != nil {
|
||||
return fmt.Errorf("failed to update meta info configmap: %w", err)
|
||||
}
|
||||
p.log.Info("Meta info configmap updated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublishMetaInfoConfigMap is a convenience function that creates a Publisher and calls Publish.
|
||||
func PublishMetaInfoConfigMap(ctx context.Context, c client.Client, cfg *config.Config, log logr.Logger) error {
|
||||
publisher := NewPublisher(c, cfg, log)
|
||||
return publisher.Publish(ctx)
|
||||
}
|
||||
|
||||
// Runnable returns a controller-runtime Runnable that publishes the metadata ConfigMap
|
||||
// when the manager starts. This ensures the cache is ready before accessing the API.
|
||||
func Runnable(c client.Client, cfg *config.Config, log logr.Logger) RunnableFunc {
|
||||
return func(ctx context.Context) error {
|
||||
if err := PublishMetaInfoConfigMap(ctx, c, cfg, log); err != nil {
|
||||
log.Error(err, "Failed to create metadata ConfigMap")
|
||||
// Non-fatal, don't return error to avoid crashing the manager
|
||||
}
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RunnableFunc is a function that implements the controller-runtime Runnable interface.
|
||||
type RunnableFunc func(context.Context) error
|
||||
|
||||
// Start implements the Runnable interface.
|
||||
func (r RunnableFunc) Start(ctx context.Context) error {
|
||||
return r(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user