Skip to content

Commit

Permalink
Semver auto deploy schedules (#2311)
Browse files Browse the repository at this point in the history
* Semver auto deploy schedules
  • Loading branch information
sgalsaleh committed Nov 12, 2021
1 parent 17bef27 commit edd198d
Show file tree
Hide file tree
Showing 19 changed files with 396 additions and 84 deletions.
3 changes: 3 additions & 0 deletions migrations/tables/app.yaml
Expand Up @@ -80,3 +80,6 @@ spec:
- name: semver_auto_deploy
type: text
default: 'disabled'
- name: semver_auto_deploy_schedule
type: text
default: '@default'
29 changes: 15 additions & 14 deletions pkg/api/handlers/types/types.go
Expand Up @@ -18,20 +18,21 @@ type AppStatusResponse struct {
}

type ResponseApp struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
IsAirgap bool `json:"isAirgap"`
CurrentSequence int64 `json:"currentSequence"`
UpstreamURI string `json:"upstreamUri"`
IconURI string `json:"iconUri"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastUpdateCheckAt string `json:"lastUpdateCheckAt"`
HasPreflight bool `json:"hasPreflight"`
IsConfigurable bool `json:"isConfigurable"`
UpdateCheckerSpec string `json:"updateCheckerSpec"`
SemverAutoDeploy apptypes.SemverAutoDeploy `json:"semverAutoDeploy"`
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
IsAirgap bool `json:"isAirgap"`
CurrentSequence int64 `json:"currentSequence"`
UpstreamURI string `json:"upstreamUri"`
IconURI string `json:"iconUri"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt *time.Time `json:"updatedAt"`
LastUpdateCheckAt string `json:"lastUpdateCheckAt"`
HasPreflight bool `json:"hasPreflight"`
IsConfigurable bool `json:"isConfigurable"`
UpdateCheckerSpec string `json:"updateCheckerSpec"`
SemverAutoDeploy apptypes.SemverAutoDeploy `json:"semverAutoDeploy"`
SemverAutoDeploySchedule string `json:"semverAutoDeploySchedule"`

IsGitOpsSupported bool `json:"isGitOpsSupported"`
IsIdentityServiceSupported bool `json:"isIdentityServiceSupported"`
Expand Down
5 changes: 5 additions & 0 deletions pkg/apiserver/server.go
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/replicatedhq/kots/pkg/autodeployer"
"github.com/replicatedhq/kots/pkg/automation"
"github.com/replicatedhq/kots/pkg/binaries"
"github.com/replicatedhq/kots/pkg/handlers"
Expand Down Expand Up @@ -125,6 +126,10 @@ func Start(params *APIServerParams) {
log.Println("Failed to start update checker", err)
}

if err := autodeployer.Start(); err != nil {
log.Println("Failed to start auto deployer", err)
}

if err := snapshotscheduler.Start(); err != nil {
log.Println("Failed to start snapshot scheduler", err)
}
Expand Down
45 changes: 23 additions & 22 deletions pkg/app/types/types.go
Expand Up @@ -21,26 +21,27 @@ const (
)

type App struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
License string `json:"license"`
IsAirgap bool `json:"isAirgap"`
CurrentSequence int64 `json:"currentSequence"`
UpstreamURI string `json:"upstreamUri"`
IconURI string `json:"iconUri"`
UpdatedAt *time.Time `json:"createdAt"`
CreatedAt time.Time `json:"updatedAt"`
LastUpdateCheckAt string `json:"lastUpdateCheckAt"`
HasPreflight bool `json:"hasPreflight"`
IsConfigurable bool `json:"isConfigurable"`
SnapshotTTL string `json:"snapshotTtl"`
SnapshotSchedule string `json:"snapshotSchedule"`
RestoreInProgressName string `json:"restoreInProgressName"`
RestoreUndeployStatus UndeployStatus `json:"restoreUndeloyStatus"`
UpdateCheckerSpec string `json:"updateCheckerSpec"`
SemverAutoDeploy SemverAutoDeploy `json:"semverAutoDeploy"`
IsGitOps bool `json:"isGitOps"`
InstallState string `json:"installState"`
LastLicenseSync string `json:"lastLicenseSync"`
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
License string `json:"license"`
IsAirgap bool `json:"isAirgap"`
CurrentSequence int64 `json:"currentSequence"`
UpstreamURI string `json:"upstreamUri"`
IconURI string `json:"iconUri"`
UpdatedAt *time.Time `json:"createdAt"`
CreatedAt time.Time `json:"updatedAt"`
LastUpdateCheckAt string `json:"lastUpdateCheckAt"`
HasPreflight bool `json:"hasPreflight"`
IsConfigurable bool `json:"isConfigurable"`
SnapshotTTL string `json:"snapshotTtl"`
SnapshotSchedule string `json:"snapshotSchedule"`
RestoreInProgressName string `json:"restoreInProgressName"`
RestoreUndeployStatus UndeployStatus `json:"restoreUndeloyStatus"`
UpdateCheckerSpec string `json:"updateCheckerSpec"`
SemverAutoDeploy SemverAutoDeploy `json:"semverAutoDeploy"`
SemverAutoDeploySchedule string `json:"semverAutoDeploySchedule"`
IsGitOps bool `json:"isGitOps"`
InstallState string `json:"installState"`
LastLicenseSync string `json:"lastLicenseSync"`
}
259 changes: 259 additions & 0 deletions pkg/autodeployer/autodeployer.go
@@ -0,0 +1,259 @@
package autodeployer

