mirror of
https://github.com/hanzo-fi/stack.git
synced 2026-10-30 20:34:43 +00:00
436 lines
13 KiB
Go
436 lines
13 KiB
Go
package modules
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
|
|
"github.com/formancehq/operator/apis/stack/v1beta3"
|
|
"github.com/formancehq/stack/libs/go-libs/collectionutils"
|
|
"github.com/pkg/errors"
|
|
"golang.org/x/mod/semver"
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
)
|
|
|
|
type Cron struct {
|
|
Container Container
|
|
Schedule string
|
|
Suspend bool
|
|
}
|
|
|
|
type DatabaseMigration struct {
|
|
// optional, will be the commit hash if empty in the kube object
|
|
Shutdown bool
|
|
Command []string
|
|
AdditionalEnv func(config ReconciliationConfig) []EnvVar
|
|
}
|
|
|
|
type Version struct {
|
|
DatabaseMigration *DatabaseMigration
|
|
Services func(cfg ReconciliationConfig) Services
|
|
Cron func(cfg ReconciliationConfig) []Cron
|
|
PreUpgrade func(ctx context.Context, jobRunner JobRunner, cfg MigrationConfig) (bool, error)
|
|
PostUpgrade func(ctx context.Context, jobRunner JobRunner, cfg MigrationConfig) (bool, error)
|
|
}
|
|
|
|
type Module interface {
|
|
Name() string
|
|
Versions() map[string]Version
|
|
}
|
|
|
|
// As standard modules are always enabled, EE modules are, by default disabled.
|
|
// To enable an ee module, it must be explicitely defined with disabled == false,
|
|
// either in global configuration, or per stack
|
|
type EEModule interface {
|
|
IsEE() bool
|
|
}
|
|
|
|
func IsEE(m Module) bool {
|
|
eeModule, ok := m.(EEModule)
|
|
if ok {
|
|
ok = eeModule.IsEE()
|
|
}
|
|
return ok
|
|
}
|
|
|
|
type DependsOnAwareModule interface {
|
|
Module
|
|
DependsOn() []Module
|
|
}
|
|
|
|
type PostgresAwareModule interface {
|
|
Module
|
|
Postgres(cfg ReconciliationConfig) v1beta3.PostgresConfig
|
|
}
|
|
|
|
var modules = make([]Module, 0)
|
|
|
|
func Register(newModules ...Module) {
|
|
modules = append(modules, newModules...)
|
|
}
|
|
|
|
func Get(name string) Module {
|
|
for _, module := range getSortedModules() {
|
|
if module.Name() == name {
|
|
return module
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getSortedModules() []Module {
|
|
sort.Slice(modules, func(i, j int) bool {
|
|
return modules[i].Name() < modules[j].Name()
|
|
})
|
|
return modules
|
|
}
|
|
|
|
func sortedVersions(module Module) []string {
|
|
versionsKeys := collectionutils.Keys(module.Versions())
|
|
sort.Slice(versionsKeys, func(i, j int) bool {
|
|
return semver.Compare(versionsKeys[i], versionsKeys[j]) == -1
|
|
})
|
|
|
|
return versionsKeys
|
|
}
|
|
|
|
func falseIfError(err error) (bool, error) {
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
type moduleReconciler struct {
|
|
*StackReconciler
|
|
module Module
|
|
}
|
|
|
|
func (r *moduleReconciler) installModule(ctx context.Context, registeredModules RegisteredModules) (bool, map[string]struct{}, error) {
|
|
logger := log.FromContext(ctx)
|
|
logger.Info(fmt.Sprintf("Installing module %s", r.module.Name()))
|
|
|
|
registeredModule := RegisteredModule{
|
|
Module: r.module,
|
|
Services: map[string]RegisteredService{},
|
|
}
|
|
registeredModules[r.module.Name()] = registeredModule
|
|
|
|
var (
|
|
chosenVersion Version
|
|
chosenVersionLabel string
|
|
sortedVersions = sortedVersions(r.module)
|
|
versions = r.module.Versions()
|
|
)
|
|
for _, version := range sortedVersions {
|
|
if !r.Versions.IsHigherOrEqual(r.module.Name(), version) {
|
|
break
|
|
}
|
|
chosenVersion = versions[version]
|
|
chosenVersionLabel = version
|
|
}
|
|
|
|
services := chosenVersion.Services(r.ReconciliationConfig)
|
|
sort.Stable(services)
|
|
|
|
serviceReconcilers := make([]*serviceReconciler, 0)
|
|
for _, service := range services {
|
|
serviceName := r.module.Name()
|
|
if service.Name != "" {
|
|
serviceName = serviceName + "-" + service.Name
|
|
}
|
|
|
|
serviceReconciler := newServiceReconciler(r, *service, serviceName)
|
|
serviceReconciler.prepare()
|
|
serviceReconcilers = append(serviceReconcilers, serviceReconciler)
|
|
|
|
registeredModule.Services[serviceName] = RegisteredService{
|
|
Port: serviceReconciler.usedPort,
|
|
Service: *service,
|
|
}
|
|
}
|
|
|
|
var (
|
|
postgresConfig v1beta3.PostgresConfig
|
|
err error
|
|
)
|
|
pam, ok := r.module.(PostgresAwareModule)
|
|
if ok {
|
|
postgresConfig = pam.Postgres(r.ReconciliationConfig)
|
|
ok, err = r.createDatabase(ctx, postgresConfig)
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
if !ok {
|
|
logger.Info("Waiting for database to be created", "module", pam.Name())
|
|
return false, nil, nil
|
|
}
|
|
}
|
|
|
|
for _, version := range sortedVersions {
|
|
if versions[version].PreUpgrade != nil {
|
|
ready, err := r.runPreUpgradeMigration(ctx, r.module, version)
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
if !ready {
|
|
return false, nil, nil
|
|
}
|
|
}
|
|
|
|
if version == chosenVersionLabel {
|
|
break
|
|
}
|
|
}
|
|
|
|
if chosenVersion.DatabaseMigration != nil {
|
|
logger.Info("Start database migration process", "pod", r.module.Name())
|
|
databaseMigrated, err := r.runDatabaseMigration(ctx, r.Versions.GetVersion(r.module.Name()), *chosenVersion.DatabaseMigration, postgresConfig)
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
if !databaseMigrated {
|
|
logger.Info("Mark module as not ready since the database is not up to date")
|
|
return false, nil, nil
|
|
}
|
|
}
|
|
|
|
deploymentNames := make(map[string]struct{})
|
|
me := &serviceErrors{}
|
|
for _, serviceReconciler := range serviceReconcilers {
|
|
err := serviceReconciler.reconcile(ctx, ServiceInstallConfiguration{
|
|
ReconciliationConfig: r.ReconciliationConfig,
|
|
RegisteredModules: registeredModules,
|
|
PostgresConfig: &postgresConfig,
|
|
})
|
|
if err != nil {
|
|
me.setError(serviceReconciler.name, err)
|
|
}
|
|
deploymentNames[serviceReconciler.name] = struct{}{}
|
|
}
|
|
if len(me.errors) > 0 {
|
|
return false, nil, me
|
|
}
|
|
|
|
return true, deploymentNames, nil
|
|
}
|
|
|
|
func (r *moduleReconciler) finalizeModule(ctx context.Context, module Module) (bool, error) {
|
|
versions := module.Versions()
|
|
|
|
var selectedVersion Version
|
|
for _, version := range sortedVersions(module) {
|
|
if !r.Versions.IsHigherOrEqual(module.Name(), version) {
|
|
break
|
|
}
|
|
selectedVersion = versions[version]
|
|
if selectedVersion.PostUpgrade == nil {
|
|
continue
|
|
}
|
|
|
|
migration := &v1beta3.Migration{}
|
|
migrationName := fmt.Sprintf("%s-%s-post-upgrade", module.Name(), version)
|
|
if err := r.namespacedResourceDeployer.client.Get(ctx, types.NamespacedName{
|
|
Namespace: r.Stack.Name,
|
|
Name: migrationName,
|
|
}, migration); err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return false, err
|
|
}
|
|
_, _, err := r.namespacedResourceDeployer.Migrations().CreateOrUpdate(ctx, migrationName, func(t *v1beta3.Migration) {
|
|
t.Spec = v1beta3.MigrationSpec{
|
|
Configuration: r.Configuration.Name,
|
|
CurrentVersion: r.Versions.GetVersion(module.Name()),
|
|
Module: module.Name(),
|
|
TargetedVersion: version,
|
|
Version: r.Versions.Name,
|
|
PostUpgrade: true,
|
|
}
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
log.FromContext(ctx).Info("Mark module as not not completed as we have just created the object",
|
|
"module", r.module.Name(), "migration", migrationName)
|
|
return false, nil
|
|
}
|
|
if !migration.Status.Terminated {
|
|
log.FromContext(ctx).Info("Mark module as not not completed since migration is not terminated",
|
|
"module", r.module.Name(), "migration", migrationName)
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
if selectedVersion.Cron != nil {
|
|
for _, cron := range selectedVersion.Cron(r.ReconciliationConfig) {
|
|
_, _, err := r.namespacedResourceDeployer.CronJobs().CreateOrUpdate(ctx, cron.Container.Name, func(t *batchv1.CronJob) {
|
|
t.Spec = batchv1.CronJobSpec{
|
|
Suspend: &cron.Suspend,
|
|
Schedule: cron.Schedule,
|
|
JobTemplate: batchv1.JobTemplateSpec{
|
|
Spec: batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
Containers: []corev1.Container{{
|
|
Name: cron.Container.Name,
|
|
Image: r.Configuration.ResolveImage(cron.Container.Image),
|
|
Command: cron.Container.Command,
|
|
Args: cron.Container.Args,
|
|
Env: cron.Container.Env.ToCoreEnv(),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return true, nil
|
|
}
|
|
|
|
func (r *moduleReconciler) createDatabase(ctx context.Context, postgresConfig v1beta3.PostgresConfig) (bool, error) {
|
|
dbName := r.Stack.GetServiceName(r.module.Name())
|
|
// PG does not support 'CREATE IF NOT EXISTS ' construct, emulate it with the above query
|
|
createDBCommand := `echo SELECT \'CREATE DATABASE \"${POSTGRES_DATABASE}\"\' WHERE NOT EXISTS \(SELECT FROM pg_database WHERE datname = \'${POSTGRES_DATABASE}\'\)\\gexec | psql -h ${POSTGRES_HOST} -p ${POSTGRES_PORT} -U ${POSTGRES_USERNAME}`
|
|
if postgresConfig.DisableSSLMode {
|
|
createDBCommand += ` "sslmode=disable"`
|
|
}
|
|
return r.RunJob(ctx, fmt.Sprintf("%s-create-database", r.module.Name()), nil,
|
|
func(t *batchv1.Job) {
|
|
t.Spec = batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
RestartPolicy: corev1.RestartPolicyOnFailure,
|
|
Containers: []corev1.Container{{
|
|
Name: "create-database",
|
|
Image: "postgres:15-alpine",
|
|
Args: []string{"sh", "-c", createDBCommand},
|
|
// There is only one service which use prefixed env var : ledger v1
|
|
// Since the ledger v1 auto handle migrations, we don't care about passing a prefix
|
|
Env: DefaultPostgresEnvVarsWithPrefix(postgresConfig, dbName, "").
|
|
// psql use PGPASSWORD env var
|
|
Append(Env("PGPASSWORD", "$(POSTGRES_PASSWORD)")).
|
|
ToCoreEnv(),
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
func (r *moduleReconciler) runPreUpgradeMigration(ctx context.Context, module Module, version string) (bool, error) {
|
|
migration := &v1beta3.Migration{}
|
|
migrationName := fmt.Sprintf("%s-%s-pre-upgrade", module.Name(), version)
|
|
if err := r.namespacedResourceDeployer.client.Get(ctx, types.NamespacedName{
|
|
Namespace: r.Stack.Name,
|
|
Name: migrationName,
|
|
}, migration); err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return false, err
|
|
}
|
|
_, _, err := r.namespacedResourceDeployer.Migrations().CreateOrUpdate(ctx, migrationName, func(t *v1beta3.Migration) {
|
|
t.Spec = v1beta3.MigrationSpec{
|
|
Configuration: r.Configuration.Name,
|
|
CurrentVersion: r.Versions.GetVersion(module.Name()),
|
|
Module: module.Name(),
|
|
TargetedVersion: version,
|
|
Version: r.Versions.Name,
|
|
}
|
|
})
|
|
return false, err
|
|
}
|
|
|
|
return migration.Status.Terminated, nil
|
|
}
|
|
|
|
func (r *moduleReconciler) runDatabaseMigration(ctx context.Context, version string, migration DatabaseMigration, postgresConfig v1beta3.PostgresConfig) (bool, error) {
|
|
logger := log.FromContext(ctx)
|
|
if !semver.IsValid(version) {
|
|
// Commit hash
|
|
if len(version) > 7 {
|
|
version = version[:7]
|
|
}
|
|
}
|
|
name := fmt.Sprintf("%s-%s-db-migration", r.module.Name(), version)
|
|
return r.RunJob(ctx, name,
|
|
func() error {
|
|
if migration.Shutdown {
|
|
logger.Info("Stop module reconciliation as required by upgrade", "module", r.module.Name())
|
|
// todo: store old replicas value
|
|
if scaledDown, err := r.podDeployer.shutdown(ctx, r.module.Name()); err != nil {
|
|
return errors.Wrap(err, "stopping pod")
|
|
} else if !scaledDown {
|
|
logger.Info("Stop reconciliation as pod needs to be scaled down", "pod", r.module.Name())
|
|
return fmt.Errorf("pod needs to be scaled down")
|
|
}
|
|
}
|
|
return nil
|
|
},
|
|
func(t *batchv1.Job) {
|
|
args := migration.Command
|
|
if len(args) == 0 {
|
|
args = []string{"migrate"}
|
|
}
|
|
env := DefaultPostgresEnvVarsWithPrefix(postgresConfig, r.Stack.GetServiceName(r.module.Name()), "")
|
|
if migration.AdditionalEnv != nil {
|
|
env = env.Append(migration.AdditionalEnv(r.ReconciliationConfig)...)
|
|
}
|
|
t.Spec = batchv1.JobSpec{
|
|
Template: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
RestartPolicy: corev1.RestartPolicyOnFailure,
|
|
Containers: []corev1.Container{{
|
|
Name: "migrate",
|
|
Image: r.Configuration.ResolveImage(GetImage(r.module.Name(), r.Versions.GetVersion(r.module.Name()))),
|
|
Args: args,
|
|
// There is only one service which use prefixed env var : ledger v1
|
|
// Since the ledger v1 auto handle migrations, we don't care about passing a prefix
|
|
Env: env.ToCoreEnv(),
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
func newModuleReconciler(stackReconciler *StackReconciler, module Module) *moduleReconciler {
|
|
return &moduleReconciler{
|
|
StackReconciler: stackReconciler,
|
|
module: module,
|
|
}
|
|
}
|
|
|
|
func ensureDeploymentSync(ctx context.Context, deployment appsv1.Deployment) (bool, error) {
|
|
logger := log.FromContext(ctx)
|
|
if deployment.Status.ObservedGeneration != deployment.Generation {
|
|
logger.Info(fmt.Sprintf("Stop reconciliation as deployment '%s' is not ready (generation not matching, generation: %d, observed: %d)",
|
|
deployment.Name, deployment.Generation, deployment.Status.ObservedGeneration))
|
|
return false, nil
|
|
}
|
|
if deployment.Spec.Replicas != nil && deployment.Status.UpdatedReplicas < *deployment.Spec.Replicas {
|
|
return false, fmt.Errorf("waiting for deployment %q rollout to finish: %d out of %d new replicas have been updated",
|
|
deployment.Name, deployment.Status.UpdatedReplicas, *deployment.Spec.Replicas)
|
|
}
|
|
if deployment.Status.Replicas > deployment.Status.UpdatedReplicas {
|
|
return false, fmt.Errorf("waiting for deployment %q rollout to finish: %d old replicas are pending termination",
|
|
deployment.Name, deployment.Status.Replicas-deployment.Status.UpdatedReplicas)
|
|
}
|
|
if deployment.Status.AvailableReplicas < deployment.Status.UpdatedReplicas {
|
|
return false, fmt.Errorf("waiting for deployment %q rollout to finish: %d of %d updated replicas are available",
|
|
deployment.Name, deployment.Status.AvailableReplicas, deployment.Status.UpdatedReplicas)
|
|
}
|
|
|
|
return true, nil
|
|
}
|