From 183095136ac3cad48ce2e814c8158d75e0ccc673 Mon Sep 17 00:00:00 2001 From: Guiheux Steven Date: Thu, 8 Oct 2020 09:33:57 +0200 Subject: [PATCH] feat(api,ui): add workflow retention policy (#5474) --- cli/cdsctl/admin.go | 1 + cli/cdsctl/admin_workflow.go | 42 ++++ .../docs/concepts/files/workflow-syntax.md | 5 + .../workflow/images/workflow_retention.png | Bin 0 -> 26312 bytes .../docs/concepts/workflow/retention.md | 25 +++ engine/api/admin.go | 31 +++ engine/api/admin_test.go | 38 ++++ engine/api/api.go | 5 +- engine/api/api_routes.go | 3 + engine/api/purge/purge.go | 63 +++++- engine/api/purge/purge_run.go | 200 ++++++++++++++++++ engine/api/purge/purge_test.go | 28 ++- engine/api/workflow.go | 98 +++++++++ engine/api/workflow/dao.go | 17 +- engine/api/workflow/dao_run.go | 35 +-- engine/api/workflow/init.go | 4 +- engine/api/workflow_export_test.go | 2 + engine/api/workflow_purge_test.go | 97 +++++++++ engine/api/workflow_queue_test.go | 2 +- engine/api/workflow_run_craft.go | 53 ++++- engine/api/workflow_run_test.go | 46 ++++ .../sql/api/218_workflow_retention_policy.sql | 12 ++ sdk/cdsclient/client_admin.go | 9 + sdk/cdsclient/interface.go | 1 + .../mock_cdsclient/interface_mock.go | 30 ++- sdk/exportentities/v2/workflow.go | 13 +- sdk/exportentities/v2/workflow_test.go | 47 ++-- sdk/luascript/luascript.go | 10 +- sdk/messages.go | 2 + sdk/purge.go | 15 ++ sdk/workflow.go | 2 + ui/src/app/model/purge.model.ts | 8 + ui/src/app/model/workflow.model.ts | 2 + .../app/service/workflow/workflow.service.ts | 12 ++ ui/src/app/store/feature.state.ts | 9 + ui/src/app/store/store.module.ts | 2 +- .../admin/broadcast/list/broadcast.list.html | 5 +- .../node/pipeline/node.pipeline.component.ts | 1 - .../pipeline/service/service.log.component.ts | 1 - .../workflow/run/workflow.run.component.ts | 8 +- .../app/views/workflow/run/workflow.run.html | 146 +++++++------ .../show/admin/workflow.admin.component.html | 70 +++--- .../show/admin/workflow.admin.component.ts | 85 +++++++- .../workflow/show/admin/workflow.admin.scss | 8 + .../app/views/workflow/workflow.component.ts | 27 ++- ui/src/assets/i18n/en.json | 14 +- ui/src/assets/i18n/fr.json | 10 +- 47 files changed, 1145 insertions(+), 199 deletions(-) create mode 100644 cli/cdsctl/admin_workflow.go create mode 100644 docs/content/docs/concepts/workflow/images/workflow_retention.png create mode 100644 docs/content/docs/concepts/workflow/retention.md create mode 100644 engine/api/purge/purge_run.go create mode 100644 engine/api/workflow_purge_test.go create mode 100644 engine/sql/api/218_workflow_retention_policy.sql create mode 100644 sdk/purge.go create mode 100644 ui/src/app/model/purge.model.ts diff --git a/cli/cdsctl/admin.go b/cli/cdsctl/admin.go index 2b92d49bb6..d0b6f933d7 100644 --- a/cli/cdsctl/admin.go +++ b/cli/cdsctl/admin.go @@ -26,6 +26,7 @@ func adminCommands() []*cobra.Command { adminErrors(), adminCurl(), adminFeatures(), + adminWorkflows(), } } diff --git a/cli/cdsctl/admin_workflow.go b/cli/cdsctl/admin_workflow.go new file mode 100644 index 0000000000..0d72845929 --- /dev/null +++ b/cli/cdsctl/admin_workflow.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/ovh/cds/cli" + "github.com/spf13/cobra" +) + +var adminWorkflowsCmd = cli.Command{ + Name: "workflows", + Aliases: []string{"workflow"}, + Short: "Manage CDS workflows", +} + +func adminWorkflows() *cobra.Command { + return cli.NewCommand(adminWorkflowsCmd, nil, []*cobra.Command{ + cli.NewCommand(adminWorkflowUpdateMaxRunCmd, adminWorkflowUpdateMaxRun, nil), + }) +} + +var adminWorkflowUpdateMaxRunCmd = cli.Command{ + Name: "maxrun", + Short: "Update the maximum number of workflow executions", + Args: []cli.Arg{ + { + Name: "projectKey", + }, + { + Name: "workflowName", + }, + { + Name: "maxRuns", + }, + }, +} + +func adminWorkflowUpdateMaxRun(v cli.Values) error { + maxRuns, err := v.GetInt64("maxRuns") + if err != nil { + return err + } + return client.AdminWorkflowUpdateMaxRuns(v.GetString("projectKey"), v.GetString("workflowName"), maxRuns) +} diff --git a/docs/content/docs/concepts/files/workflow-syntax.md b/docs/content/docs/concepts/files/workflow-syntax.md index e6d7f96d77..2ab4d9a10b 100644 --- a/docs/content/docs/concepts/files/workflow-syntax.md +++ b/docs/content/docs/concepts/files/workflow-syntax.md @@ -35,6 +35,7 @@ notifications: on_success: never recipients: - me@foo.bar +retention_policy: return run_days_before < 7 ``` There are two major things to understand: `workflow` and `hooks`. A workflow is a kind of graph starting from a root pipeline, and other pipelines with dependencies. In this example, the `deploy` pipeline will be triggered after the `build` pipeline. @@ -143,3 +144,7 @@ workflow: # ... one_at_a_time: true # No concurent deployments ``` + +## Retention Policy + +[Retention documentation]({{}}) diff --git a/docs/content/docs/concepts/workflow/images/workflow_retention.png b/docs/content/docs/concepts/workflow/images/workflow_retention.png new file mode 100644 index 0000000000000000000000000000000000000000..9d212afca7fe726f0fe25330469358f3279d678c GIT binary patch literal 26312 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 Error: %v", err) } log.Debug("purge> Deleting all workflow run marked to delete...") - if err := deleteWorkflowRunsHistory(ctx, DBFunc(), store, sharedStorage, workflowRunsDeleted); err != nil { + if err := deleteWorkflowRunsHistory(ctx, DBFunc(), sharedStorage, workflowRunsDeleted); err != nil { log.Warning(ctx, "purge> Error on deleteWorkflowRunsHistory : %v", err) } @@ -51,13 +65,48 @@ func Initialize(ctx context.Context, store cache.Store, DBFunc func() *gorp.DbMa } } +// Deprecated: old method to mark runs to delete +func MarkWorkflowRuns(ctx context.Context, db *gorp.DbMap, workflowRunsMarkToDelete *stats.Int64Measure) error { + dao := new(workflow.WorkflowDAO) + dao.Filters.DisableFilterDeletedWorkflow = false + wfs, err := dao.LoadAll(ctx, db) + if err != nil { + return err + } + for _, wf := range wfs { + enabled := featureflipping.IsEnabled(ctx, gorpmapping.Mapper, db, FeaturePurgeName, map[string]string{"project_key": wf.ProjectKey}) + if enabled { + continue + } + tx, err := db.Begin() + if err != nil { + log.Error(ctx, "workflow.PurgeWorkflowRuns> error %v", err) + tx.Rollback() // nolint + continue + } + if err := workflow.PurgeWorkflowRun(ctx, tx, wf); err != nil { + log.Error(ctx, "workflow.PurgeWorkflowRuns> error %v", err) + tx.Rollback() // nolint + continue + } + if err := tx.Commit(); err != nil { + log.Error(ctx, "workflow.PurgeWorkflowRuns> unable to commit transaction: %v", err) + _ = tx.Rollback() + continue + } + } + + workflow.CountWorkflowRunsMarkToDelete(ctx, db, workflowRunsMarkToDelete) + return nil +} + // workflows purges all marked workflows func workflows(ctx context.Context, db *gorp.DbMap, store cache.Store, workflowRunsMarkToDelete *stats.Int64Measure) error { query := "SELECT id, project_id FROM workflow WHERE to_delete = true ORDER BY id ASC" - res := []struct { + var res []struct { ID int64 `db:"id"` ProjectID int64 `db:"project_id"` - }{} + } if _, err := db.Select(&res, query); err != nil { if err == sql.ErrNoRows { @@ -147,7 +196,7 @@ func workflows(ctx context.Context, db *gorp.DbMap, store cache.Store, workflowR } // deleteWorkflowRunsHistory is useful to delete all the workflow run marked with to delete flag in db -func deleteWorkflowRunsHistory(ctx context.Context, db *gorp.DbMap, store cache.Store, sharedStorage objectstore.Driver, workflowRunsDeleted *stats.Int64Measure) error { +func deleteWorkflowRunsHistory(ctx context.Context, db *gorp.DbMap, sharedStorage objectstore.Driver, workflowRunsDeleted *stats.Int64Measure) error { var workflowRunIDs []int64 if _, err := db.Select(&workflowRunIDs, "SELECT id FROM workflow_run WHERE to_delete = true ORDER BY id ASC LIMIT 2000"); err != nil { return err @@ -166,7 +215,7 @@ func deleteWorkflowRunsHistory(ctx context.Context, db *gorp.DbMap, store cache. log.Error(ctx, "deleteWorkflowRunsHistory> error while opening transaction : %v", err) continue } - if err := DeleteArtifacts(ctx, tx, store, sharedStorage, workflowRunID); err != nil { + if err := DeleteArtifacts(ctx, tx, sharedStorage, workflowRunID); err != nil { log.Error(ctx, "deleteWorkflowRunsHistory> error while deleting artifacts: %v", err) _ = tx.Rollback() continue @@ -202,7 +251,7 @@ func deleteWorkflowRunsHistory(ctx context.Context, db *gorp.DbMap, store cache. } // DeleteArtifacts removes artifacts from storage -func DeleteArtifacts(ctx context.Context, db gorp.SqlExecutor, store cache.Store, sharedStorage objectstore.Driver, workflowRunID int64) error { +func DeleteArtifacts(ctx context.Context, db gorp.SqlExecutor, sharedStorage objectstore.Driver, workflowRunID int64) error { wr, err := workflow.LoadRunByID(db, workflowRunID, workflow.LoadRunOptions{WithArtifacts: true, DisableDetailledNodeRun: false, WithDeleted: true}) if err != nil { return sdk.WrapError(err, "error on load LoadRunByID:%d", workflowRunID) diff --git a/engine/api/purge/purge_run.go b/engine/api/purge/purge_run.go new file mode 100644 index 0000000000..995c4fdf08 --- /dev/null +++ b/engine/api/purge/purge_run.go @@ -0,0 +1,200 @@ +package purge + +import ( + "context" + "fmt" + "math" + "strconv" + "time" + + "github.com/fsamin/go-dump" + "github.com/go-gorp/gorp" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/api/repositoriesmanager" + "github.com/ovh/cds/engine/api/workflow" + "github.com/ovh/cds/engine/cache" + "github.com/ovh/cds/engine/featureflipping" + "github.com/ovh/cds/sdk" + "github.com/ovh/cds/sdk/log" + "github.com/ovh/cds/sdk/luascript" + "github.com/sirupsen/logrus" + "go.opencensus.io/stats" +) + +type MarkAsDeleteOptions struct { + DryRun bool +} + +const ( + RunStatus = "run_status" + RunDateBefore = "run_days_before" + RunGitBranchExist = "git_branch_exist" +) + +func GetRetetionPolicyVariables() []string { + return []string{RunDateBefore, RunStatus, RunGitBranchExist} +} + +func markWorkflowRunsToDelete(ctx context.Context, store cache.Store, db *gorp.DbMap, workflowRunsMarkToDelete *stats.Int64Measure) error { + dao := new(workflow.WorkflowDAO) + wfs, err := dao.LoadAll(ctx, db) + if err != nil { + return err + } + for _, wf := range wfs { + enabled := featureflipping.IsEnabled(ctx, gorpmapping.Mapper, db, FeaturePurgeName, map[string]string{"project_key": wf.ProjectKey}) + if !enabled { + continue + } + if _, err := ApplyRetentionPolicyOnWorkflow(ctx, store, db, wf, MarkAsDeleteOptions{DryRun: false}); err != nil { + log.ErrorWithFields(ctx, logrus.Fields{"stack_trace": fmt.Sprintf("%+v", err)}, "%s", err) + } + } + workflow.CountWorkflowRunsMarkToDelete(ctx, db, workflowRunsMarkToDelete) + return nil +} + +func ApplyRetentionPolicyOnWorkflow(ctx context.Context, store cache.Store, db *gorp.DbMap, wf sdk.Workflow, opts MarkAsDeleteOptions) ([]sdk.WorkflowRunToKeep, error) { + runsTokeep := make([]sdk.WorkflowRunToKeep, 0) + + limit := 50 + offset := 0 + + branches, err := getBranchesForWorkflow(ctx, store, db, wf) + if err != nil { + return nil, err + } + branchesMap := make(map[string]struct{}) + for _, b := range branches { + branchesMap[b.DisplayID] = struct{}{} + } + + for { + wfRuns, _, _, count, err := workflow.LoadRuns(db, wf.ProjectKey, wf.Name, offset, limit, nil) + if err != nil { + return nil, err + } + + for _, run := range wfRuns { + keep, err := applyRetentionPolicyOnRun(db, wf, run, branchesMap, opts) + if err != nil { + return nil, err + } + if keep { + runsTokeep = append(runsTokeep, sdk.WorkflowRunToKeep{ID: run.ID, Num: run.Number, Status: run.Status}) + } + } + + if count > offset+limit { + offset += limit + continue + } + break + } + return runsTokeep, nil +} + +func applyRetentionPolicyOnRun(db *gorp.DbMap, wf sdk.Workflow, run sdk.WorkflowRun, branchesMap map[string]struct{}, opts MarkAsDeleteOptions) (bool, error) { + if wf.ToDelete && !opts.DryRun { + if err := workflow.MarkWorkflowRunsAsDelete(db, []int64{run.ID}); err != nil { + return false, sdk.WithStack(err) + } + return false, nil + } + luacheck, err := luascript.NewCheck() + if err != nil { + return true, sdk.WithStack(err) + } + + if err := purgeComputeVariables(luacheck, run, branchesMap); err != nil { + return true, err + } + + if err := luacheck.Perform(wf.RetentionPolicy); err != nil { + return true, sdk.NewErrorFrom(sdk.ErrWrongRequest, "%v", err) + } + + if luacheck.Result { + return true, nil + } + if !opts.DryRun { + if err := workflow.MarkWorkflowRunsAsDelete(db, []int64{run.ID}); err != nil { + return true, sdk.WithStack(err) + } + } + return false, nil +} + +func purgeComputeVariables(luaCheck *luascript.Check, run sdk.WorkflowRun, branchesMap map[string]struct{}) error { + vars := make(map[string]string) + varsFloats := make(map[string]float64) + + // Add payload as variable + if run.ToCraftOpts != nil { + switch { + case run.ToCraftOpts.Manual != nil: + payload := run.ToCraftOpts.Manual.Payload + if payload != nil { + // COMPUTE PAYLOAD + e := dump.NewDefaultEncoder() + e.Formatters = []dump.KeyFormatterFunc{dump.WithDefaultLowerCaseFormatter()} + e.ExtraFields.DetailedMap = false + e.ExtraFields.DetailedStruct = false + e.ExtraFields.Len = false + e.ExtraFields.Type = false + tmpVars, err := e.ToStringMap(payload) + if err != nil { + return sdk.WithStack(err) + } + for k, v := range tmpVars { + vars[k] = v + } + } + case run.ToCraftOpts.Hook != nil && run.ToCraftOpts.Hook.Payload != nil: + vars = run.ToCraftOpts.Hook.Payload + } + + } + + // If we have a branch in payload, check if it exists on repository branches list + if b, has := vars["git.branch"]; has { + _, exist := branchesMap[b] + vars[RunGitBranchExist] = strconv.FormatBool(exist) + } + vars[RunStatus] = run.Status + + varsFloats[RunDateBefore] = math.Floor(time.Now().Sub(run.LastModified).Hours() / 24) + + luaCheck.SetVariables(vars) + luaCheck.SetFloatVariables(varsFloats) + return nil +} + +func getBranchesForWorkflow(ctx context.Context, store cache.Store, db *gorp.DbMap, wf sdk.Workflow) ([]sdk.VCSBranch, error) { + appID := wf.WorkflowData.Node.Context.ApplicationID + if appID != 0 { + app := wf.Applications[appID] + if app.RepositoryFullname != "" { + tx, err := db.Begin() + if err != nil { + return nil, sdk.WithStack(err) + } + defer tx.Rollback() + //Get the RepositoriesManager Client + vcsServer, err := repositoriesmanager.LoadProjectVCSServerLinkByProjectKeyAndVCSServerName(ctx, tx, wf.ProjectKey, app.VCSServer) + if err != nil { + log.Debug("SendVCSEvent> No vcsServer found: %v", err) + return nil, err + } + client, err := repositoriesmanager.AuthorizedClient(ctx, tx, store, wf.ProjectKey, vcsServer) + if err != nil { + return nil, sdk.WithStack(err) + } + + branches, err := client.Branches(ctx, app.RepositoryFullname) + return branches, sdk.WithStack(err) + } + } + return nil, nil +} diff --git a/engine/api/purge/purge_test.go b/engine/api/purge/purge_test.go index 3109849b63..8a736556f9 100644 --- a/engine/api/purge/purge_test.go +++ b/engine/api/purge/purge_test.go @@ -2,9 +2,12 @@ package purge import ( "context" + "github.com/ovh/cds/sdk" + "github.com/stretchr/testify/require" "os" "path" "testing" + "time" "github.com/ovh/cds/engine/api/bootstrap" "github.com/ovh/cds/engine/api/objectstore" @@ -12,7 +15,7 @@ import ( ) func Test_deleteWorkflowRunsHistory(t *testing.T) { - db, cache := test.SetupPG(t, bootstrap.InitiliazeDB) + db, _ := test.SetupPG(t, bootstrap.InitiliazeDB) // Init store cfg := objectstore.Config{ @@ -27,8 +30,29 @@ func Test_deleteWorkflowRunsHistory(t *testing.T) { sharedStorage, errO := objectstore.Init(context.Background(), cfg) test.NoError(t, errO) - err := deleteWorkflowRunsHistory(context.Background(), db.DbMap, cache, sharedStorage, nil) + err := deleteWorkflowRunsHistory(context.Background(), db.DbMap, sharedStorage, nil) test.NoError(t, err) // test on delete artifact from storage is done on Test_postWorkflowJobArtifactHandler } + +func Test_applyRetentionPolicyOnRun(t *testing.T) { + db, _ := test.SetupPG(t, bootstrap.InitiliazeDB) + wf := sdk.Workflow{ + RetentionPolicy: "return run_days_before < 2", + } + now := time.Now() + run1 := sdk.WorkflowRun{ + LastModified: now.Add(-49 * time.Hour), + } + keep, err := applyRetentionPolicyOnRun(db.DbMap, wf, run1, nil, MarkAsDeleteOptions{DryRun: true}) + require.NoError(t, err) + require.False(t, keep) + + run2 := sdk.WorkflowRun{ + LastModified: now.Add(-47 * time.Hour), + } + keep, err = applyRetentionPolicyOnRun(db.DbMap, wf, run2, nil, MarkAsDeleteOptions{DryRun: true}) + require.NoError(t, err) + require.True(t, keep) +} diff --git a/engine/api/workflow.go b/engine/api/workflow.go index 8fa59e630f..5762826db3 100644 --- a/engine/api/workflow.go +++ b/engine/api/workflow.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/fsamin/go-dump" "github.com/go-gorp/gorp" "github.com/gorilla/mux" @@ -19,6 +20,7 @@ import ( "github.com/ovh/cds/engine/api/permission" "github.com/ovh/cds/engine/api/pipeline" "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/engine/api/purge" "github.com/ovh/cds/engine/api/services" "github.com/ovh/cds/engine/api/workflow" "github.com/ovh/cds/engine/service" @@ -92,6 +94,102 @@ func (api *API) setWorkflowURLs(w1 *sdk.Workflow) { } } +func (api *API) getRetentionPolicySuggestionHandler() service.Handler { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + key := vars["key"] + name := vars["permWorkflowName"] + + proj, err := project.Load(ctx, api.mustDBWithCtx(ctx), key) + if err != nil { + return err + } + + varsPayload := make(map[string]string, 0) + run, err := workflow.LoadLastRun(api.mustDB(), key, name, workflow.LoadRunOptions{DisableDetailledNodeRun: true}) + if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) { + return err + } + + e := dump.NewDefaultEncoder() + e.Formatters = []dump.KeyFormatterFunc{dump.WithDefaultLowerCaseFormatter()} + e.ExtraFields.DetailedMap = false + e.ExtraFields.DetailedStruct = false + e.ExtraFields.Len = false + e.ExtraFields.Type = false + + if run != nil && run.ToCraftOpts != nil { + if run.ToCraftOpts.Hook != nil { + varsPayload = run.ToCraftOpts.Hook.Payload + } + if run.ToCraftOpts.Manual != nil { + payload := run.ToCraftOpts.Manual.Payload + if payload != nil { + tmpVars, err := e.ToStringMap(payload) + if err != nil { + return sdk.WithStack(err) + } + for k, v := range tmpVars { + varsPayload[k] = v + } + } + } + } + if len(varsPayload) == 0 { + wf, err := workflow.Load(ctx, api.mustDBWithCtx(ctx), api.Cache, *proj, name, workflow.LoadOptions{}) + if err != nil { + return err + } + if wf.WorkflowData.Node.Context.DefaultPayload != nil { + tmpVars, err := e.ToStringMap(wf.WorkflowData.Node.Context.DefaultPayload) + if err != nil { + return sdk.WithStack(err) + } + for k, v := range tmpVars { + varsPayload[k] = v + } + } + } + + retentionPolicySuggestion := purge.GetRetetionPolicyVariables() + for k := range varsPayload { + retentionPolicySuggestion = append(retentionPolicySuggestion, k) + } + + return service.WriteJSON(w, retentionPolicySuggestion, http.StatusOK) + } +} + +func (api *API) postWorkflowRetentionPolicyDryRun() service.Handler { + return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + key := vars["key"] + name := vars["permWorkflowName"] + + var request sdk.PurgeDryRunRequest + if err := service.UnmarshalBody(r, &request); err != nil { + return err + } + + proj, err := project.Load(ctx, api.mustDBWithCtx(ctx), key) + if err != nil { + return err + } + + wf, err := workflow.Load(ctx, api.mustDBWithCtx(ctx), api.Cache, *proj, name, workflow.LoadOptions{}) + if err != nil { + return err + } + + wf.RetentionPolicy = request.RetentionPolicy + runs, err := purge.ApplyRetentionPolicyOnWorkflow(ctx, api.Cache, api.mustDBWithCtx(ctx), *wf, purge.MarkAsDeleteOptions{DryRun: true}) + if err != nil { + return err + } + return service.WriteJSON(w, runs, http.StatusOK) + } +} + // getWorkflowHandler returns a full workflow func (api *API) getWorkflowHandler() service.Handler { return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error { diff --git a/engine/api/workflow/dao.go b/engine/api/workflow/dao.go index 129f1f00f8..010e2ca108 100644 --- a/engine/api/workflow/dao.go +++ b/engine/api/workflow/dao.go @@ -268,6 +268,11 @@ func LoadByID(ctx context.Context, db gorp.SqlExecutor, store cache.Store, proj return &ws, nil } +func UpdateMaxRunsByID(db gorp.SqlExecutor, workflowID int64, maxRuns int64) error { + _, err := db.Exec("UPDATE workflow set max_runs = $1 WHERE id = $2", maxRuns, workflowID) + return sdk.WithStack(err) +} + // Insert inserts a new workflow func Insert(ctx context.Context, db gorpmapper.SqlExecutorWithTx, store cache.Store, proj sdk.Project, w *sdk.Workflow) error { if err := CompleteWorkflow(ctx, db, w, proj, LoadOptions{}); err != nil { @@ -288,14 +293,16 @@ func Insert(ctx context.Context, db gorpmapper.SqlExecutorWithTx, store cache.St if w.HistoryLength == 0 { w.HistoryLength = sdk.DefaultHistoryLength } + w.MaxRuns = maxRuns + w.RetentionPolicy = "return (git_branch_exist == \"false\" and run_days_before < 2) or run_days_before < 365" w.LastModified = time.Now() if err := db.QueryRow(`INSERT INTO workflow ( - name, description, icon, project_id, history_length, from_repository, purge_tags, workflow_data, metadata + name, description, icon, project_id, history_length, from_repository, purge_tags, workflow_data, metadata, retention_policy, max_runs ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`, - w.Name, w.Description, w.Icon, w.ProjectID, w.HistoryLength, w.FromRepository, w.PurgeTags, w.WorkflowData, w.Metadata).Scan(&w.ID); err != nil { + w.Name, w.Description, w.Icon, w.ProjectID, w.HistoryLength, w.FromRepository, w.PurgeTags, w.WorkflowData, w.Metadata, w.RetentionPolicy, w.MaxRuns).Scan(&w.ID); err != nil { return sdk.WrapError(err, "Unable to insert workflow %s/%s", w.ProjectKey, w.Name) } @@ -602,6 +609,10 @@ func Update(ctx context.Context, db gorpmapper.SqlExecutorWithTx, store cache.St if err != nil { return sdk.WrapError(err, "Unable to load existing workflow with proj:%s ID:%d", proj.Key, wf.ID) } + + // Keep MaxRun + wf.MaxRuns = oldWf.MaxRuns + if err := DeleteWorkflowData(db, *oldWf); err != nil { return sdk.WrapError(err, "unable to delete from old workflow data(%d - %s)", wf.ID, wf.Name) } diff --git a/engine/api/workflow/dao_run.go b/engine/api/workflow/dao_run.go index eaaa4a8a4e..64c1f291d7 100644 --- a/engine/api/workflow/dao_run.go +++ b/engine/api/workflow/dao_run.go @@ -878,6 +878,11 @@ func purgeWorkflowRunWithoutTags(ctx context.Context, db gorp.SqlExecutor, wf sd return nil } +func CountNotPendingWorkflowRunsByWorkflowID(db gorp.SqlExecutor, workflowID int64) (int64, error) { + n, err := db.SelectInt("SELECT COUNT(id) FROM workflow_run WHERE workflow_id = $1 AND to_delete = false AND status <> $2", workflowID, sdk.StatusPending) + return n, sdk.WithStack(err) +} + func CountWorkflowRunsMarkToDelete(ctx context.Context, db gorp.SqlExecutor, workflowRunsMarkToDelete *stats.Int64Measure) int64 { n, err := db.SelectInt("select count(1) from workflow_run where to_delete = true") if err != nil { @@ -1061,33 +1066,3 @@ func stopRunsBlocked(ctx context.Context, db *gorp.DbMap) error { } return nil } - -func PurgeWorkflowRuns(ctx context.Context, db *gorp.DbMap, workflowRunsMarkToDelete *stats.Int64Measure) error { - dao := new(WorkflowDAO) - dao.Filters.DisableFilterDeletedWorkflow = false - wfs, err := dao.LoadAll(ctx, db) - if err != nil { - return err - } - for _, wf := range wfs { - tx, err := db.Begin() - defer tx.Rollback() // nolint - if err != nil { - log.Error(ctx, "workflow.PurgeWorkflowRuns> error %v", err) - tx.Rollback() // nolint - continue - } - if err := PurgeWorkflowRun(ctx, tx, wf); err != nil { - log.Error(ctx, "workflow.PurgeWorkflowRuns> error %v", err) - tx.Rollback() // nolint - continue - } - if err := tx.Commit(); err != nil { - log.Error(ctx, "workflow.PurgeWorkflowRuns> unable to commit transaction: %v", err) - continue - } - } - - CountWorkflowRunsMarkToDelete(ctx, db, workflowRunsMarkToDelete) - return nil -} diff --git a/engine/api/workflow/init.go b/engine/api/workflow/init.go index 3e0cfdf52e..bd1110855c 100644 --- a/engine/api/workflow/init.go +++ b/engine/api/workflow/init.go @@ -11,12 +11,14 @@ import ( ) var baseUIURL, defaultOS, defaultArch string +var maxRuns int64 //Initialize starts goroutines for workflows -func Initialize(ctx context.Context, DBFunc func() *gorp.DbMap, store cache.Store, uiURL, confDefaultOS, confDefaultArch string, maxLogSize int64) { +func Initialize(ctx context.Context, DBFunc func() *gorp.DbMap, store cache.Store, uiURL, confDefaultOS, confDefaultArch string, maxLogSize int64, confMaxRuns int64) { baseUIURL = uiURL defaultOS = confDefaultOS defaultArch = confDefaultArch + maxRuns = confMaxRuns tickStop := time.NewTicker(30 * time.Minute) tickHeart := time.NewTicker(10 * time.Second) defer tickHeart.Stop() diff --git a/engine/api/workflow_export_test.go b/engine/api/workflow_export_test.go index bddd944030..234f8a44c7 100644 --- a/engine/api/workflow_export_test.go +++ b/engine/api/workflow_export_test.go @@ -145,6 +145,7 @@ workflow: depends_on: - fork pipeline: pip1 +retention_policy: return (git_branch_exist == "false" and run_days_before < 2) or run_days_before < 365 `, rec.Body.String()) } @@ -278,6 +279,7 @@ workflow: pipeline: pip1 permissions: Test_getWorkflowExportHandlerWithPermissions-Group2: 7 +retention_policy: return (git_branch_exist == "false" and run_days_before < 2) or run_days_before < 365 history_length: 25 `, rec.Body.String()) diff --git a/engine/api/workflow_purge_test.go b/engine/api/workflow_purge_test.go new file mode 100644 index 0000000000..c37a7e2ce1 --- /dev/null +++ b/engine/api/workflow_purge_test.go @@ -0,0 +1,97 @@ +package api + +import ( + "context" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ovh/cds/engine/api/pipeline" + "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/engine/api/test" + "github.com/ovh/cds/engine/api/test/assets" + "github.com/ovh/cds/engine/api/workflow" + "github.com/ovh/cds/sdk" +) + +func Test_purgeDryRunHandler(t *testing.T) { + api, db, _ := newTestAPI(t) + + u, pass := assets.InsertAdminUser(t, db) + key := sdk.RandomString(10) + proj := assets.InsertTestProject(t, db, api.Cache, key, key) + + //First pipeline + pip := sdk.Pipeline{ + ProjectID: proj.ID, + ProjectKey: proj.Key, + Name: "pip1", + } + test.NoError(t, pipeline.InsertPipeline(api.mustDB(), &pip)) + + w := sdk.Workflow{ + Name: sdk.RandomString(10), + ProjectID: proj.ID, + ProjectKey: proj.Key, + WorkflowData: sdk.WorkflowData{ + Node: sdk.Node{ + Type: sdk.NodeTypePipeline, + Context: &sdk.NodeContext{ + PipelineID: pip.ID, + }, + }, + }, + } + test.NoError(t, workflow.RenameNode(context.Background(), db, &w)) + + proj, _ = project.Load(context.TODO(), api.mustDB(), proj.Key, + project.LoadOptions.WithPipelines, + project.LoadOptions.WithGroups, + ) + + require.NoError(t, workflow.Insert(context.TODO(), db, api.Cache, *proj, &w)) + require.NoError(t, workflow.UpdateMaxRunsByID(db, w.ID, 10)) + w1, err := workflow.Load(context.TODO(), api.mustDB(), api.Cache, *proj, w.Name, workflow.LoadOptions{}) + test.NoError(t, err) + + run1, err := workflow.CreateRun(api.mustDB(), w1, sdk.WorkflowRunPostHandlerOption{Hook: &sdk.WorkflowNodeRunHookEvent{}}) + require.NoError(t, err) + + run2, err := workflow.CreateRun(api.mustDB(), w1, sdk.WorkflowRunPostHandlerOption{Hook: &sdk.WorkflowNodeRunHookEvent{}}) + require.NoError(t, err) + + run1.Status = sdk.StatusSuccess + require.NoError(t, workflow.UpdateWorkflowRunStatus(api.mustDB(), run1)) + + run2.Status = sdk.StatusFail + require.NoError(t, workflow.UpdateWorkflowRunStatus(api.mustDB(), run2)) + + //Prepare request + vars := map[string]string{ + "key": proj.Key, + "permWorkflowName": w1.Name, + } + request := sdk.PurgeDryRunRequest{RetentionPolicy: "return run_status == 'Success'"} + uri := api.Router.GetRoute("POST", api.postWorkflowRetentionPolicyDryRun, vars) + test.NotEmpty(t, uri) + req := assets.NewAuthentifiedRequest(t, u, pass, "POST", uri, request) + + //Do the request + rec := httptest.NewRecorder() + api.Router.Mux.ServeHTTP(rec, req) + assert.Equal(t, 200, rec.Code) + + var result []sdk.WorkflowRunToKeep + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &result)) + + require.Len(t, result, 1) + require.Equal(t, run1.ID, result[0].ID) + + run1DB, err := workflow.LoadRunByID(api.mustDB(), run2.ID, workflow.LoadRunOptions{DisableDetailledNodeRun: true}) + require.NoError(t, err) + require.False(t, run1DB.ToDelete) + +} diff --git a/engine/api/workflow_queue_test.go b/engine/api/workflow_queue_test.go index 23a3c9c829..b1d38ac7b0 100644 --- a/engine/api/workflow_queue_test.go +++ b/engine/api/workflow_queue_test.go @@ -875,7 +875,7 @@ func Test_postWorkflowJobArtifactHandler(t *testing.T) { assert.Equal(t, true, exists) // then purge run to delete artifact - require.NoError(t, purge.DeleteArtifacts(router.Background, db, api.Cache, api.SharedStorage, ctx.run.ID)) + require.NoError(t, purge.DeleteArtifacts(router.Background, db, api.SharedStorage, ctx.run.ID)) // check if file is deleted exists = fileExists(artifactPath) diff --git a/engine/api/workflow_run_craft.go b/engine/api/workflow_run_craft.go index 5d4e76ae6d..95e7c02ee1 100644 --- a/engine/api/workflow_run_craft.go +++ b/engine/api/workflow_run_craft.go @@ -5,14 +5,19 @@ import ( "strconv" "time" + "github.com/pkg/errors" + "go.opencensus.io/trace" + + "github.com/ovh/cds/engine/api/database/gorpmapping" + "github.com/ovh/cds/engine/api/event" "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/engine/api/purge" "github.com/ovh/cds/engine/api/workflow" "github.com/ovh/cds/engine/cache" + "github.com/ovh/cds/engine/featureflipping" "github.com/ovh/cds/sdk" "github.com/ovh/cds/sdk/log" "github.com/ovh/cds/sdk/telemetry" - "github.com/pkg/errors" - "go.opencensus.io/trace" ) func (api *API) WorkflowRunCraft(ctx context.Context, tick time.Duration) error { @@ -107,6 +112,50 @@ func (api *API) workflowRunCraft(ctx context.Context, id int64) error { return sdk.WrapError(err, "unable to load workflow %d", run.WorkflowID) } + enabled := featureflipping.IsEnabled(ctx, gorpmapping.Mapper, api.mustDB(), purge.FeatureMaxRuns, map[string]string{"project_key": wf.ProjectKey}) + if enabled { + countRuns, err := workflow.CountNotPendingWorkflowRunsByWorkflowID(api.mustDB(), run.WorkflowID) + if err != nil { + return sdk.WrapError(err, "unable to count workflow runs for workflow %d", run.WorkflowID) + } + if countRuns >= wf.MaxRuns { + // check spawn infos to know if we already check this run + for _, i := range run.Infos { + if i.Message.ID == sdk.MsgTooMuchWorkflowRun.ID { + return nil + } + } + + info := sdk.SpawnMsg{ + ID: sdk.MsgTooMuchWorkflowRun.ID, + Type: sdk.MsgTooMuchWorkflowRun.Type, + Args: []interface{}{wf.MaxRuns}, + } + workflow.AddWorkflowRunInfo(run, info) + if err := workflow.UpdateWorkflowRun(ctx, api.mustDB(), run); err != nil { + return err + } + event.PublishWorkflowRun(ctx, *run, wf.ProjectKey) + return nil + } + found := false + for i := range run.Infos { + if run.Infos[i].Message.ID == sdk.MsgTooMuchWorkflowRun.ID { + run.Infos[i].Type = sdk.RunInfoTypInfo + run.Infos[i].Message.Type = sdk.RunInfoTypInfo + found = true + break + } + } + if found { + if err := workflow.UpdateWorkflowRun(ctx, api.mustDB(), run); err != nil { + return err + } + event.PublishWorkflowRun(ctx, *run, wf.ProjectKey) + } + + } + log.Debug("api.workflowRunCraft> crafting workflow %s/%s #%d.%d (%d)", proj.Key, wf.Name, run.Number, run.LastSubNumber, run.ID) api.initWorkflowRun(ctx, proj.Key, wf, run, *run.ToCraftOpts) diff --git a/engine/api/workflow_run_test.go b/engine/api/workflow_run_test.go index 80e3ccdf4d..a6924e4c82 100644 --- a/engine/api/workflow_run_test.go +++ b/engine/api/workflow_run_test.go @@ -16,18 +16,21 @@ import ( "github.com/ovh/cds/engine/api/application" "github.com/ovh/cds/engine/api/ascode" "github.com/ovh/cds/engine/api/authentication" + "github.com/ovh/cds/engine/api/database/gorpmapping" "github.com/ovh/cds/engine/api/environment" "github.com/ovh/cds/engine/api/group" "github.com/ovh/cds/engine/api/integration" "github.com/ovh/cds/engine/api/pipeline" "github.com/ovh/cds/engine/api/plugin" "github.com/ovh/cds/engine/api/project" + "github.com/ovh/cds/engine/api/purge" "github.com/ovh/cds/engine/api/repositoriesmanager" "github.com/ovh/cds/engine/api/services" "github.com/ovh/cds/engine/api/test" "github.com/ovh/cds/engine/api/test/assets" "github.com/ovh/cds/engine/api/user" "github.com/ovh/cds/engine/api/workflow" + "github.com/ovh/cds/engine/featureflipping" "github.com/ovh/cds/engine/gorpmapper" "github.com/ovh/cds/sdk" "github.com/stretchr/testify/assert" @@ -1178,6 +1181,49 @@ func waitCraftinWorkflow(t *testing.T, api *API, db gorp.SqlExecutor, id int64) } } +func Test_workflowRunCraft(t *testing.T) { + featureflipping.Init(gorpmapping.Mapper) + api, db, _ := newTestAPI(t) + key := sdk.RandomString(10) + + features, err := featureflipping.LoadAll(context.TODO(), gorpmapping.Mapper, db) + require.NoError(t, err) + for _, f := range features { + _ = featureflipping.Delete(db, f.ID) + } + + proj := assets.InsertTestProject(t, db, api.Cache, key, key) + wf := assets.InsertTestWorkflow(t, db, api.Cache, proj, sdk.RandomString(10)) + + require.NoError(t, workflow.UpdateMaxRunsByID(db, wf.ID, 1)) + + wr, err := workflow.CreateRun(db.DbMap, wf, sdk.WorkflowRunPostHandlerOption{ + Hook: &sdk.WorkflowNodeRunHookEvent{}, + }) + require.NoError(t, err) + wr.Status = sdk.StatusSuccess + require.NoError(t, workflow.UpdateWorkflowRunStatus(db, wr)) + + wrPending, err := workflow.CreateRun(db.DbMap, wf, sdk.WorkflowRunPostHandlerOption{ + Hook: &sdk.WorkflowNodeRunHookEvent{}, + }) + require.NoError(t, err) + + f := sdk.Feature{ + Name: purge.FeatureMaxRuns, + Rule: "return true", + } + require.NoError(t, featureflipping.Insert(gorpmapping.Mapper, api.mustDB(), &f)) + + require.NoError(t, api.workflowRunCraft(context.TODO(), wrPending.ID)) + + wrDB, err := workflow.LoadRunByID(db, wrPending.ID, workflow.LoadRunOptions{}) + require.NoError(t, err) + + require.Len(t, wrDB.Infos, 1) + require.Equal(t, sdk.MsgTooMuchWorkflowRun.ID, wrDB.Infos[0].Message.ID) +} + /** * This test does * 1. Create worklow diff --git a/engine/sql/api/218_workflow_retention_policy.sql b/engine/sql/api/218_workflow_retention_policy.sql new file mode 100644 index 0000000000..345dd82bfd --- /dev/null +++ b/engine/sql/api/218_workflow_retention_policy.sql @@ -0,0 +1,12 @@ +-- +migrate Up +alter table "workflow" add column retention_policy TEXT; +update workflow set retention_policy = 'return (git_branch_exist == "false" and run_days_before < 2) or run_days_before < 365'; +alter table "workflow" alter column retention_policy SET NOT NULL; + +alter table "workflow" add column max_runs INT; +update workflow set max_runs = 255; +alter table "workflow" alter column max_runs SET NOT NULL; +alter table "workflow" alter column max_runs SET DEFAULT 255; + +-- +migrate Down +alter table "workflow" drop column retention_policy; diff --git a/sdk/cdsclient/client_admin.go b/sdk/cdsclient/client_admin.go index 30554ac77b..6cd5950b38 100644 --- a/sdk/cdsclient/client_admin.go +++ b/sdk/cdsclient/client_admin.go @@ -184,3 +184,12 @@ func (c *client) AdminDatabaseRollAllEncryptedEntities() error { } return nil } + +func (c *client) AdminWorkflowUpdateMaxRuns(projectKey string, workflowName string, maxRuns int64) error { + request := sdk.UpdateMaxRunRequest{MaxRuns: maxRuns} + url := fmt.Sprintf("/project/%s/workflows/%s/retention/maxruns", projectKey, workflowName) + if _, err := c.PostJSON(context.Background(), url, &request, nil); err != nil { + return err + } + return nil +} diff --git a/sdk/cdsclient/interface.go b/sdk/cdsclient/interface.go index 9d4a6c951a..22eda1cb11 100644 --- a/sdk/cdsclient/interface.go +++ b/sdk/cdsclient/interface.go @@ -47,6 +47,7 @@ type Admin interface { AdminCDSMigrationList() ([]sdk.Migration, error) AdminCDSMigrationCancel(id int64) error AdminCDSMigrationReset(id int64) error + AdminWorkflowUpdateMaxRuns(projectKey string, workflowName string, maxRuns int64) error Features() ([]sdk.Feature, error) FeatureCreate(f sdk.Feature) error FeatureDelete(name string) error diff --git a/sdk/cdsclient/mock_cdsclient/interface_mock.go b/sdk/cdsclient/mock_cdsclient/interface_mock.go index ce2ea39f94..8317dfc997 100644 --- a/sdk/cdsclient/mock_cdsclient/interface_mock.go +++ b/sdk/cdsclient/mock_cdsclient/interface_mock.go @@ -1,5 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: sdk/cdsclient/interface.go +// Source: interface.go // Package mock_cdsclient is a generated GoMock package. package mock_cdsclient @@ -386,6 +386,20 @@ func (mr *MockAdminMockRecorder) AdminCDSMigrationReset(id interface{}) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminCDSMigrationReset", reflect.TypeOf((*MockAdmin)(nil).AdminCDSMigrationReset), id) } +// AdminWorkflowUpdateMaxRuns mocks base method +func (m *MockAdmin) AdminWorkflowUpdateMaxRuns(projectKey, workflowName string, maxRuns int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdminWorkflowUpdateMaxRuns", projectKey, workflowName, maxRuns) + ret0, _ := ret[0].(error) + return ret0 +} + +// AdminWorkflowUpdateMaxRuns indicates an expected call of AdminWorkflowUpdateMaxRuns +func (mr *MockAdminMockRecorder) AdminWorkflowUpdateMaxRuns(projectKey, workflowName, maxRuns interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminWorkflowUpdateMaxRuns", reflect.TypeOf((*MockAdmin)(nil).AdminWorkflowUpdateMaxRuns), projectKey, workflowName, maxRuns) +} + // Features mocks base method func (m *MockAdmin) Features() ([]sdk.Feature, error) { m.ctrl.T.Helper() @@ -5108,6 +5122,20 @@ func (mr *MockInterfaceMockRecorder) AdminCDSMigrationReset(id interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminCDSMigrationReset", reflect.TypeOf((*MockInterface)(nil).AdminCDSMigrationReset), id) } +// AdminWorkflowUpdateMaxRuns mocks base method +func (m *MockInterface) AdminWorkflowUpdateMaxRuns(projectKey, workflowName string, maxRuns int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdminWorkflowUpdateMaxRuns", projectKey, workflowName, maxRuns) + ret0, _ := ret[0].(error) + return ret0 +} + +// AdminWorkflowUpdateMaxRuns indicates an expected call of AdminWorkflowUpdateMaxRuns +func (mr *MockInterfaceMockRecorder) AdminWorkflowUpdateMaxRuns(projectKey, workflowName, maxRuns interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminWorkflowUpdateMaxRuns", reflect.TypeOf((*MockInterface)(nil).AdminWorkflowUpdateMaxRuns), projectKey, workflowName, maxRuns) +} + // Features mocks base method func (m *MockInterface) Features() ([]sdk.Feature, error) { m.ctrl.T.Helper() diff --git a/sdk/exportentities/v2/workflow.go b/sdk/exportentities/v2/workflow.go index 26f0e212eb..07853f51ff 100644 --- a/sdk/exportentities/v2/workflow.go +++ b/sdk/exportentities/v2/workflow.go @@ -23,11 +23,12 @@ type Workflow struct { Hooks map[string][]HookEntry `json:"hooks,omitempty" yaml:"hooks,omitempty" jsonschema_description:"Workflow hooks list."` // extra workflow data - Permissions map[string]int `json:"permissions,omitempty" yaml:"permissions,omitempty" jsonschema_description:"The permissions for the workflow (ex: myGroup: 7).\nhttps://ovh.github.io/cds/docs/concepts/permissions"` - Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` - PurgeTags []string `json:"purge_tags,omitempty" yaml:"purge_tags,omitempty"` - Notifications []NotificationEntry `json:"notifications,omitempty" yaml:"notifications,omitempty"` // This is used when the workflow have only one pipeline - HistoryLength *int64 `json:"history_length,omitempty" yaml:"history_length,omitempty"` + Permissions map[string]int `json:"permissions,omitempty" yaml:"permissions,omitempty" jsonschema_description:"The permissions for the workflow (ex: myGroup: 7).\nhttps://ovh.github.io/cds/docs/concepts/permissions"` + Metadata map[string]string `json:"metadata,omitempty" yaml:"metadata,omitempty"` + PurgeTags []string `json:"purge_tags,omitempty" yaml:"purge_tags,omitempty"` + RetentionPolicy string `json:"retention_policy,omitempty" yaml:"retention_policy,omitempty"` + Notifications []NotificationEntry `json:"notifications,omitempty" yaml:"notifications,omitempty"` // This is used when the workflow have only one pipeline + HistoryLength *int64 `json:"history_length,omitempty" yaml:"history_length,omitempty"` } // NodeEntry represents a node as code @@ -103,6 +104,7 @@ func NewWorkflow(ctx context.Context, w sdk.Workflow, version string, opts ...Ex exportedWorkflow.Version = version exportedWorkflow.Workflow = map[string]NodeEntry{} exportedWorkflow.Hooks = map[string][]HookEntry{} + exportedWorkflow.RetentionPolicy = w.RetentionPolicy if len(w.Metadata) > 0 { exportedWorkflow.Metadata = make(map[string]string, len(w.Metadata)) for k, v := range w.Metadata { @@ -369,6 +371,7 @@ func (w Workflow) GetWorkflow() (*sdk.Workflow, error) { wf.Pipelines = make(map[int64]sdk.Pipeline) wf.Environments = make(map[int64]sdk.Environment) wf.ProjectIntegrations = make(map[int64]sdk.ProjectIntegration) + wf.RetentionPolicy = w.RetentionPolicy if err := w.CheckValidity(); err != nil { return nil, sdk.WrapError(err, "unable to check validity") diff --git a/sdk/exportentities/v2/workflow_test.go b/sdk/exportentities/v2/workflow_test.go index 0ccad113d5..4d3d68562d 100644 --- a/sdk/exportentities/v2/workflow_test.go +++ b/sdk/exportentities/v2/workflow_test.go @@ -140,13 +140,14 @@ func TestWorkflow_checkValidity(t *testing.T) { func TestWorkflow_GetWorkflow(t *testing.T) { true := true type fields struct { - Name string - Description string - Version string - Workflow map[string]v2.NodeEntry - Hooks map[string][]v2.HookEntry - Permissions map[string]int - HistoryLength int64 + Name string + Description string + Version string + Workflow map[string]v2.NodeEntry + Hooks map[string][]v2.HookEntry + Permissions map[string]int + HistoryLength int64 + RetentionPolicy string } tsts := []struct { name string @@ -182,6 +183,7 @@ func TestWorkflow_GetWorkflow(t *testing.T) { }, }}, }, + RetentionPolicy: "return false", }, wantErr: false, want: sdk.Workflow{ @@ -229,6 +231,7 @@ func TestWorkflow_GetWorkflow(t *testing.T) { }, }, }, + RetentionPolicy: "return false", }, }, // root(pipeline-root) -> child(pipeline-child) @@ -632,13 +635,14 @@ func TestWorkflow_GetWorkflow(t *testing.T) { for _, tt := range tsts { t.Run(tt.name, func(t *testing.T) { w := v2.Workflow{ - Name: tt.fields.Name, - Description: tt.fields.Description, - Version: tt.fields.Version, - Workflow: tt.fields.Workflow, - Hooks: tt.fields.Hooks, - Permissions: tt.fields.Permissions, - HistoryLength: &tt.fields.HistoryLength, + Name: tt.fields.Name, + Description: tt.fields.Description, + Version: tt.fields.Version, + Workflow: tt.fields.Workflow, + Hooks: tt.fields.Hooks, + Permissions: tt.fields.Permissions, + HistoryLength: &tt.fields.HistoryLength, + RetentionPolicy: tt.fields.RetentionPolicy, } got, err := exportentities.ParseWorkflow(w) if (err != nil) != tt.wantErr { @@ -692,6 +696,21 @@ func TestFromYAMLToYAML(t *testing.T) { yaml string wantErr bool }{ + { + name: "Retention policy", + yaml: `name: retention +version: v2.0 +workflow: + 1_start: + conditions: + check: + - variable: git.branch + operator: eq + value: master + pipeline: test +retention_policy: return false +`, + }, { name: "1_start -> 2_webhook -> 3_after_webhook -> 4_fork_before_end -> 5_end", yaml: `name: test1 diff --git a/sdk/luascript/luascript.go b/sdk/luascript/luascript.go index 1d654f2cc9..9a5389ffaf 100644 --- a/sdk/luascript/luascript.go +++ b/sdk/luascript/luascript.go @@ -15,7 +15,6 @@ import ( type Check struct { state *lua.LState exceptionHandlerFunction *lua.LFunction - variables map[string]string IsError bool Result bool ctx context.Context @@ -71,7 +70,6 @@ func (c *Check) exceptionHandler(L *lua.LState) int { } func (c *Check) SetVariables(vars map[string]string) { - c.variables = vars for k, v := range vars { k = strings.Replace(k, ".", "_", -1) k = strings.Replace(k, "-", "_", -1) @@ -79,6 +77,14 @@ func (c *Check) SetVariables(vars map[string]string) { } } +func (c *Check) SetFloatVariables(vars map[string]float64) { + for k, v := range vars { + k = strings.Replace(k, ".", "_", -1) + k = strings.Replace(k, "-", "_", -1) + c.state.SetGlobal(k, lua.LNumber(v)) + } +} + //Perform the lua script func (c *Check) Perform(script string) error { var ok bool diff --git a/sdk/messages.go b/sdk/messages.go index 06b103d2e9..751f215fb1 100644 --- a/sdk/messages.go +++ b/sdk/messages.go @@ -89,6 +89,7 @@ var ( MsgWorkflowErrorBadVCSStrategy = &Message{"MsgWorkflowErrorBadVCSStrategy", trad{FR: "Vos informations vcs_* sont incorrectes", EN: "Your vcs_* fields are incorrects"}, nil, RunInfoTypeError} MsgWorkflowDeprecatedVersion = &Message{"MsgWorkflowDeprecatedVersion", trad{FR: "La configuration yaml de votre workflow est dans un format déprécié. Exportez le avec la CLI `cdsctl workflow export %s %s`", EN: "The yaml workflow configuration format is deprecated. Export your workflow with CLI `cdsctl workflow export %s %s`"}, nil, RunInfoTypeWarning} MsgWorkflowGeneratedFromTemplateVersion = &Message{"MsgWorkflowGeneratedFromTemplateVersion", trad{FR: "Le workflow a été généré à partir du modèle de workflow: %s.", EN: "The workflow was generated from the template: %s"}, nil, RunInfoTypInfo} + MsgTooMuchWorkflowRun = &Message{"MsgTooMuchWorkflowRun", trad{FR: "L'exécution de ce workflow est suspendu. Vous dépassez le nombre maximum d'éxécution autorisé (%.f). Merci de revoir la politique de retention de ce workflow", EN: "Workflow run is delayed. The maximum number of runs for this workflow has been reached ( %.f ). Please update your workflow retention policy"}, nil, RunInfoTypeWarning} ) // Messages contains all sdk Messages @@ -162,6 +163,7 @@ var Messages = map[string]*Message{ MsgWorkflowErrorBadVCSStrategy.ID: MsgWorkflowErrorBadVCSStrategy, MsgWorkflowDeprecatedVersion.ID: MsgWorkflowDeprecatedVersion, MsgWorkflowGeneratedFromTemplateVersion.ID: MsgWorkflowGeneratedFromTemplateVersion, + MsgTooMuchWorkflowRun.ID: MsgTooMuchWorkflowRun, } //Message represent a struc format translated messages diff --git a/sdk/purge.go b/sdk/purge.go new file mode 100644 index 0000000000..f012bfee0d --- /dev/null +++ b/sdk/purge.go @@ -0,0 +1,15 @@ +package sdk + +type PurgeDryRunRequest struct { + RetentionPolicy string `json:"retention_policy"` +} + +type WorkflowRunToKeep struct { + ID int64 `json:"id"` + Status string `json:"status"` + Num int64 `json:"num"` +} + +type UpdateMaxRunRequest struct { + MaxRuns int64 `json:"max_runs"` +} diff --git a/sdk/workflow.go b/sdk/workflow.go index 2280f56de6..4dcb530ee5 100644 --- a/sdk/workflow.go +++ b/sdk/workflow.go @@ -41,6 +41,8 @@ type Workflow struct { Usage *Usage `json:"usage,omitempty" db:"-" cli:"-"` HistoryLength int64 `json:"history_length" db:"history_length" cli:"-"` PurgeTags PurgeTags `json:"purge_tags,omitempty" db:"purge_tags" cli:"-"` + RetentionPolicy string `json:"retention_policy,omitempty" db:"retention_policy" cli:"-"` + MaxRuns int64 `json:"max_runs,omitempty" db:"max_runs" cli:"-"` Notifications []WorkflowNotification `json:"notifications,omitempty" db:"-" cli:"-"` FromRepository string `json:"from_repository,omitempty" db:"from_repository" cli:"from"` DerivedFromWorkflowID int64 `json:"derived_from_workflow_id,omitempty" db:"derived_from_workflow_id" cli:"-"` diff --git a/ui/src/app/model/purge.model.ts b/ui/src/app/model/purge.model.ts new file mode 100644 index 0000000000..60c83dee84 --- /dev/null +++ b/ui/src/app/model/purge.model.ts @@ -0,0 +1,8 @@ + + +export class RunToKeep { + id: number; + status: string; + num: string; + +} diff --git a/ui/src/app/model/workflow.model.ts b/ui/src/app/model/workflow.model.ts index dfd5e1e5c3..e88cb32247 100644 --- a/ui/src/app/model/workflow.model.ts +++ b/ui/src/app/model/workflow.model.ts @@ -52,6 +52,8 @@ export class Workflow { labels: Label[]; workflow_data: WorkflowData; as_code_events: Array; + retention_policy: string; + max_runs: number; preview: Workflow; asCode: string; diff --git a/ui/src/app/service/workflow/workflow.service.ts b/ui/src/app/service/workflow/workflow.service.ts index 6e0658238f..d4ee5cf5f5 100644 --- a/ui/src/app/service/workflow/workflow.service.ts +++ b/ui/src/app/service/workflow/workflow.service.ts @@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Operation } from 'app/model/operation.model'; import { BuildResult, CDNLogLink, ServiceLog, SpawnInfo } from 'app/model/pipeline.model'; +import { RunToKeep } from 'app/model/purge.model'; import { Workflow, WorkflowPull, WorkflowTriggerConditionCache } from 'app/model/workflow.model'; import { Observable } from 'rxjs'; @@ -78,4 +79,15 @@ export class WorkflowService { nodeRunID: number, nodeJobRunID: number): Observable> { return this._http.get>(`/project/${projectKey}/workflows/${workflowName}/runs/${runNumber}/nodes/${nodeRunID}/job/${nodeJobRunID}/info`); } + + retentionPolicyDryRun(workflow: Workflow): Observable> { + return this._http.post>(`/project/${workflow.project_key}/workflows/${workflow.name}/retention/dryrun`, + { retention_policy: workflow.retention_policy}); + } + + retentionPolicySuggestion(workflow: Workflow) { + return this._http.get>(`/project/${workflow.project_key}/workflows/${workflow.name}/retention/suggest`); + } + + } diff --git a/ui/src/app/store/feature.state.ts b/ui/src/app/store/feature.state.ts index cff2e11768..401d1f58c5 100644 --- a/ui/src/app/store/feature.state.ts +++ b/ui/src/app/store/feature.state.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { Action, createSelector, State, StateContext } from '@ngxs/store'; +import { cloneDeep } from 'lodash-es'; import * as actionFeature from './feature.action'; export class FeatureResults { @@ -31,6 +32,13 @@ export class FeatureState { }); } + static featureProject(key: string, params: string) { + return createSelector([FeatureState], (state: FeatureStateModel) => { + return state.features.find(f => f.key === key)?.results.find(r => r.paramString === params); + }); + } + + @Action(actionFeature.AddFeatureResult) addFeatureResult(ctx: StateContext, action: actionFeature.AddFeatureResult) { const state = ctx.getState(); @@ -40,6 +48,7 @@ export class FeatureState { let existingFeature = state.features.find(f => f.key === action.payload.key); if (existingFeature) { feature.results = existingFeature.results.filter(r => r.paramString !== action.payload.result.paramString); + } feature.results.push(action.payload.result) diff --git a/ui/src/app/store/store.module.ts b/ui/src/app/store/store.module.ts index c99467ef5e..9dc0f11a52 100644 --- a/ui/src/app/store/store.module.ts +++ b/ui/src/app/store/store.module.ts @@ -12,8 +12,8 @@ import { PipelinesState } from 'app/store/pipelines.state'; import { environment as env } from '../../environments/environment'; import { AuthenticationState } from './authentication.state'; import { EventState } from './event.state'; -import { HelpState } from './help.state'; import { FeatureState } from './feature.state'; +import { HelpState } from './help.state'; import { ProjectState } from './project.state'; import { QueueState } from './queue.state'; import { WorkflowState } from './workflow.state'; diff --git a/ui/src/app/views/admin/broadcast/list/broadcast.list.html b/ui/src/app/views/admin/broadcast/list/broadcast.list.html index 433f0f8325..8d27704dba 100644 --- a/ui/src/app/views/admin/broadcast/list/broadcast.list.html +++ b/ui/src/app/views/admin/broadcast/list/broadcast.list.html @@ -3,7 +3,6 @@ {{ 'btn_add' | translate }} - - + - \ No newline at end of file + diff --git a/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts b/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts index 89a3fa90f4..967b2868bc 100644 --- a/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts +++ b/ui/src/app/views/workflow/run/node/pipeline/node.pipeline.component.ts @@ -10,7 +10,6 @@ import { FeatureService } from 'app/service/feature/feature.service'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; import { DurationService } from 'app/shared/duration/duration.service'; import { AddFeatureResult, FeaturePayload } from 'app/store/feature.action'; -import { FeatureResult } from 'app/store/feature.state'; import { ProjectState } from 'app/store/project.state'; import { SelectWorkflowNodeRunJob } from 'app/store/workflow.action'; import { WorkflowState, WorkflowStateModel } from 'app/store/workflow.state'; diff --git a/ui/src/app/views/workflow/run/node/pipeline/service/service.log.component.ts b/ui/src/app/views/workflow/run/node/pipeline/service/service.log.component.ts index 23617c128e..8c28282e22 100644 --- a/ui/src/app/views/workflow/run/node/pipeline/service/service.log.component.ts +++ b/ui/src/app/views/workflow/run/node/pipeline/service/service.log.component.ts @@ -100,7 +100,6 @@ export class WorkflowServiceLogComponent implements OnInit, OnDestroy { let projectKey = this._store.selectSnapshot(ProjectState.projectSnapshot).key; let workflowName = this._store.selectSnapshot(WorkflowState.workflowSnapshot).name; - let runNumber = (this._store.selectSnapshot(WorkflowState)).workflowNodeRun.num; let nodeRunId = (this._store.selectSnapshot(WorkflowState)).workflowNodeRun.id; let runJobId = this.currentRunJobID; diff --git a/ui/src/app/views/workflow/run/workflow.run.component.ts b/ui/src/app/views/workflow/run/workflow.run.component.ts index 6101bee2a0..87b5d515b0 100644 --- a/ui/src/app/views/workflow/run/workflow.run.component.ts +++ b/ui/src/app/views/workflow/run/workflow.run.component.ts @@ -34,6 +34,8 @@ export class WorkflowRunComponent implements OnInit, OnDestroy { version: string; direction: string; + runDelayed: boolean; + paramsSub: Subscription; pipelineStatusEnum = PipelineStatus; @@ -85,7 +87,9 @@ export class WorkflowRunComponent implements OnInit, OnDestroy { } if (wr && this.workflowRunData && this.workflowRunData['id'] === wr.id && this.workflowRunData['status'] === wr.status) { - return; + if ((!this.workflowRunData['infos'] && !wr.infos) || (wr.infos.length === (<[]>this.workflowRunData['infos'])?.length)) { + return; + } } if (!this.workflowRunData) { @@ -115,6 +119,8 @@ export class WorkflowRunComponent implements OnInit, OnDestroy { )) { this.displayError = wr.infos.some((info) => info.type === 'Error'); this.warnings = wr.infos.filter(i => i.type === 'Warning'); + + this.runDelayed = this.warnings.findIndex(w => w.message.id === 'MsgTooMuchWorkflowRun') !== -1; } this.workflowRunData['id'] = wr.id; diff --git a/ui/src/app/views/workflow/run/workflow.run.html b/ui/src/app/views/workflow/run/workflow.run.html index c716858f5b..830ddc5238 100644 --- a/ui/src/app/views/workflow/run/workflow.run.html +++ b/ui/src/app/views/workflow/run/workflow.run.html @@ -2,75 +2,75 @@ - - -
-
-
-
-
- - - - -
- - - {{info.message.id}} - +
+
+
+
+
+ + + + +
+ + + {{info.message.id}} + {{errorsMap[info.message.id].title | translate}} -
-
-

- {{info.user_message}}. {{errorsMap[info.message.id].description | translate}} -

-

- {{'common_find_help' | translate}} {{'common_here' | translate}}. -

-
-
-
-
-
+
+
+

+ {{info.user_message}}. {{errorsMap[info.message.id].description | translate}} +

+

+ {{'common_find_help' | translate}} {{'common_here' | translate}}. +

+
+ + +
-
- -
-
-
-
- - - - -
- - - + + +
+
+
+
+ + + + +
+ + + {{warningsMap[info.message.id].title | translate}} - {{info.message.id}} -
-
-

- {{info.user_message}}. -

-

- {{'common_find_help' | translate}} {{'common_here' | translate}} -

-
-
-
-
-
+ {{info.message.id}} +
+
+

+ {{info.user_message}}. +

+

+ {{'common_find_help' | translate}} {{'common_here' | translate}} +

+
+ + +
-
+
+ +
@@ -101,13 +101,19 @@
-
-
-
-
-
-
- {{'workflow_run_scheduling' | translate }} + +
+
+
+
+
+
+ {{'workflow_run_scheduling' | translate }} +
+ + + {{'workflow_run_delayed' | translate }} +
diff --git a/ui/src/app/views/workflow/show/admin/workflow.admin.component.html b/ui/src/app/views/workflow/show/admin/workflow.admin.component.html index 02d6f474de..463d697f8f 100644 --- a/ui/src/app/views/workflow/show/admin/workflow.admin.component.html +++ b/ui/src/app/views/workflow/show/admin/workflow.admin.component.html @@ -84,45 +84,43 @@
+
+ +
- -
- -
- -
- {{ 'workfow_tag_no' | translate }} -
-
- -
-
-
- {{t + ' '}} - -
-
-
-
-
-
- - - - -
-
-
- + + +
+
+ + +
{{ 'workflow_retention_policy' | translate }}
+
+ +
{{'workflow_purged_dry_run_empty' | translate }}
+
+ +
+ +
-
+ +
+
+
+
diff --git a/ui/src/app/views/workflow/show/admin/workflow.admin.component.ts b/ui/src/app/views/workflow/show/admin/workflow.admin.component.ts index 597d229cda..52082eed47 100644 --- a/ui/src/app/views/workflow/show/admin/workflow.admin.component.ts +++ b/ui/src/app/views/workflow/show/admin/workflow.admin.component.ts @@ -10,19 +10,25 @@ import { import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { Store } from '@ngxs/store'; +import { ModalTemplate, SuiActiveModal, SuiModalService, TemplateModalConfig } from '@richardlt/ng2-semantic-ui'; import { Project } from 'app/model/project.model'; +import { RunToKeep } from 'app/model/purge.model'; import { Workflow } from 'app/model/workflow.model'; +import { ThemeStore } from 'app/service/theme/theme.store'; import { WorkflowRunService } from 'app/service/workflow/run/workflow.run.service'; +import { WorkflowService } from 'app/service/workflow/workflow.service'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; import { WarningModalComponent } from 'app/shared/modal/warning/warning.component'; +import { Column, ColumnType } from 'app/shared/table/data-table.component'; import { ToastService } from 'app/shared/toast/ToastService'; +import { FeatureState } from 'app/store/feature.state'; import { DeleteWorkflow, DeleteWorkflowIcon, UpdateWorkflow, UpdateWorkflowIcon } from 'app/store/workflow.action'; import cloneDeep from 'lodash-es/cloneDeep'; +import { CodemirrorComponent } from 'ng2-codemirror-typescript/Codemirror'; import { DragulaService } from 'ng2-dragula-sgu'; import { forkJoin, Subscription } from 'rxjs'; import { finalize, first } from 'rxjs/operators'; - @Component({ selector: 'app-workflow-admin', templateUrl: 'workflow.admin.component.html', @@ -58,9 +64,19 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { iconUpdated = false; tagsToAdd = new Array(); tagsToAddPurge = new Array(); + maxRunsEnabled = false; + codeMirrorConfig: any; @ViewChild('updateWarning') private warningUpdateModal: WarningModalComponent; + @ViewChild('codemirrorRetentionPolicy') codemirror: CodemirrorComponent; + themeSubscription: Subscription; + + availableVariables: string; + dryRunColumns = []; + dryRunDatas: Array; + @ViewChild('modalDryRun') dryRunModal: ModalTemplate; + modal: SuiActiveModal; loading = false; fileTooLarge = false; @@ -72,23 +88,36 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { private _toast: ToastService, private _router: Router, private _workflowRunService: WorkflowRunService, + private _workflowService: WorkflowService, private _cd: ChangeDetectorRef, private _dragularService: DragulaService, + private _theme: ThemeStore, + private _modalService: SuiModalService ) { this._dragularService.createGroup('bag-tag', { accepts: function (el, target, source, sibling) { - if (sibling === null) { - return false; - } - return true; + return sibling !== null; } }); - this.dragulaSubscription = this._dragularService.drop('bag-tag').subscribe(({ el, source }) => { + this.dragulaSubscription = this._dragularService.drop('bag-tag').subscribe(({}) => { setTimeout(() => { this.updateTagMetadata(); }); }); + this.dryRunColumns = [ + >{ + name: 'run_number', + class: 'two', + selector: (r: RunToKeep) => r.num + }, + >{ + type: ColumnType.TEXT, + name: 'status', + class: 'two', + selector: (r: RunToKeep) => r.status + } + ]; } ngOnDestroy() { @@ -96,6 +125,25 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.codeMirrorConfig = { + matchBrackets: true, + autoCloseBrackets: true, + mode: 'text/x-lua', + lineWrapping: true, + lineNumbers: true, + autoRefresh: true, + readOnly: !this.editMode, + gutters: ['CodeMirror-lint-markers'], + }; + + this.themeSubscription = this._theme.get().subscribe(t => { + this.codeMirrorConfig.theme = t === 'night' ? 'darcula' : 'default'; + if (this.codemirror && this.codemirror.instance) { + this.codemirror.instance.setOption('theme', this.codeMirrorConfig.theme); + this._cd.markForCheck(); + } + }); + if (!this._workflow.metadata) { this._workflow.metadata = new Map(); } @@ -124,6 +172,17 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { this.originalRunNumber = n.num; this.runnumber = n.num; }); + + this._workflowService.retentionPolicySuggestion(this.workflow).subscribe(sg => { + this.availableVariables = sg.join(', '); + this._cd.markForCheck(); + }); + + let featMaxRunsResult = this.store.selectSnapshot(FeatureState.featureProject('workflow-retention-maxruns', + JSON.stringify({ 'project_key': this.project.key }))) + this.maxRunsEnabled = featMaxRunsResult?.enabled; + + this._cd.markForCheck(); } initExistingtags(): void { @@ -167,7 +226,7 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { updateTagMetadata(): void { if (this.tagsToAdd && this.tagsToAdd.length > 0) { if (!this.selectedTags) { - this.selectedTags = new Array(); + this.selectedTags = []; } this.selectedTags.push(...this.tagsToAdd); this.initExistingtags(); @@ -180,7 +239,7 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { updateTagPurge(): void { if (this.tagsToAddPurge && this.tagsToAddPurge.length > 0) { if (!this.selectedTagsPurge) { - this.selectedTagsPurge = new Array(); + this.selectedTagsPurge = []; } this.selectedTagsPurge.push(...this.tagsToAddPurge); this.initExistingtags(); @@ -202,6 +261,16 @@ export class WorkflowAdminComponent implements OnInit, OnDestroy { this.updateTagPurge(); } + retentionPolicyDryRun(): void { + this._workflowService.retentionPolicyDryRun(this.workflow).subscribe(wr => { + this.dryRunDatas = wr; + const config = new TemplateModalConfig(this.dryRunModal); + config.mustScroll = true; + this.modal = this._modalService.open(config); + this._cd.markForCheck(); + }); + } + onSubmitWorkflowUpdate(skip?: boolean) { if (!skip && this.workflow.externalChange) { this.warningUpdateModal.show(); diff --git a/ui/src/app/views/workflow/show/admin/workflow.admin.scss b/ui/src/app/views/workflow/show/admin/workflow.admin.scss index e4bc1a2f5f..7accc4d82e 100644 --- a/ui/src/app/views/workflow/show/admin/workflow.admin.scss +++ b/ui/src/app/views/workflow/show/admin/workflow.admin.scss @@ -15,4 +15,12 @@ .ui.horizontal.list > .item { margin-right: 0.5em; } + + .field { + margin-top: 10px; + } +} + +.dryrun { + min-height: 460px; } diff --git a/ui/src/app/views/workflow/workflow.component.ts b/ui/src/app/views/workflow/workflow.component.ts index 24af147e41..a27e4096da 100644 --- a/ui/src/app/views/workflow/workflow.component.ts +++ b/ui/src/app/views/workflow/workflow.component.ts @@ -12,12 +12,14 @@ import { Select, Store } from '@ngxs/store'; import { SuiPopup } from '@richardlt/ng2-semantic-ui'; import { Project } from 'app/model/project.model'; import { Workflow } from 'app/model/workflow.model'; +import { FeatureService } from 'app/service/feature/feature.service'; import { WorkflowCoreService } from 'app/service/workflow/workflow.core.service'; import { WorkflowSidebarMode } from 'app/service/workflow/workflow.sidebar.store'; import { AsCodeSaveModalComponent } from 'app/shared/ascode/save-modal/ascode.save-modal.component'; import { AutoUnsubscribe } from 'app/shared/decorator/autoUnsubscribe'; import { ToastService } from 'app/shared/toast/ToastService'; import { WorkflowTemplateApplyModalComponent } from 'app/shared/workflow-template/apply-modal/workflow-template.apply-modal.component'; +import { AddFeatureResult, FeaturePayload } from 'app/store/feature.action'; import { ProjectState, ProjectStateModel } from 'app/store/project.state'; import { CleanWorkflowRun, @@ -86,8 +88,9 @@ export class WorkflowComponent implements OnInit, OnDestroy { private _toast: ToastService, private _translate: TranslateService, private _store: Store, - private _cd: ChangeDetectorRef - ) { } + private _cd: ChangeDetectorRef, + private _featureService: FeatureService + ) {} ngOnDestroy(): void {} // Should be set to use @AutoUnsubscribe with AOT @@ -100,7 +103,25 @@ export class WorkflowComponent implements OnInit, OnDestroy { } this._cd.detectChanges(); }); - + let data = { 'project_key': this.project.key } + this._featureService.isEnabled('workflow-retention-policy', data).subscribe(f => { + this._store.dispatch(new AddFeatureResult({ + key: f.name, + result: { + paramString: JSON.stringify(data), + enabled: f.enabled + } + })); + }); + this._featureService.isEnabled('workflow-retention-maxruns', data).subscribe(f => { + this._store.dispatch(new AddFeatureResult({ + key: f.name, + result: { + paramString: JSON.stringify(data), + enabled: f.enabled + } + })); + }); this.sidebarSubs = this.sibebar$.subscribe(m => { if (m === this.sidebarMode) { return; diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json index 3e6289df26..93ff85da1d 100644 --- a/ui/src/assets/i18n/en.json +++ b/ui/src/assets/i18n/en.json @@ -121,6 +121,7 @@ "btn_close": "Close", "btn_create": "Create", "btn_create_workflow": "Create workflow", + "btn_dry_run": "Dry Run", "btn_finish": "Finish", "btn_delete": "Delete", "btn_goto_workflow": "See workflow", @@ -819,6 +820,7 @@ "workflow_name_error": "Workflow name must follow this pattern ^[a-zA-Z0-9._-]{1,}$", "workflow_name_error_duplicate": "This workflow name already exists in your project", "workflow_preview_mode": "Your workflow is in preview mode", + "workflow_purged_dry_run_empty": "No data will be deleted", "workflow_node_context_label": "Execution context", "workflow_node_input": "Inputs", "workflow_node_context_pipeline_parameter": "Pipeline parameters", @@ -863,9 +865,7 @@ "workflow_node_trigger_condition_no": "There is no trigger condition", "workflow_node_trigger_title": "Add a trigger from {{pip}}", "workflow_node_type_outgoing_hook": "Outgoing Hook", - "workflow_history_length_title": "History's length of your builds to keep by tag", "workflow_node_permissions_form_title": "Add a permission", - "workflow_history_length": "History's length", "workflow_permission_list_title": "List workflow permissions", "workflow_permission_form_title": "Add a permission on workflow", "workflow_root_context_application": "Application (optional)", @@ -875,8 +875,8 @@ "workflow_root_context_pipeline": "Pipeline", "workflow_run_loading": "Loading runs...", "workflow_no_run_found": "No workflow run found", - "workflow_run_readonly_title": "Execution of the workflow is on read only mode", - "workflow_run_readonly_content": "This workflow execution is on read only mode. It cannot be run anymore.", + "workflow_run_readonly_title": "This workflow run is on read only mode", + "workflow_run_readonly_content": "This workflow run is on read only mode. It cannot be run anymore.", "workflow_run_resync_help": "When you resync this run, it will update the pipelines linked to this run with your last editions. It won't resync your project variables.", "workflow_run_with_parameters": "Run workflow", "workflow_sidebar_tag_zone": "Tags to display in the sidebar (order by priority)", @@ -908,6 +908,12 @@ "workflow_notification_vcs_comment_always": "Always send", "workflow_notification_vcs_pr_comment_body": "Pull-request's comment body", "workflow_notification_explanation": "_A user notification can be useful to report the status of a workflow according to its status. Each pipeline in a workflow can be notified based on status in 'Success', 'Fail' or status change. The message sent to the recipients can be set using [CDS variables] (https://ovh.github.io/cds/docs/concepts/variables/). E-mail notifications can also contain HTML, cf. [User Notifications] documentation (https://ovh.github.io/cds/docs/concepts/workflow/notifications/) ._", + "workflow_retention_maxruns": "Maximum number of workflow runs: ", + "workflow_retention_maxruns_admin": "You can contact a CDS administrator to customize this value", + "workflow_retention_policy": "Workflow run retention policy", + "workflow_retention_variables": "Available variables: {{vars}}", + "workflow_retention_result_title": "The following runs will be kept", + "workflow_run_delayed": "Workflow run has been delayed", "workflow_event_explanation": "_Here you can configure one or more integrations of type `Event`. This allows you to send all technical data in a backend to make it accessible by third-party applications such as Kafka or ElasticSearch. See the [Event Notifications] (https://ovh.github.io/cds/docs/concepts/workflow/notifications/) documentation for more information._", "workflow_no_event_integration": "You haven't any event integration on your project.", "workflow_event_form": "Events integrations", diff --git a/ui/src/assets/i18n/fr.json b/ui/src/assets/i18n/fr.json index 7ac56798b3..bee8e15c6b 100644 --- a/ui/src/assets/i18n/fr.json +++ b/ui/src/assets/i18n/fr.json @@ -142,6 +142,7 @@ "btn_create_workflow": "Création du workflow", "btn_create": "Créer", "btn_delete": "Supprimer", + "btn_dry_run": "Tester", "btn_filter": "Filtrer", "btn_finish": "Terminer", "btn_goto_workflow": "Voir le workflow", @@ -822,8 +823,6 @@ "workflow_from_repository": "Workflow importé depuis {{repo}}", "workflow_from_template_btn": "Génération du workflow depuis un modèle", "workflow_from_template": "Workflow importé depuis le modèle", - "workflow_history_length_title": "Nombre de builds à conserver par tag", - "workflow_history_length": "Nombre de builds", "workflow_hook_delete_msg": "Êtes-vous certain de vouloir supprimer ce hook ?", "workflow_hook_delete_title": "Supprimer le hook", "workflow_hook_log_title": "Logs du hook", @@ -902,11 +901,18 @@ "workflow_permission_form_title": "Ajouter une permission sur le workflow", "workflow_permission_list_title": "Liste des permissions sur le workflow", "workflow_preview_mode": "Votre workflow est dans un état de prévisualisation", + "workflow_purged_dry_run_empty": "Aucune donnée ne sera supprimée", "workflow_repository_help_line_1": "Votre workflow n'a pas été importé depuis votre code.", "workflow_repository_help_line_2": "Gérez votre workflow \"as code\" depuis votre gestionnaire de dépôt pour modifier automatiquement celui-ci avec les changements sur vos branches.", "workflow_resync_vcs_tooltip": "Cette action va renvoyer les statuts de vos pipelines à votre gestionnaire de dépots distants", "workflow_resync_vcs": "Resynchroniser les statuts VCS", "workflow_resync": "Resynchroniser le workflow", + "workflow_retention_maxruns": "Nombre maximum d'exécutions du workflow: ", + "workflow_retention_maxruns_admin": "Vous pouvez contacter un administrateur pour changer cette valeur", + "workflow_retention_policy": "Politique de rétention des éxécutions du workflow", + "workflow_retention_variables": "Variables disponibles: {{vars}}", + "workflow_retention_result_title": "Les exécutions suivantes seront gardées", + "workflow_run_delayed": "L'exécution du workflow a été reportée", "workflow_run_only_failed": "Uniquement les jobs en erreur", "workflow_root_context_application": "Application (facultatif)", "workflow_root_context_environment": "Environnement (facultatif)",