import (
"sort"
"sync"

"github.com/blang/semver"
"github.com/pkg/errors"
downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types"
apptypes "github.com/replicatedhq/kots/pkg/app/types"
"github.com/replicatedhq/kots/pkg/logger"
"github.com/replicatedhq/kots/pkg/store"
storetypes "github.com/replicatedhq/kots/pkg/store/types"
"github.com/replicatedhq/kots/pkg/version"
cron "github.com/robfig/cron/v3"
"go.uber.org/zap"
)

// jobs maps app ids to their cron jobs
var jobs = make(map[string]*cron.Cron)
var mtx sync.Mutex

// Start will start the auto deployer
// the frequency of those update checks are app specific and can be modified by the user
func Start() error {
logger.Debug("starting auto deployer")

appsList, err := store.GetStore().ListInstalledApps()
if err != nil {
return errors.Wrap(err, "failed to list installed apps")
}

for _, a := range appsList {
if err := Configure(a.ID); err != nil {
logger.Error(errors.Wrapf(err, "failed to configure app %s", a.Slug))
}
}

return nil
}

// Configure will configure a cron job for semver auto deployment schedules based on the application's configuration
func Configure(appID string) error {
a, err := store.GetStore().GetApp(appID)
if err != nil {
return errors.Wrap(err, "failed to get app")
}

if a.IsAirgap {
return nil
}

if a.SemverAutoDeploy == "" || a.SemverAutoDeploy == apptypes.SemverAutoDeployDisabled {
return nil
}

logger.Debug("configure semver auto deployments for app",
zap.String("slug", a.Slug))

mtx.Lock()
defer mtx.Unlock()

cronSpec := a.SemverAutoDeploySchedule

if cronSpec == "" {
Stop(a.ID)
return nil
}

if cronSpec == "@default" {
// if automatic deployments are enabled, then by default updates are automatically deployed as soon as they're available
// if they meet the configured criteria, which happens as part of the automatic update check process
Stop(a.ID)
return nil
}

job, ok := jobs[a.ID]
if ok {
// job already exists, remove entries
entries := job.Entries()
for _, entry := range entries {
job.Remove(entry.ID)
}
} else {
// job does not exist, create a new one
job = cron.New(cron.WithChain(
cron.Recover(cron.DefaultLogger),
))
}

jobAppID := a.ID
jobAppSlug := a.Slug
jobSemverAutoDeploy := a.SemverAutoDeploy

_, err = job.AddFunc(cronSpec, func() {
logger.Debug("processing semver auto deployments for app", zap.String("slug", jobAppSlug))

opts := ExecuteOpts{
AppID: jobAppID,
SemverAutoDeploy: jobSemverAutoDeploy,
}
if err := execute(opts); err != nil {
logger.Error(errors.Wrapf(err, "failed to execute for app %s", jobAppSlug))
return
}
})
if err != nil {
return errors.Wrap(err, "failed to add func")
}

job.Start()
jobs[a.ID] = job

return nil
}

// Stop will stop a running cron job (if exists) for a specific app
func Stop(appID string) {
if jobs == nil {
logger.Debug("no cron jobs found")
return
}
if job, ok := jobs[appID]; ok {
job.Stop()
} else {
logger.Debug("cron job not found for app", zap.String("appID", appID))
}
}

