Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions modules/common/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"time"

"github.com/openstack-k8s-operators/lib-common/modules/common/env"
"github.com/openstack-k8s-operators/lib-common/modules/common/helper"
"github.com/openstack-k8s-operators/lib-common/modules/common/pod"
"github.com/openstack-k8s-operators/lib-common/modules/common/util"
Expand All @@ -30,6 +31,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

Expand Down Expand Up @@ -141,6 +143,12 @@ func GetDeploymentWithName(
return depl, nil
}

// ConfigHashEnvVar is the environment variable that operators inject into
// pod templates to track which configuration revision the workload runs.
// The canonical definition lives in the env package; this alias is kept for
// backward compatibility with existing importers.
const ConfigHashEnvVar = env.ConfigHashEnvVar

// IsReady - validates when deployment is ready deployed to whats being requested
// - the requested replicas in the spec matches the ReadyReplicas of the status
// - the Status.Replicas match Status.ReadyReplicas. if a deployment update is in progress, Replicas > ReadyReplicas
Expand All @@ -153,3 +161,27 @@ func IsReady(deployment appsv1.Deployment) bool {
deployment.Status.Replicas == deployment.Status.ReadyReplicas &&
deployment.Generation == deployment.Status.ObservedGeneration
}

// IsReadyForInput reads a Deployment directly from the provided reader
// (typically an uncached API reader) and reports whether the workload is
// fully rolled out with the expected configuration. It returns true only
// when IsReady passes and env.ConfigHashMatches confirms a container (init or
// regular) carries a literal CONFIG_HASH equal to configHash. A blank
// configHash never reports ready.
func IsReadyForInput(
ctx context.Context,
reader client.Reader,
name types.NamespacedName,
configHash string,
) (bool, error) {
depl := &appsv1.Deployment{}
if err := reader.Get(ctx, name, depl); err != nil {
return false, err
}

if !IsReady(*depl) {
return false, nil
}

return env.ConfigHashMatches(depl.Spec.Template.Spec, configHash), nil
}
130 changes: 130 additions & 0 deletions modules/common/deployment/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ limitations under the License.
package deployment

import (
"context"
"testing"

. "github.com/onsi/gomega"
appsv1 "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/types"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func TestIsReady(t *testing.T) {
Expand Down Expand Up @@ -146,3 +152,127 @@ func TestIsReady(t *testing.T) {
})
}
}

func TestIsReadyForInput(t *testing.T) {
scheme := runtime.NewScheme()
_ = appsv1.AddToScheme(scheme)

readyDepl := func(hash string) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: "test-depl",
Namespace: "test-ns",
Generation: 1,
},
Spec: appsv1.DeploymentSpec{
Replicas: ptr.To[int32](1),
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "service",
Env: []corev1.EnvVar{
{Name: ConfigHashEnvVar, Value: hash},
},
},
},
},
},
},
Status: appsv1.DeploymentStatus{
Replicas: 1,
ReadyReplicas: 1,
UpdatedReplicas: 1,
ObservedGeneration: 1,
},
}
}

name := types.NamespacedName{Name: "test-depl", Namespace: "test-ns"}

tests := []struct {
name string
depl *appsv1.Deployment
configHash string
want bool
wantErr bool
}{
{
name: "ready with matching config hash",
depl: readyDepl("hash-abc"),
configHash: "hash-abc",
want: true,
},
{
name: "ready but config hash mismatch",
depl: readyDepl("hash-abc"),
configHash: "hash-xyz",
want: false,
},
{
name: "ready but no CONFIG_HASH env var",
depl: func() *appsv1.Deployment {
d := readyDepl("")
d.Spec.Template.Spec.Containers[0].Env = nil
return d
}(),
configHash: "hash-abc",
want: false,
},
{
name: "not ready - replicas mismatch",
depl: func() *appsv1.Deployment {
d := readyDepl("hash-abc")
d.Status.ReadyReplicas = 0
return d
}(),
configHash: "hash-abc",
want: false,
},
{
name: "not ready - generation mismatch",
depl: func() *appsv1.Deployment {
d := readyDepl("hash-abc")
d.Generation = 2
return d
}(),
configHash: "hash-abc",
want: false,
},
{
name: "not ready - rolling update in progress",
depl: func() *appsv1.Deployment {
d := readyDepl("hash-abc")
d.Status.Replicas = 2
return d
}(),
configHash: "hash-abc",
want: false,
},
{
name: "not found",
depl: nil,
configHash: "hash-abc",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
builder := fake.NewClientBuilder().WithScheme(scheme)
if tt.depl != nil {
builder = builder.WithObjects(tt.depl)
}
reader := builder.Build()

got, err := IsReadyForInput(context.Background(), reader, name, tt.configHash)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(got).To(Equal(tt.want))
}
})
}
}
30 changes: 30 additions & 0 deletions modules/common/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ type Setter func(*corev1.EnvVar)
// SetterMap - env setter map
type SetterMap map[string]Setter

// ConfigHashEnvVar is the environment variable that operators inject into pod
// templates to track which configuration revision the workload runs.
const ConfigHashEnvVar = "CONFIG_HASH"

