mirror of
https://github.com/hanzo-fi/stack.git
synced 2026-10-30 20:34:43 +00:00
443 lines
13 KiB
Go
443 lines
13 KiB
Go
package modules
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/formancehq/stack/libs/go-libs/logging"
|
|
"github.com/pkg/errors"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
"k8s.io/client-go/tools/record"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/manager"
|
|
|
|
"github.com/formancehq/operator/apis/stack/v1beta3"
|
|
"github.com/formancehq/stack/libs/go-libs/collectionutils"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
)
|
|
|
|
const (
|
|
PartOfConfigurationLabel = "stacks.formance.cloud/partof-configuration"
|
|
copiedSecretLabel = "stack.formance.cloud/copied-secret"
|
|
PartOfConfigurationAnyValue = "any"
|
|
trueValue = "true"
|
|
|
|
secretNameOnConfigurationAnnotation = "stacks.formance.cloud/referenced-by-name"
|
|
originalSecretNamespaceAnnotation = "stack.formance.cloud/original-secret-namespace"
|
|
originalSecretNameAnnotation = "stack.formance.cloud/original-secret-name"
|
|
)
|
|
|
|
type StackReconcilerFactory struct {
|
|
platform Platform
|
|
manager manager.Manager
|
|
}
|
|
|
|
func (sm *StackReconcilerFactory) Platform() Platform {
|
|
return sm.platform
|
|
}
|
|
|
|
func (sm *StackReconcilerFactory) NewDeployer(stack *v1beta3.Stack, configuration *v1beta3.Configuration, versions *v1beta3.Versions) *StackReconciler {
|
|
return newStackReconciler(sm.manager, ReconciliationConfig{
|
|
Stack: stack,
|
|
Configuration: configuration,
|
|
Versions: versions,
|
|
Platform: sm.platform,
|
|
})
|
|
}
|
|
|
|
func NewsStackReconcilerFactory(mgr manager.Manager, platform Platform) *StackReconcilerFactory {
|
|
return &StackReconcilerFactory{
|
|
platform: platform,
|
|
manager: mgr,
|
|
}
|
|
}
|
|
|
|
type MigrationConfig struct {
|
|
ReconciliationConfig ReconciliationConfig
|
|
Version string
|
|
}
|
|
|
|
type ReconciliationConfig struct {
|
|
Stack *v1beta3.Stack
|
|
Configuration *v1beta3.Configuration
|
|
Versions *v1beta3.Versions
|
|
Platform Platform
|
|
}
|
|
|
|
func (s *ReconciliationConfig) IsDisabled(module string, isEE bool) bool {
|
|
if isEE {
|
|
return !s.Stack.Spec.Services.IsExplicitlyEnabled(module) && !s.Configuration.Spec.Services.IsExplicitlyEnabled(module)
|
|
} else {
|
|
return s.Stack.Spec.Services.IsExplicitlyDisabled(module) || s.Configuration.Spec.Services.IsExplicitlyDisabled(module)
|
|
}
|
|
}
|
|
|
|
type StackReconciler struct {
|
|
ReconciliationConfig
|
|
JobRunner
|
|
podDeployer PodDeployer
|
|
portAllocator PortAllocator
|
|
namespacedResourceDeployer *scopedResourceDeployer
|
|
manager manager.Manager
|
|
|
|
ready collectionutils.Set[Module]
|
|
secretsEventRecorder record.EventRecorder
|
|
}
|
|
|
|
func newStackReconciler(mgr manager.Manager, cfg ReconciliationConfig) *StackReconciler {
|
|
|
|
resourceDeployer := NewScopedDeployer(mgr.GetClient(), mgr.GetScheme(), cfg.Stack, cfg.Stack)
|
|
|
|
var (
|
|
portAllocator PortAllocator = StaticPortAllocator(8080)
|
|
podDeployer PodDeployer = NewDefaultPodDeployer(resourceDeployer)
|
|
)
|
|
|
|
if cfg.Configuration.Spec.LightMode {
|
|
podDeployer = NewMonoPodDeployer(resourceDeployer, cfg.Stack.Name)
|
|
portAllocator = NewPortRangeAllocator(10000)
|
|
}
|
|
return &StackReconciler{
|
|
ReconciliationConfig: cfg,
|
|
namespacedResourceDeployer: resourceDeployer,
|
|
podDeployer: podDeployer,
|
|
portAllocator: portAllocator,
|
|
ready: collectionutils.NewSet[Module](),
|
|
JobRunner: NewJobRunner(mgr.GetClient(), mgr.GetScheme(), cfg.Stack, cfg.Stack, ""),
|
|
manager: mgr,
|
|
secretsEventRecorder: mgr.GetEventRecorderFor("operator"),
|
|
}
|
|
}
|
|
|
|
func (r *StackReconciler) Reconcile(ctx context.Context) (bool, error) {
|
|
|
|
logger := log.FromContext(ctx)
|
|
logger = logger.WithValues("stack", r.Stack.Name)
|
|
|
|
// When Stack is Disabled, we want to remove all deployments
|
|
if r.Stack.Spec.Disabled {
|
|
logger.Info("Stack is disabled, remove all deployments")
|
|
return falseIfError(r.deleteAllStackDeployments(ctx))
|
|
}
|
|
|
|
if r.Stack.ModeChanged(r.Configuration) {
|
|
logger.Info("Stack mode has changed, remove all deployments")
|
|
if err := r.deleteAllStackDeployments(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
r.Stack.Status.LightMode = r.Configuration.Spec.LightMode
|
|
}
|
|
|
|
if err := r.prepareSecrets(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
registeredModules := RegisteredModules{}
|
|
|
|
logger.Info("Prepare modules")
|
|
err := r.prepareModules(ctx, registeredModules)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if r.Configuration.Spec.LightMode {
|
|
for _, module := range getSortedModules() {
|
|
if r.IsDisabled(module.Name(), IsEE(module)) {
|
|
continue
|
|
}
|
|
if !r.ready.Contains(module) {
|
|
logger.Info("Stop reconciliation because we're in light mode and module is not ready", "module", module.Name())
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
if err := r.podDeployer.(PodDeployerFinalizer).finalize(ctx); err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
|
|
ready, err := r.checkDeployments(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !ready {
|
|
logger.Info("Skip modules finalizing as all modules are not ready")
|
|
return false, nil
|
|
}
|
|
|
|
logger.Info("Finalize modules")
|
|
allReady := true
|
|
for _, module := range getSortedModules() {
|
|
if r.IsDisabled(module.Name(), IsEE(module)) {
|
|
continue
|
|
}
|
|
if !r.ready.Contains(module) {
|
|
allReady = false
|
|
logger.Info(fmt.Sprintf("Skip post install of modules '%s' as it is marked as not ready", module.Name()))
|
|
continue
|
|
}
|
|
logger.Info(fmt.Sprintf("Post install module '%s'", module.Name()))
|
|
completed, err := newModuleReconciler(r, module).finalizeModule(ctx, module)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
allReady = allReady && completed
|
|
if !completed {
|
|
logger.Info(fmt.Sprintf("Module '%s' marked as not completed", module.Name()))
|
|
}
|
|
}
|
|
|
|
return allReady, nil
|
|
}
|
|
|
|
func (r *StackReconciler) copySecrets(ctx context.Context) ([]corev1.Secret, error) {
|
|
|
|
logger := log.FromContext(ctx)
|
|
logger.Info("Copy secrets")
|
|
|
|
requirement, err := labels.NewRequirement(PartOfConfigurationLabel, selection.In, []string{r.Configuration.Name, PartOfConfigurationAnyValue})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secretsToCopy := &corev1.SecretList{}
|
|
if err := r.manager.GetClient().List(ctx, secretsToCopy, &client.ListOptions{
|
|
LabelSelector: labels.NewSelector().Add(*requirement),
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, secret := range secretsToCopy.Items {
|
|
secretName, ok := secret.Annotations[secretNameOnConfigurationAnnotation]
|
|
if !ok {
|
|
logger.Info("Secret name annotation not found, use secret name", "secret", secret.Name)
|
|
secretName = secret.Name
|
|
}
|
|
|
|
_, operationResult, err := r.namespacedResourceDeployer.Secrets().CreateOrUpdate(ctx, secretName, func(t *corev1.Secret) {
|
|
t.Data = secret.Data
|
|
t.StringData = secret.StringData
|
|
t.Type = secret.Type
|
|
t.Labels = map[string]string{
|
|
copiedSecretLabel: trueValue,
|
|
}
|
|
t.Annotations = map[string]string{
|
|
originalSecretNamespaceAnnotation: secret.Namespace,
|
|
originalSecretNameAnnotation: secret.Name,
|
|
}
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
switch operationResult {
|
|
case controllerutil.OperationResultCreated:
|
|
r.secretsEventRecorder.Eventf(r.Stack, "Normal", "Created secret",
|
|
"Secret created from secret %s/%s with name '%s'", secret.Namespace, secret.Name, secretName)
|
|
case controllerutil.OperationResultUpdated:
|
|
r.secretsEventRecorder.Eventf(r.Stack, "Normal", "Updated secret",
|
|
"Secret updated from secret %s/%s with name '%s'", secret.Namespace, secret.Name, secretName)
|
|
}
|
|
}
|
|
|
|
return secretsToCopy.Items, nil
|
|
}
|
|
|
|
func (r *StackReconciler) cleanSecrets(ctx context.Context, copiedSecrets []corev1.Secret) error {
|
|
logger := logging.FromContext(ctx)
|
|
logger.Info("Clean secrets")
|
|
|
|
requirement, err := labels.NewRequirement(copiedSecretLabel, selection.Equals, []string{trueValue})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
existingSecrets := &corev1.SecretList{}
|
|
if err := r.manager.GetClient().List(ctx, existingSecrets, &client.ListOptions{
|
|
Namespace: r.Stack.Name,
|
|
LabelSelector: labels.NewSelector().Add(*requirement),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
l:
|
|
for _, existingSecret := range existingSecrets.Items {
|
|
originalSecretNamespace := existingSecret.Annotations[originalSecretNamespaceAnnotation]
|
|
originalSecretName := existingSecret.Annotations[originalSecretNameAnnotation]
|
|
for _, copiedSecret := range copiedSecrets {
|
|
if originalSecretNamespace == copiedSecret.Namespace && originalSecretName == copiedSecret.Name {
|
|
continue l
|
|
}
|
|
}
|
|
if err := r.manager.GetClient().Delete(ctx, &existingSecret); err != nil {
|
|
return errors.Wrap(err, "error deleting old secret")
|
|
}
|
|
r.secretsEventRecorder.AnnotatedEventf(r.Stack, existingSecret.Annotations, "Normal",
|
|
"Removed secret", "Secret %s removed", existingSecret.Name)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *StackReconciler) prepareSecrets(ctx context.Context) error {
|
|
secrets, err := r.copySecrets(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return r.cleanSecrets(ctx, secrets)
|
|
}
|
|
|
|
func (r *StackReconciler) prepareModules(ctx context.Context, registeredModules RegisteredModules) error {
|
|
processed := collectionutils.NewSet[Module]()
|
|
for _, module := range getSortedModules() {
|
|
if err := r.prepareModule(ctx, module, registeredModules, newGraphVisitor(), processed); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *StackReconciler) prepareModule(ctx context.Context, module Module,
|
|
registeredModules RegisteredModules, visitor *graphVisitor, processed collectionutils.Set[Module]) error {
|
|
if err := visitor.visit(module); err != nil {
|
|
return err
|
|
}
|
|
defer processed.Put(module)
|
|
|
|
dependsOnAwareModule, ok := module.(DependsOnAwareModule)
|
|
if ok {
|
|
for _, dependency := range dependsOnAwareModule.DependsOn() {
|
|
if err := r.prepareModule(ctx, dependency, registeredModules, visitor.copy(), processed); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
if processed.Contains(module) {
|
|
return nil
|
|
}
|
|
|
|
if r.IsDisabled(module.Name(), IsEE(module)) {
|
|
return r.scaleDownStackModule(ctx, module)
|
|
}
|
|
|
|
isReady, deploymentNames, err := newModuleReconciler(r, module).installModule(ctx, registeredModules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if isReady {
|
|
log.FromContext(ctx).Info("Mark module as ready", "module", module.Name())
|
|
r.ready.Put(module)
|
|
|
|
if err := r.cleanOldModuleDeployments(ctx, module, deploymentNames); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *StackReconciler) deleteStackDeployment(ctx context.Context, deployment *appsv1.Deployment) error {
|
|
return r.namespacedResourceDeployer.client.Delete(ctx, deployment)
|
|
}
|
|
|
|
func (r *StackReconciler) deleteAllStackDeployments(ctx context.Context) error {
|
|
return r.namespacedResourceDeployer.client.DeleteAllOf(ctx, &appsv1.Deployment{},
|
|
client.InNamespace(r.Stack.Name),
|
|
client.MatchingLabels{
|
|
"stack": "true",
|
|
},
|
|
)
|
|
}
|
|
|
|
func (r *StackReconciler) cleanOldModuleDeployments(ctx context.Context, module Module, deploymentNames map[string]struct{}) error {
|
|
// Clean old deployments
|
|
deploymentsList := appsv1.DeploymentList{}
|
|
if err := r.namespacedResourceDeployer.client.List(ctx, &deploymentsList,
|
|
client.InNamespace(r.Stack.Name),
|
|
client.MatchingLabels(map[string]string{
|
|
"app.kubernetes.io/name": module.Name(),
|
|
stackLabel: "true",
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, d := range deploymentsList.Items {
|
|
if _, ok := deploymentNames[d.Name]; !ok {
|
|
if err := r.deleteStackDeployment(ctx, &d); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *StackReconciler) checkDeployments(ctx context.Context) (bool, error) {
|
|
deploymentsList := appsv1.DeploymentList{}
|
|
if err := r.namespacedResourceDeployer.client.List(ctx, &deploymentsList,
|
|
client.InNamespace(r.Stack.Name),
|
|
client.MatchingLabels(map[string]string{
|
|
stackLabel: "true",
|
|
})); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, deployment := range deploymentsList.Items {
|
|
ok, err := ensureDeploymentSync(ctx, deployment)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (r *StackReconciler) scaleDownStackModule(ctx context.Context, module Module) error {
|
|
return r.namespacedResourceDeployer.client.DeleteAllOf(ctx, &appsv1.Deployment{},
|
|
client.InNamespace(r.Stack.Name),
|
|
client.MatchingLabels{
|
|
"app.kubernetes.io/name": module.Name(),
|
|
"stack": "true",
|
|
},
|
|
)
|
|
}
|
|
|
|
type graphVisitor struct {
|
|
visited collectionutils.Set[Module]
|
|
stack []Module
|
|
}
|
|
|
|
func (v *graphVisitor) visit(t Module) error {
|
|
v.stack = append(v.stack, t)
|
|
if v.visited.Contains(t) {
|
|
return fmt.Errorf("circular dependencies detected: %s", strings.Join(collectionutils.Map(v.stack, Module.Name), " -> "))
|
|
}
|
|
v.visited.Put(t)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (v *graphVisitor) copy() *graphVisitor {
|
|
return &graphVisitor{
|
|
visited: collectionutils.CopyMap(v.visited),
|
|
stack: v.stack[:],
|
|
}
|
|
}
|
|
|
|
func newGraphVisitor() *graphVisitor {
|
|
return &graphVisitor{
|
|
visited: make(map[Module]struct{}),
|
|
stack: []Module{},
|
|
}
|
|
}
|