type ExecuteOpts struct {
AppID string
SemverAutoDeploy apptypes.SemverAutoDeploy
}

func execute(opts ExecuteOpts) error {
a, err := store.GetStore().GetApp(opts.AppID)
if err != nil {
return errors.Wrap(err, "failed to get app")
}

downstreams, err := store.GetStore().ListDownstreamsForApp(a.ID)
if err != nil {
return errors.Wrap(err, "failed to list downstreams for app")
}
if len(downstreams) == 0 {
return errors.Errorf("no downstreams found for app %q", a.Slug)
}
d := downstreams[0]

currentVersion, err := store.GetStore().GetCurrentVersion(a.ID, d.ClusterID)
if err != nil {
return errors.Wrap(err, "failed to get current version")
}
if currentVersion == nil {
return nil
}

versions, err := store.GetStore().GetAppVersions(a.ID, d.ClusterID)
if err != nil {
return errors.Wrap(err, "failed to get pending versions")
}

sequence := findVersionToDeploy(opts, versions.PendingVersions, currentVersion.VersionLabel)
if sequence == -1 {
return nil
}

status, err := store.GetStore().GetStatusForVersion(a.ID, d.ClusterID, sequence)
if err != nil {
return errors.Wrap(err, "failed to get status for version")
}

if status == storetypes.VersionPendingConfig {
logger.Infof("not deploying version %d because it's %s", sequence, status)
return nil
}

if err := version.DeployVersion(a.ID, sequence); err != nil {
return errors.Wrap(err, "failed to queue version for deployment")
}

return nil
}

type PendingVersion struct {
Semver semver.Version
Sequence int64
}

type PendingVersions []PendingVersion

func (s PendingVersions) Len() int { return len(s) }

func (s PendingVersions) Less(i, j int) bool {
return s[i].Semver.LT(s[j].Semver)
}

func (s PendingVersions) Swap(i, j int) {
tmp := s[i]
s[i] = s[j]
s[j] = tmp
}

// findVersionToDeploy will return the sequence number for the version that satisfies the semver auto deploy criteria or -1 if no match was found
func findVersionToDeploy(opts ExecuteOpts, versions []*downstreamtypes.DownstreamVersion, currentVersionLabel string) int64 {
currentSemver, err := semver.ParseTolerant(currentVersionLabel)
if err != nil {
return -1
}

pendingVersions := PendingVersions{}

for _, v := range versions {
switch opts.SemverAutoDeploy {
case apptypes.SemverAutoDeployPatch:
s, err := semver.ParseTolerant(v.VersionLabel)
if err != nil {
continue
}
if s.Major != currentSemver.Major || s.Minor != currentSemver.Minor {
continue
}
pendingVersions = append(pendingVersions, PendingVersion{
Semver: s,
Sequence: v.Sequence,
})

case apptypes.SemverAutoDeployMinorPatch:
s, err := semver.ParseTolerant(v.VersionLabel)
if err != nil {
continue
}
if s.Major != currentSemver.Major {
continue
}
pendingVersions = append(pendingVersions, PendingVersion{
Semver: s,
Sequence: v.Sequence,
})

case apptypes.SemverAutoDeployMajorMinorPatch:
s, err := semver.ParseTolerant(v.VersionLabel)
if err != nil {
continue
}
pendingVersions = append(pendingVersions, PendingVersion{
Semver: s,
Sequence: v.Sequence,
})
}
}

if len(pendingVersions) == 0 {
return -1
}
sort.Sort(sort.Reverse(pendingVersions))

return pendingVersions[0].Sequence
}
1 change: 1 addition & 0 deletions pkg/handlers/app.go
Expand Up @@ -275,6 +275,7 @@ func responseAppFromApp(a *apptypes.App) (*types.ResponseApp, error) {
IsConfigurable: a.IsConfigurable,
UpdateCheckerSpec: a.UpdateCheckerSpec,
SemverAutoDeploy: a.SemverAutoDeploy,
SemverAutoDeploySchedule: a.SemverAutoDeploySchedule,
IsGitOpsSupported: license.Spec.IsGitOpsSupported,
IsIdentityServiceSupported: license.Spec.IsIdentityServiceSupported,
IsAppIdentityServiceSupported: isAppIdentityServiceSupported,
Expand Down

0 comments on commit edd198d

Please sign in to comment.