// ConfigHashMatches reports whether any container in the given pod spec carries
// a CONFIG_HASH environment variable whose literal value equals configHash. It
// scans both init and regular containers.
//
// An empty configHash never matches: a not-yet-computed (or cleared) hash must
// not be treated as "applied", which would otherwise report a premature ready
// result. Only the literal EnvVar.Value is compared; a CONFIG_HASH sourced via
// ValueFrom (fieldRef/configMapKeyRef/secretKeyRef) is intentionally treated as
// not matching, following the operator convention of injecting the hash as a
// literal value.
func ConfigHashMatches(podSpec corev1.PodSpec, configHash string) bool {
if configHash == "" {
return false
}
for _, containers := range [][]corev1.Container{podSpec.InitContainers, podSpec.Containers} {
for _, c := range containers {
for _, e := range c.Env {
if e.Name == ConfigHashEnvVar && e.Value == configHash {
return true
}
}
}
}
return false
}

// MergeEnvs - merge envs
func MergeEnvs(envs []corev1.EnvVar, newEnvs SetterMap) []corev1.EnvVar {

Expand Down
87 changes: 87 additions & 0 deletions modules/common/env/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,90 @@ func TestMergeEnvs(t *testing.T) {
})
}
}

func containerWithEnv(env ...corev1.EnvVar) corev1.Container {
return corev1.Container{Name: "c", Env: env}
}

func TestConfigHashMatches(t *testing.T) {
tests := []struct {
name string
podSpec corev1.PodSpec
configHash string
want bool
}{
{
name: "matches a literal CONFIG_HASH on a regular container",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: ConfigHashEnvVar, Value: "abc"})},
},
configHash: "abc",
want: true,
},
{
name: "no CONFIG_HASH env var present",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: "OTHER", Value: "abc"})},
},
configHash: "abc",
want: false,
},
{
name: "CONFIG_HASH present but value differs",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: ConfigHashEnvVar, Value: "stale"})},
},
configHash: "abc",
want: false,
},
{
name: "empty configHash never matches even against an empty CONFIG_HASH value",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: ConfigHashEnvVar, Value: ""})},
},
configHash: "",
want: false,
},
{
name: "empty configHash never matches against a populated CONFIG_HASH value",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: ConfigHashEnvVar, Value: "abc"})},
},
configHash: "",
want: false,
},
{
name: "matches a literal CONFIG_HASH carried only on an init container",
podSpec: corev1.PodSpec{
InitContainers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: ConfigHashEnvVar, Value: "abc"})},
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{Name: "OTHER", Value: "x"})},
},
configHash: "abc",
want: true,
},
{
name: "CONFIG_HASH sourced via ValueFrom is not treated as a match",
podSpec: corev1.PodSpec{
Containers: []corev1.Container{containerWithEnv(corev1.EnvVar{
Name: ConfigHashEnvVar,
ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{FieldPath: "metadata.name"}},
})},
},
configHash: "abc",
want: false,
},
{
name: "no containers at all",
podSpec: corev1.PodSpec{},
configHash: "abc",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
g.Expect(ConfigHashMatches(tt.podSpec, tt.configHash)).To(Equal(tt.want))
})
}
}
32 changes: 32 additions & 0 deletions modules/common/statefulset/statefulset.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"time"

"github.com/openstack-k8s-operators/lib-common/modules/common/env"
"github.com/openstack-k8s-operators/lib-common/modules/common/helper"
"github.com/openstack-k8s-operators/lib-common/modules/common/pod"
"github.com/openstack-k8s-operators/lib-common/modules/common/util"
Expand All @@ -30,6 +31,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

Expand Down Expand Up @@ -163,6 +165,12 @@ func (s *StatefulSet) Delete(
return nil
}

// ConfigHashEnvVar is the environment variable that operators inject into
// pod templates to track which configuration revision the workload runs.
// The canonical definition lives in the env package; this alias is kept for
// backward compatibility with existing importers.
const ConfigHashEnvVar = env.ConfigHashEnvVar

// IsReady - validates when deployment is ready deployed to whats being requested
// - the requested replicas in the spec matches the ReadyReplicas of the status
// - all pods run the current spec (UpdatedReplicas == requested replicas)
Expand All @@ -175,3 +183,27 @@ func IsReady(deployment appsv1.StatefulSet) bool {
deployment.Generation == deployment.Status.ObservedGeneration &&
deployment.Status.CurrentRevision == deployment.Status.UpdateRevision
}

// IsReadyForInput reads a StatefulSet directly from the provided reader
// (typically an uncached API reader) and reports whether the workload is
// fully rolled out with the expected configuration. It returns true only
// when IsReady passes and env.ConfigHashMatches confirms a container (init or
// regular) carries a literal CONFIG_HASH equal to configHash. A blank
// configHash never reports ready.
func IsReadyForInput(
ctx context.Context,
reader client.Reader,
name types.NamespacedName,
configHash string,
) (bool, error) {
sts := &appsv1.StatefulSet{}
if err := reader.Get(ctx, name, sts); err != nil {
return false, err
}

if !IsReady(*sts) {
return false, nil
}

return env.ConfigHashMatches(sts.Spec.Template.Spec, configHash), nil
}
Loading
Loading