diff --git a/api/jobs.go b/api/jobs.go index bd52927d8ad..907f548c434 100644 --- a/api/jobs.go +++ b/api/jobs.go @@ -264,8 +264,50 @@ func (j *Jobs) ScaleStatus(jobID string, q *QueryOptions) (*JobScaleStatusRespon // Versions is used to retrieve all versions of a particular job given its // unique ID. func (j *Jobs) Versions(jobID string, diffs bool, q *QueryOptions) ([]*Job, []*JobDiff, *QueryMeta, error) { + opts := &VersionsOptions{ + Diffs: diffs, + } + return j.VersionsOpts(jobID, opts, q) +} + +// VersionByTag is used to retrieve a job version by its VersionTag name. +func (j *Jobs) VersionByTag(jobID, tag string, q *QueryOptions) (*Job, *QueryMeta, error) { + versions, _, qm, err := j.Versions(jobID, false, q) + if err != nil { + return nil, nil, err + } + + // Find the version with the matching tag + for _, version := range versions { + if version.VersionTag != nil && version.VersionTag.Name == tag { + return version, qm, nil + } + } + + return nil, nil, fmt.Errorf("version tag %s not found for job %s", tag, jobID) +} + +type VersionsOptions struct { + Diffs bool + DiffTag string + DiffVersion *uint64 +} + +func (j *Jobs) VersionsOpts(jobID string, opts *VersionsOptions, q *QueryOptions) ([]*Job, []*JobDiff, *QueryMeta, error) { var resp JobVersionsResponse - qm, err := j.client.query(fmt.Sprintf("/v1/job/%s/versions?diffs=%v", url.PathEscape(jobID), diffs), &resp, q) + + qp := url.Values{} + if opts != nil { + qp.Add("diffs", strconv.FormatBool(opts.Diffs)) + if opts.DiffTag != "" { + qp.Add("diff_tag", opts.DiffTag) + } + if opts.DiffVersion != nil { + qp.Add("diff_version", strconv.FormatUint(*opts.DiffVersion, 10)) + } + } + + qm, err := j.client.query(fmt.Sprintf("/v1/job/%s/versions?%s", url.PathEscape(jobID), qp.Encode()), &resp, q) if err != nil { return nil, nil, nil, err } @@ -988,6 +1030,24 @@ func (j *JobUILink) Copy() *JobUILink { } } +type JobVersionTag struct { + Name string + Description string + TaggedTime int64 +} + +func (j *JobVersionTag) Copy() *JobVersionTag { + if j == nil { + return nil + } + + return &JobVersionTag{ + Name: j.Name, + Description: j.Description, + TaggedTime: j.TaggedTime, + } +} + func (js *JobSubmission) Canonicalize() { if js == nil { return @@ -1066,6 +1126,7 @@ type Job struct { CreateIndex *uint64 ModifyIndex *uint64 JobModifyIndex *uint64 + VersionTag *JobVersionTag } // IsPeriodic returns whether a job is periodic. @@ -1622,3 +1683,22 @@ type JobStatusesRequest struct { // IncludeChildren will include child (batch) jobs in the response. IncludeChildren bool } + +type TagVersionRequest struct { + Version uint64 + Description string + WriteRequest +} + +func (j *Jobs) TagVersion(jobID string, version uint64, name string, description string, q *WriteOptions) (*WriteMeta, error) { + var tagRequest = &TagVersionRequest{ + Version: version, + Description: description, + } + + return j.client.put("/v1/job/"+url.PathEscape(jobID)+"/versions/"+name+"/tag", tagRequest, nil, q) +} + +func (j *Jobs) UntagVersion(jobID string, name string, q *WriteOptions) (*WriteMeta, error) { + return j.client.delete("/v1/job/"+url.PathEscape(jobID)+"/versions/"+name+"/tag", nil, nil, q) +} diff --git a/command/agent/job_endpoint.go b/command/agent/job_endpoint.go index a6142f5eeaa..19b30e81def 100644 --- a/command/agent/job_endpoint.go +++ b/command/agent/job_endpoint.go @@ -117,6 +117,14 @@ func (s *HTTPServer) JobSpecificRequest(resp http.ResponseWriter, req *http.Requ case strings.HasSuffix(path, "/action"): jobID := strings.TrimSuffix(path, "/action") return s.jobRunAction(resp, req, jobID) + case strings.HasSuffix(path, "/tag"): + parts := strings.Split(path, "/") + if len(parts) != 4 { + return nil, CodedError(404, "invalid job tag endpoint") + } + jobID := parts[0] + name := parts[2] // job//tag/ + return s.jobTagVersion(resp, req, jobID, name) default: return s.jobCRUD(resp, req, path) } @@ -400,6 +408,62 @@ func (s *HTTPServer) jobRunAction(resp http.ResponseWriter, req *http.Request, j return s.execStream(conn, &args) } +func (s *HTTPServer) jobTagVersion(resp http.ResponseWriter, req *http.Request, jobID string, name string) (interface{}, error) { + switch req.Method { + case http.MethodPut, http.MethodPost: + return s.jobVersionApplyTag(resp, req, jobID, name) + case http.MethodDelete: + return s.jobVersionUnsetTag(resp, req, jobID, name) + default: + return nil, CodedError(405, ErrInvalidMethod) + } +} + +func (s *HTTPServer) jobVersionApplyTag(resp http.ResponseWriter, req *http.Request, jobID string, name string) (interface{}, error) { + var args api.TagVersionRequest + + if err := decodeBody(req, &args); err != nil { + return nil, CodedError(400, err.Error()) + } + + rpcArgs := structs.JobApplyTagRequest{ + JobID: jobID, + Version: args.Version, + Name: name, + Tag: &structs.JobVersionTag{ + Name: name, + Description: args.Description, + }, + } + + // parseWriteRequest overrides Namespace, Region and AuthToken + // based on values from the original http request + s.parseWriteRequest(req, &rpcArgs.WriteRequest) + + var out structs.JobTagResponse + if err := s.agent.RPC("Job.TagVersion", &rpcArgs, &out); err != nil { + return nil, err + } + return out, nil +} + +func (s *HTTPServer) jobVersionUnsetTag(resp http.ResponseWriter, req *http.Request, jobID string, name string) (interface{}, error) { + rpcArgs := structs.JobApplyTagRequest{ + JobID: jobID, + Name: name, + } + + // parseWriteRequest overrides Namespace, Region and AuthToken + // based on values from the original http request + s.parseWriteRequest(req, &rpcArgs.WriteRequest) + + var out structs.JobTagResponse + if err := s.agent.RPC("Job.TagVersion", &rpcArgs, &out); err != nil { + return nil, err + } + return out, nil +} + func (s *HTTPServer) jobSubmissionCRUD(resp http.ResponseWriter, req *http.Request, jobID string) (*structs.JobSubmission, error) { version, err := strconv.ParseUint(req.URL.Query().Get("version"), 10, 64) if err != nil { @@ -684,6 +748,9 @@ func (s *HTTPServer) jobScaleAction(resp http.ResponseWriter, req *http.Request, func (s *HTTPServer) jobVersions(resp http.ResponseWriter, req *http.Request, jobID string) (interface{}, error) { diffsStr := req.URL.Query().Get("diffs") + diffTagName := req.URL.Query().Get("diff_tag") + diffVersion := req.URL.Query().Get("diff_version") + var diffsBool bool if diffsStr != "" { var err error @@ -693,9 +760,21 @@ func (s *HTTPServer) jobVersions(resp http.ResponseWriter, req *http.Request, jo } } + var diffVersionInt *uint64 + + if diffVersion != "" { + parsedDiffVersion, err := strconv.ParseUint(diffVersion, 10, 64) + if err != nil { + return nil, fmt.Errorf("Failed to parse value of %q (%v) as a uint64: %v", "diff_version", diffVersion, err) + } + diffVersionInt = &parsedDiffVersion + } + args := structs.JobVersionsRequest{ - JobID: jobID, - Diffs: diffsBool, + JobID: jobID, + Diffs: diffsBool, + DiffVersion: diffVersionInt, + DiffTagName: diffTagName, } if s.parse(resp, req, &args.Region, &args.QueryOptions) { return nil, nil @@ -1034,6 +1113,7 @@ func ApiJobToStructJob(job *api.Job) *structs.Job { Constraints: ApiConstraintsToStructs(job.Constraints), Affinities: ApiAffinitiesToStructs(job.Affinities), UI: ApiJobUIConfigToStructs(job.UI), + VersionTag: ApiJobVersionTagToStructs(job.VersionTag), } // Update has been pushed into the task groups. stagger and max_parallel are @@ -2138,6 +2218,18 @@ func ApiJobUIConfigToStructs(jobUI *api.JobUIConfig) *structs.JobUIConfig { } } +func ApiJobVersionTagToStructs(jobVersionTag *api.JobVersionTag) *structs.JobVersionTag { + if jobVersionTag == nil { + return nil + } + + return &structs.JobVersionTag{ + Name: jobVersionTag.Name, + Description: jobVersionTag.Description, + TaggedTime: jobVersionTag.TaggedTime, + } +} + func ApiAffinityToStructs(a1 *api.Affinity) *structs.Affinity { return &structs.Affinity{ LTarget: a1.LTarget, diff --git a/command/agent/job_endpoint_test.go b/command/agent/job_endpoint_test.go index f06c5767e30..def1fde1240 100644 --- a/command/agent/job_endpoint_test.go +++ b/command/agent/job_endpoint_test.go @@ -4448,3 +4448,35 @@ func TestConversion_ApiJobUIConfigToStructs(t *testing.T) { must.Eq(t, expected, result) }) } + +func TestConversion_ApiJobVersionTagToStructs(t *testing.T) { + t.Run("nil tagged version", func(t *testing.T) { + must.Nil(t, ApiJobVersionTagToStructs(nil)) + }) + + t.Run("empty tagged version", func(t *testing.T) { + versionTag := &api.JobVersionTag{} + expected := &structs.JobVersionTag{ + Name: "", + Description: "", + TaggedTime: 0, + } + result := ApiJobVersionTagToStructs(versionTag) + must.Eq(t, expected, result) + }) + + t.Run("tagged version with tag and version", func(t *testing.T) { + versionTag := &api.JobVersionTag{ + Name: "low-latency", + Description: "Low latency version", + TaggedTime: 1234567890, + } + expected := &structs.JobVersionTag{ + Name: "low-latency", + Description: "Low latency version", + TaggedTime: 1234567890, + } + result := ApiJobVersionTagToStructs(versionTag) + must.Eq(t, expected, result) + }) +} diff --git a/command/commands.go b/command/commands.go index 51fa81db3be..026eed15e66 100644 --- a/command/commands.go +++ b/command/commands.go @@ -521,6 +521,21 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "job tag": func() (cli.Command, error) { + return &JobTagCommand{ + Meta: meta, + }, nil + }, + "job tag apply": func() (cli.Command, error) { + return &JobTagApplyCommand{ + Meta: meta, + }, nil + }, + "job tag unset": func() (cli.Command, error) { + return &JobTagUnsetCommand{ + Meta: meta, + }, nil + }, "job validate": func() (cli.Command, error) { return &JobValidateCommand{ Meta: meta, diff --git a/command/job_history.go b/command/job_history.go index 9b9e753f3ad..8d513b31090 100644 --- a/command/job_history.go +++ b/command/job_history.go @@ -40,7 +40,18 @@ General Options: History Options: -p - Display the difference between each job and its predecessor. + Display the difference between each version of the job and a reference + version. The reference version can be specified using the -diff-tag or + -diff-version flags. If neither flag is set, the most recent version is used. + + -diff-tag + Specifies the version of the job to compare against, referenced by + tag name (defaults to latest). Mutually exclusive with -diff-version. + This tag can be set using the "nomad job tag" command. + + -diff-version + Specifies the version number of the job to compare against. + Mutually exclusive with -diff-tag. -full Display the full job definition for each version. @@ -64,11 +75,13 @@ func (c *JobHistoryCommand) Synopsis() string { func (c *JobHistoryCommand) AutocompleteFlags() complete.Flags { return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), complete.Flags{ - "-p": complete.PredictNothing, - "-full": complete.PredictNothing, - "-version": complete.PredictAnything, - "-json": complete.PredictNothing, - "-t": complete.PredictAnything, + "-p": complete.PredictNothing, + "-full": complete.PredictNothing, + "-version": complete.PredictAnything, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + "-diff-tag": complete.PredictNothing, + "-diff-version": complete.PredictNothing, }) } @@ -91,7 +104,8 @@ func (c *JobHistoryCommand) Name() string { return "job history" } func (c *JobHistoryCommand) Run(args []string) int { var json, diff, full bool - var tmpl, versionStr string + var tmpl, versionStr, diffTag, diffVersionFlag string + var diffVersion *uint64 flags := c.Meta.FlagSet(c.Name(), FlagSetClient) flags.Usage = func() { c.Ui.Output(c.Help()) } @@ -100,6 +114,8 @@ func (c *JobHistoryCommand) Run(args []string) int { flags.BoolVar(&json, "json", false, "") flags.StringVar(&versionStr, "version", "", "") flags.StringVar(&tmpl, "t", "", "") + flags.StringVar(&diffTag, "diff-tag", "", "") + flags.StringVar(&diffVersionFlag, "diff-version", "", "") if err := flags.Parse(args); err != nil { return 1 @@ -118,6 +134,25 @@ func (c *JobHistoryCommand) Run(args []string) int { return 1 } + if (diffTag != "" && !diff) || (diffVersionFlag != "" && !diff) { + c.Ui.Error("-diff-tag and -diff-version can only be used with -p") + return 1 + } + + if diffTag != "" && diffVersionFlag != "" { + c.Ui.Error("-diff-tag and -diff-version are mutually exclusive") + return 1 + } + + if diffVersionFlag != "" { + parsedDiffVersion, err := strconv.ParseUint(diffVersionFlag, 10, 64) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error parsing -diff-version: %s", err)) + return 1 + } + diffVersion = &parsedDiffVersion + } + // Get the HTTP client client, err := c.Meta.Client() if err != nil { @@ -136,7 +171,12 @@ func (c *JobHistoryCommand) Run(args []string) int { q := &api.QueryOptions{Namespace: namespace} // Prefix lookup matched a single job - versions, diffs, _, err := client.Jobs().Versions(jobID, diff, q) + versionOptions := &api.VersionsOptions{ + Diffs: diff, + DiffTag: diffTag, + DiffVersion: diffVersion, + } + versions, diffs, _, err := client.Jobs().VersionsOpts(jobID, versionOptions, q) if err != nil { c.Ui.Error(fmt.Sprintf("Error retrieving job versions: %s", err)) return 1 @@ -158,7 +198,6 @@ func (c *JobHistoryCommand) Run(args []string) int { var job *api.Job var diff *api.JobDiff - var nextVersion uint64 for i, v := range versions { if *v.Version != version { continue @@ -167,7 +206,6 @@ func (c *JobHistoryCommand) Run(args []string) int { job = v if i+1 <= len(diffs) { diff = diffs[i] - nextVersion = *versions[i+1].Version } } @@ -182,7 +220,7 @@ func (c *JobHistoryCommand) Run(args []string) int { return 0 } - if err := c.formatJobVersion(job, diff, nextVersion, full); err != nil { + if err := c.formatJobVersion(job, diff, full); err != nil { c.Ui.Error(err.Error()) return 1 } @@ -222,19 +260,14 @@ func parseVersion(input string) (uint64, bool, error) { func (c *JobHistoryCommand) formatJobVersions(versions []*api.Job, diffs []*api.JobDiff, full bool) error { vLen := len(versions) dLen := len(diffs) - if dLen != 0 && vLen != dLen+1 { - return fmt.Errorf("Number of job versions %d doesn't match number of diffs %d", vLen, dLen) - } for i, version := range versions { var diff *api.JobDiff - var nextVersion uint64 if i+1 <= dLen { diff = diffs[i] - nextVersion = *versions[i+1].Version } - if err := c.formatJobVersion(version, diff, nextVersion, full); err != nil { + if err := c.formatJobVersion(version, diff, full); err != nil { return err } @@ -247,7 +280,7 @@ func (c *JobHistoryCommand) formatJobVersions(versions []*api.Job, diffs []*api. return nil } -func (c *JobHistoryCommand) formatJobVersion(job *api.Job, diff *api.JobDiff, nextVersion uint64, full bool) error { +func (c *JobHistoryCommand) formatJobVersion(job *api.Job, diff *api.JobDiff, full bool) error { if job == nil { return fmt.Errorf("Error printing job history for non-existing job or job version") } @@ -257,9 +290,15 @@ func (c *JobHistoryCommand) formatJobVersion(job *api.Job, diff *api.JobDiff, ne fmt.Sprintf("Stable|%v", *job.Stable), fmt.Sprintf("Submit Date|%v", formatTime(time.Unix(0, *job.SubmitTime))), } + // if tagged version is not nil + if job.VersionTag != nil { + basic = append(basic, fmt.Sprintf("Tag Name|%v", *&job.VersionTag.Name)) + if job.VersionTag.Description != "" { + basic = append(basic, fmt.Sprintf("Tag Description|%v", *&job.VersionTag.Description)) + } + } - if diff != nil { - //diffStr := fmt.Sprintf("Difference between version %d and %d:", *job.Version, nextVersion) + if diff != nil && diff.Type != "None" { basic = append(basic, fmt.Sprintf("Diff|\n%s", strings.TrimSpace(formatJobDiff(diff, false)))) } diff --git a/command/job_history_test.go b/command/job_history_test.go index d4be11e8790..a30256f1802 100644 --- a/command/job_history_test.go +++ b/command/job_history_test.go @@ -166,3 +166,191 @@ namespace "default" { }) } } + +func blocksFromOutput(t *testing.T, out string) []string { + t.Helper() + rawBlocks := strings.Split(out, "Version") + // trim empty blocks from whitespace in the output + var blocks []string + for _, block := range rawBlocks { + trimmed := strings.TrimSpace(block) + if trimmed != "" { + blocks = append(blocks, trimmed) + } + } + return blocks +} + +func TestJobHistoryCommand_Diffs(t *testing.T) { + ci.Parallel(t) + + // Start test server + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + state := srv.Agent.Server().State() + + // Create a job with multiple versions + v0 := mock.Job() + + v0.ID = "test-job-history" + v0.TaskGroups[0].Count = 1 + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, v0)) + + v1 := v0.Copy() + v1.TaskGroups[0].Count = 2 + v1.VersionTag = &structs.JobVersionTag{ + Name: "example-tag", + Description: "example-description", + } + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1001, nil, v1)) + + v2 := v0.Copy() + v2.TaskGroups[0].Count = 3 + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1002, nil, v2)) + + v3 := v0.Copy() + v3.TaskGroups[0].Count = 4 + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1003, nil, v3)) + + t.Run("Without diffs", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address", url, v0.ID}) + must.Zero(t, code) + + out := ui.OutputWriter.String() + // There should be four outputs + must.Eq(t, 4, strings.Count(out, "Version")) + must.Eq(t, 0, strings.Count(out, "Diff")) + }) + t.Run("With diffs", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-p", "-address", url, v0.ID}) + must.Zero(t, code) + + out := ui.OutputWriter.String() + blocks := blocksFromOutput(t, out) + + // Check that we have 4 versions + must.Len(t, 4, blocks) + must.Eq(t, 4, strings.Count(out, "Version")) + must.Eq(t, 3, strings.Count(out, "Diff")) + + // Diffs show up for all versions except the first one + must.StrContains(t, blocks[0], "Diff") + must.StrContains(t, blocks[1], "Diff") + must.StrContains(t, blocks[2], "Diff") + must.StrNotContains(t, blocks[3], "Diff") + + // Check that the diffs are specifically against their predecessor + must.StrContains(t, blocks[0], "\"3\" => \"4\"") + must.StrContains(t, blocks[1], "\"2\" => \"3\"") + must.StrContains(t, blocks[2], "\"1\" => \"2\"") + }) + + t.Run("With diffs against a specific version that doesnt exist", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-p", "-diff-version", "4", "-address", url, v0.ID}) + must.One(t, code) + // Error that version 4 doesnt exists + must.StrContains(t, ui.ErrorWriter.String(), "version 4 not found") + + }) + t.Run("With diffs against a specific version", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-p", "-diff-version", "3", "-address", url, v0.ID}) + must.Zero(t, code) + + out := ui.OutputWriter.String() + blocks := blocksFromOutput(t, out) + + // Check that we have 4 versions + must.Len(t, 4, blocks) + must.Eq(t, 4, strings.Count(out, "Version")) + must.Eq(t, 3, strings.Count(out, "Diff")) + + // Diffs show up for all versions except the specified one + must.StrNotContains(t, blocks[0], "Diff") + must.StrContains(t, blocks[1], "Diff") + must.StrContains(t, blocks[2], "Diff") + must.StrContains(t, blocks[3], "Diff") + + // Check that the diffs are specifically against the tagged version (which has a count of 4) + must.StrContains(t, blocks[1], "\"4\" => \"3\"") + must.StrContains(t, blocks[2], "\"4\" => \"2\"") + must.StrContains(t, blocks[3], "\"4\" => \"1\"") + + }) + + t.Run("With diffs against another specific version", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + // Diff against version 1 instead + code := cmd.Run([]string{"-p", "-diff-version", "2", "-address", url, v0.ID}) + must.Zero(t, code) + + out := ui.OutputWriter.String() + blocks := blocksFromOutput(t, out) + + // Check that we have 4 versions + must.Len(t, 4, blocks) + must.Eq(t, 4, strings.Count(out, "Version")) + must.Eq(t, 3, strings.Count(out, "Diff")) + + // Diffs show up for all versions except the specified one + must.StrContains(t, blocks[0], "Diff") + must.StrNotContains(t, blocks[1], "Diff") + must.StrContains(t, blocks[2], "Diff") + must.StrContains(t, blocks[3], "Diff") + + // Check that the diffs are specifically against the tagged version (which has a count of 3) + must.StrContains(t, blocks[0], "\"3\" => \"4\"") + must.StrContains(t, blocks[2], "\"3\" => \"2\"") + must.StrContains(t, blocks[3], "\"3\" => \"1\"") + }) + + t.Run("With diffs against a specific tag that doesnt exist", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-p", "-diff-tag", "nonexistent-tag", "-address", url, v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "tag \"nonexistent-tag\" not found") + }) + + t.Run("With diffs against a specific tag", func(t *testing.T) { + ui := cli.NewMockUi() + + // Run history command with diff against the tag + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + code := cmd.Run([]string{"-p", "-diff-tag", "example-tag", "-address", url, v0.ID}) + must.Zero(t, code) + + out := ui.OutputWriter.String() + blocks := blocksFromOutput(t, out) + + // Check that we have 4 versions + must.Len(t, 4, blocks) + must.Eq(t, 4, strings.Count(out, "Version")) + must.Eq(t, 3, strings.Count(out, "Diff")) + + // Check that the diff is present for versions other than the tagged version + must.StrContains(t, blocks[0], "Diff") + must.StrContains(t, blocks[1], "Diff") + must.StrNotContains(t, blocks[2], "Diff") + must.StrContains(t, blocks[3], "Diff") + + // Check that the diffs are specifically against the tagged version (which has a count of 2) + must.StrContains(t, blocks[0], "\"2\" => \"4\"") + must.StrContains(t, blocks[1], "\"2\" => \"3\"") + must.StrContains(t, blocks[3], "\"2\" => \"1\"") + }) +} diff --git a/command/job_revert.go b/command/job_revert.go index 713c06e094d..db557296446 100644 --- a/command/job_revert.go +++ b/command/job_revert.go @@ -19,7 +19,7 @@ type JobRevertCommand struct { func (c *JobRevertCommand) Help() string { helpText := ` -Usage: nomad job revert [options] +Usage: nomad job revert [options] Revert is used to revert a job to a prior version of the job. The available versions to revert to can be found using "nomad job history" command. @@ -30,6 +30,10 @@ Usage: nomad job revert [options] capability is required to monitor the resulting evaluation when -detach is not used. + If the version number is specified, the job will be reverted to the exact + version number. If a version tag is specified, the job will be reverted to + the version with the given tag. + General Options: ` + generalOptionsUsage(usageOptsDefault) + ` @@ -108,7 +112,7 @@ func (c *JobRevertCommand) Run(args []string) int { // Check that we got two args args = flags.Args() if l := len(args); l != 2 { - c.Ui.Error("This command takes two arguments: ") + c.Ui.Error("This command takes two arguments: ") c.Ui.Error(commandErrorText(c)) return 1 } @@ -132,15 +136,19 @@ func (c *JobRevertCommand) Run(args []string) int { vaultToken = os.Getenv("VAULT_TOKEN") } - // Parse the job version - revertVersion, ok, err := parseVersion(args[1]) - if !ok { - c.Ui.Error("The job version to revert to must be specified using the -job-version flag") - return 1 - } - if err != nil { - c.Ui.Error(fmt.Sprintf("Failed to parse job-version flag: %v", err)) - return 1 + // Parse the job version or version tag + var revertVersion uint64 + + parsedVersion, ok, err := parseVersion(args[1]) + if ok && err == nil { + revertVersion = parsedVersion + } else { + foundTaggedVersion, _, err := client.Jobs().VersionByTag(args[0], args[1], nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error retrieving job versions: %s", err)) + return 1 + } + revertVersion = *foundTaggedVersion.Version } // Check if the job exists diff --git a/command/job_revert_test.go b/command/job_revert_test.go index b55c48373a9..a57179f3508 100644 --- a/command/job_revert_test.go +++ b/command/job_revert_test.go @@ -200,3 +200,99 @@ namespace "default" { }) } } +func TestJobRevertCommand_VersionTag(t *testing.T) { + ci.Parallel(t) + + // Start test server + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + state := srv.Agent.Server().State() + + // Create a job with multiple versions + v0 := mock.Job() + v0.ID = "test-job-revert" + v0.TaskGroups[0].Count = 1 + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, v0)) + + v1 := v0.Copy() + v1.TaskGroups[0].Count = 2 + v1.VersionTag = &structs.JobVersionTag{ + Name: "v1-tag", + Description: "Version 1 tag", + } + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1001, nil, v1)) + + v2 := v0.Copy() + v2.TaskGroups[0].Count = 3 + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1002, nil, v2)) + + t.Run("Revert to version tag", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address", url, "-detach", "test-job-revert", "v1-tag"}) + must.Zero(t, code) + }) + + t.Run("Revert to non-existent version tag", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address", url, "-detach", "test-job-revert", "non-existent-tag"}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "Error retrieving job versions") + must.StrContains(t, ui.ErrorWriter.String(), "tag non-existent-tag not found") + }) + + t.Run("Revert to version number", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address", url, "-detach", "test-job-revert", "0"}) + must.Zero(t, code) + }) + + t.Run("Throws errors with incorrect number of args", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + + code := cmd.Run([]string{"-address", url, "test-job-revert", "v1-tag", "0"}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "This command takes two arguments") + + code2 := cmd.Run([]string{"-address", url, "test-job-revert"}) + must.One(t, code2) + must.StrContains(t, ui.ErrorWriter.String(), "This command takes two arguments") + }) + + t.Run("Revert to tagged version doesn't duplicate tag", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + + // First, revert to the tagged version + code := cmd.Run([]string{"-address", url, "-detach", "test-job-revert", "v1-tag"}) + must.Zero(t, code) + + // Now, fetch the job versions + historyCmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + historyCode := historyCmd.Run([]string{"-address", url, "-version=4", v0.ID}) + must.Zero(t, historyCode) + + // Check the output for the expected version and no tag + output := ui.OutputWriter.String() + must.StrContains(t, output, "Version = 4") + must.StrNotContains(t, output, "Tag Name") + must.StrNotContains(t, output, "Tag Description") + + ui.OutputWriter.Reset() + + // Make sure the old version of the tag is still tagged + historyCmd = &JobHistoryCommand{Meta: Meta{Ui: ui}} + historyCode = historyCmd.Run([]string{"-address", url, "-version=1", v0.ID}) + must.Zero(t, historyCode) + output = ui.OutputWriter.String() + must.StrContains(t, output, "Version = 1") + must.StrContains(t, output, "Tag Name = v1-tag") + must.StrContains(t, output, "Tag Description = Version 1 tag") + }) +} diff --git a/command/job_tag.go b/command/job_tag.go new file mode 100644 index 00000000000..3dca8700bf3 --- /dev/null +++ b/command/job_tag.go @@ -0,0 +1,37 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +type JobTagCommand struct { + Meta +} + +func (c *JobTagCommand) Name() string { return "job tag" } + +func (c *JobTagCommand) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *JobTagCommand) Synopsis() string { + return "Manage job version tags" +} + +func (c *JobTagCommand) Help() string { + helpText := ` +Usage: nomad job tag [options] [args] + + This command is used to manage tags for job versions. It has subcommands + for applying and unsetting tags. + +For more information on a specific subcommand, run: + nomad job tag -h +` + return strings.TrimSpace(helpText) +} diff --git a/command/job_tag_apply.go b/command/job_tag_apply.go new file mode 100644 index 00000000000..997efeca819 --- /dev/null +++ b/command/job_tag_apply.go @@ -0,0 +1,153 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strconv" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/posener/complete" +) + +type JobTagApplyCommand struct { + Meta +} + +func (c *JobTagApplyCommand) Help() string { + helpText := ` +Usage: nomad job tag apply [options] + + Save a job version to prevent it from being garbage-collected and allow it to + be diffed and reverted by name. + + Example usage: + + nomad job tag apply -name "My Golden Version" \ + -description "The version we can roll back to if needed" + + nomad job tag apply -version 3 -name "My Golden Version" + + The first of the above will tag the latest version of the job, while the second + will specifically tag version 3 of the job. + +Tag Specific Options: + + -name + Specifies the name of the version to tag. This is a required field. + + -description + Specifies a description for the version. This is an optional field. + + -version + Specifies the version of the job to tag. If not provided, the latest version + of the job will be tagged. + + +General Options: + + ` + generalOptionsUsage(usageOptsNoNamespace) + ` +` + return strings.TrimSpace(helpText) +} + +func (c *JobTagApplyCommand) Synopsis() string { + return "Save a job version to prevent it from being garbage-collected and allow it to be diffed and reverted by name." +} + +func (c *JobTagApplyCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-name": complete.PredictAnything, + "-description": complete.PredictAnything, + "-version": complete.PredictNothing, + }) +} + +func (c *JobTagApplyCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *JobTagApplyCommand) Name() string { return "job tag apply" } + +func (c *JobTagApplyCommand) Run(args []string) int { + var name, description, versionStr string + + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.StringVar(&name, "name", "", "") + flags.StringVar(&description, "description", "", "") + flags.StringVar(&versionStr, "version", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + if len(flags.Args()) != 1 { + c.Ui.Error("This command takes one argument: ") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + var job = flags.Args()[0] + + if job == "" { + c.Ui.Error("A job name is required") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if name == "" { + c.Ui.Error("A version tag name is required") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Check if the job exists + jobIDPrefix := strings.TrimSpace(job) + jobID, namespace, err := c.JobIDByPrefix(client, jobIDPrefix, nil) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + // If the version is not provided, get the "active" version of the job + var versionInt uint64 + if versionStr == "" { + q := &api.QueryOptions{ + Namespace: namespace, + } + latestVersion, _, err := client.Jobs().Info(job, q) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error retrieving job versions: %s", err)) + return 1 + } + versionInt = *latestVersion.Version + } else { + var parseErr error + versionInt, parseErr = strconv.ParseUint(versionStr, 10, 64) + if parseErr != nil { + c.Ui.Error(fmt.Sprintf("Error parsing version: %s", parseErr)) + return 1 + } + } + + _, err = client.Jobs().TagVersion(jobID, versionInt, name, description, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error tagging job version: %s", err)) + return 1 + } + + c.Ui.Output(fmt.Sprintf("Job version %d tagged with name \"%s\"", versionInt, name)) + + return 0 +} diff --git a/command/job_tag_test.go b/command/job_tag_test.go new file mode 100644 index 00000000000..8fb313d9b4f --- /dev/null +++ b/command/job_tag_test.go @@ -0,0 +1,170 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/mitchellh/cli" + "github.com/shoenig/test/must" +) + +func TestJobTagCommand_Implements(t *testing.T) { + ci.Parallel(t) + var _ cli.Command = &JobTagCommand{} +} +func TestJobTagApplyCommand_Implements(t *testing.T) { + ci.Parallel(t) + var _ cli.Command = &JobTagApplyCommand{} +} + +// Top-level, nomad job tag doesn't do anything on its own but list subcommands. +func TestJobTagCommand_Help(t *testing.T) { + ci.Parallel(t) + ui := cli.NewMockUi() + cmd := &JobTagCommand{Meta: Meta{Ui: ui}} + code := cmd.Run([]string{}) + must.Eq(t, -18511, code) +} + +func TestJobTagApplyCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Create a server + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + // Create a job with multiple versions + v0 := mock.Job() + + state := srv.Agent.Server().State() + + v0.ID = "test-job-applyer" + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, v0)) + + v1 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1001, nil, v1)) + + v2 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1002, nil, v2)) + + v3 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1003, nil, v3)) + + ui := cli.NewMockUi() + cmd := &JobTagApplyCommand{Meta: Meta{Ui: ui}} + + // not passing a name errors + code := cmd.Run([]string{"-address=" + url, v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "name is required") + ui.ErrorWriter.Reset() + + // passing a non-integer version errors + code = cmd.Run([]string{"-address=" + url, "-name=test", "-version=abc", v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "Error parsing version") + ui.ErrorWriter.Reset() + + // passing a specific version is fine and tags that version + code = cmd.Run([]string{"-address=" + url, "-name=test", "-version=0", v0.ID}) + must.Zero(t, code) + must.StrContains(t, ui.OutputWriter.String(), "Job version 0 tagged with name \"test\"") + ui.OutputWriter.Reset() + + // passing no version is fine and defaults to the latest version of the job + code = cmd.Run([]string{"-address=" + url, "-name=test2", v0.ID}) + apiJob, _, err := client.Jobs().Info(v0.ID, nil) + must.NoError(t, err) + must.NotNil(t, apiJob.VersionTag) + must.Eq(t, "test2", apiJob.VersionTag.Name) + must.StrContains(t, ui.OutputWriter.String(), "Job version 3 tagged with name \"test2\"") + must.Zero(t, code) + ui.OutputWriter.Reset() + + // passing a jobname that doesn't exist errors + code = cmd.Run([]string{"-address=" + url, "-name=test", "non-existent-job"}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "No job(s) with prefix or ID \"non-existent-job\" found") + ui.ErrorWriter.Reset() + + // passing a version that doesn't exist errors + code = cmd.Run([]string{"-address=" + url, "-name=test3", "-version=999", v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "version 999 not found") + ui.ErrorWriter.Reset() + + // passing a version with the same name and different version fails + code = cmd.Run([]string{"-address=" + url, "-name=test", "-version=1", v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "tag \"test\" already exists on a different version of job") + ui.ErrorWriter.Reset() +} + +func TestJobTagUnsetCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Create a server + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + ui := cli.NewMockUi() + cmd := &JobTagUnsetCommand{Meta: Meta{Ui: ui}} + + // Create a job with multiple versions + v0 := mock.Job() + + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, v0) + must.NoError(t, err) + + v0.ID = "test-job-unsetter" + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, v0)) + + v1 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1001, nil, v1)) + + v2 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1002, nil, v2)) + + v3 := v0.Copy() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, 1003, nil, v3)) + + // not passing a name errors + code := cmd.Run([]string{"-address=" + url, v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "name is required") + ui.ErrorWriter.Reset() + + // passing a jobname that doesn't exist errors + code = cmd.Run([]string{"-address=" + url, "-name=test", "non-existent-job"}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "No job(s) with prefix or ID") + ui.ErrorWriter.Reset() + + // passing a name that doesn't exist errors + code = cmd.Run([]string{"-address=" + url, "-name=non-existent-tag", v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "tag \"non-existent-tag\" not found") + ui.ErrorWriter.Reset() + + // successfully unsetting a tag + _, err = client.Jobs().TagVersion(v0.ID, 0, "test-tag", "Test description", nil) + must.NoError(t, err) + + code = cmd.Run([]string{"-address=" + url, "-name=test-tag", v0.ID}) + must.Zero(t, code) + // check the output + must.StrContains(t, ui.OutputWriter.String(), "Tag \"test-tag\" removed from job \"test-job-unsetter\"") + ui.OutputWriter.Reset() + + // attempting to unset a tag that was just unset + code = cmd.Run([]string{"-address=" + url, "-name=test-tag", v0.ID}) + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), "tag \"test-tag\" not found") + ui.ErrorWriter.Reset() +} diff --git a/command/job_tag_unset.go b/command/job_tag_unset.go new file mode 100644 index 00000000000..21c841f8883 --- /dev/null +++ b/command/job_tag_unset.go @@ -0,0 +1,110 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + + "github.com/posener/complete" +) + +type JobTagUnsetCommand struct { + Meta +} + +func (c *JobTagUnsetCommand) Help() string { + helpText := ` +Usage: nomad job tag unset [options] -name + + Remove a tag from a job version. This command requires a job ID and a tag name. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Tag Unset Options: + + -name + The name of the tag to remove from the job version. + +` + return strings.TrimSpace(helpText) +} + +func (c *JobTagUnsetCommand) Synopsis() string { + return "Remove a tag from a job version." +} + +func (c *JobTagUnsetCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{}) +} + +func (c *JobTagUnsetCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (c *JobTagUnsetCommand) Name() string { return "job tag unset" } + +func (c *JobTagUnsetCommand) Run(args []string) int { + var name string + + flags := c.Meta.FlagSet(c.Name(), FlagSetClient) + flags.Usage = func() { c.Ui.Output(c.Help()) } + flags.StringVar(&name, "name", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + if len(flags.Args()) != 1 { + c.Ui.Error("This command takes one argument: ") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + var job = flags.Args()[0] + + if job == "" { + c.Ui.Error( + "A job name is required", + ) + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if name == "" { + c.Ui.Error( + "A version tag name is required", + ) + c.Ui.Error(commandErrorText(c)) + return 1 + } + + // Get the HTTP client + client, err := c.Meta.Client() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Check if the job exists + jobIDPrefix := strings.TrimSpace(job) + jobID, _, err := c.JobIDByPrefix(client, jobIDPrefix, nil) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + _, err = client.Jobs().UntagVersion(jobID, name, nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error tagging job version: %s", err)) + return 1 + } + + c.Ui.Output(fmt.Sprintf("Tag \"%s\" removed from job \"%s\"", name, job)) + + return 0 +} diff --git a/nomad/core_sched.go b/nomad/core_sched.go index 83999d064eb..3433daac7cc 100644 --- a/nomad/core_sched.go +++ b/nomad/core_sched.go @@ -156,6 +156,17 @@ OUTER: // Job is eligible for garbage collection if allEvalsGC { + // if any version of the job is tagged, it should be kept + versions, err := c.snap.JobVersionsByID(ws, job.Namespace, job.ID) + if err != nil { + c.logger.Error("job GC failed to get versions for job", "job", job.ID, "error", err) + continue + } + for _, v := range versions { + if v.VersionTag != nil { + continue OUTER + } + } gcJob = append(gcJob, job) gcAlloc = append(gcAlloc, jobAlloc...) gcEval = append(gcEval, jobEval...) diff --git a/nomad/core_sched_test.go b/nomad/core_sched_test.go index b47d0ebdac9..1ad94d3b6c8 100644 --- a/nomad/core_sched_test.go +++ b/nomad/core_sched_test.go @@ -610,6 +610,94 @@ func TestCoreScheduler_EvalGC_Batch(t *testing.T) { }) } +// A job that has any of its versions tagged should not be GC-able. +func TestCoreScheduler_EvalGC_JobVersionTag(t *testing.T) { + ci.Parallel(t) + + s1, cleanupS1 := TestServer(t, nil) + defer cleanupS1() + testutil.WaitForLeader(t, s1.RPC) + + store := s1.fsm.State() + job := mock.MinJob() + job.Stop = true // to be GC-able + + // to be GC-able, the job needs an associated eval with a terminal Status, + // so that the job gets considered "dead" and not "pending" + // NOTE: this needs to come before UpsertJob for some Mystery Reason + // (otherwise job Status ends up as "pending" later) + eval := mock.Eval() + eval.JobID = job.ID + eval.Status = structs.EvalStatusComplete + must.NoError(t, store.UpsertEvals(structs.MsgTypeTestSetup, 999, []*structs.Evaluation{eval})) + // upsert a couple versions of the job, so the "jobs" table has one + // and the "job_version" table has two. + must.NoError(t, store.UpsertJob(structs.MsgTypeTestSetup, 1000, nil, job.Copy())) + must.NoError(t, store.UpsertJob(structs.MsgTypeTestSetup, 1001, nil, job.Copy())) + + jobExists := func(t *testing.T) bool { + t.Helper() + // any job at all + jobs, err := store.Jobs(nil, state.SortDefault) + must.NoError(t, err, must.Sprint("error getting jobs")) + return jobs.Next() != nil + } + forceGC := func(t *testing.T) { + t.Helper() + snap, err := store.Snapshot() + must.NoError(t, err) + core := NewCoreScheduler(s1, snap) + + idx, err := store.LatestIndex() + must.NoError(t, err) + gc := s1.coreJobEval(structs.CoreJobForceGC, idx+1) + + must.NoError(t, core.Process(gc)) + } + + applyTag := func(t *testing.T, idx, version uint64, name, desc string) { + t.Helper() + must.NoError(t, store.UpdateJobVersionTag(idx, job.Namespace, + &structs.JobApplyTagRequest{ + JobID: job.ID, + Name: name, + Tag: &structs.JobVersionTag{ + Name: name, + Description: desc, + }, + Version: version, + })) + } + unsetTag := func(t *testing.T, idx uint64, name string) { + t.Helper() + must.NoError(t, store.UpdateJobVersionTag(idx, job.Namespace, + &structs.JobApplyTagRequest{ + JobID: job.ID, + Name: name, + Tag: nil, // this triggers the deletion + })) + } + + // tagging the latest version (latest of the 2 jobs, 0 and 1, is 1) + // will tag the job in the "jobs" table, which should protect from GC + applyTag(t, 2000, 1, "v1", "version 1") + forceGC(t) + must.True(t, jobExists(t), must.Sprint("latest job version being tagged should protect from GC")) + + // untagging latest and tagging the oldest (only one in "job_version" table) + // should also protect from GC + unsetTag(t, 3000, "v1") + applyTag(t, 3001, 0, "v0", "version 0") + forceGC(t) + must.True(t, jobExists(t), must.Sprint("old job version being tagged should protect from GC")) + + //untagging v0 should leave no tags left, so GC should delete the job + //and all its versions + unsetTag(t, 4000, "v0") + forceGC(t) + must.False(t, jobExists(t), must.Sprint("all tags being removed should enable GC")) +} + func TestCoreScheduler_EvalGC_Partial(t *testing.T) { ci.Parallel(t) diff --git a/nomad/fsm.go b/nomad/fsm.go index 3996708d9fc..d862c36177c 100644 --- a/nomad/fsm.go +++ b/nomad/fsm.go @@ -395,6 +395,8 @@ func (n *nomadFSM) Apply(log *raft.Log) interface{} { case structs.WrappedRootKeysUpsertRequestType: return n.applyWrappedRootKeysUpsert(msgType, buf[1:], log.Index) + case structs.JobVersionTagRequestType: + return n.applyJobVersionTag(buf[1:], log.Index) } // Check enterprise only message types. @@ -1188,6 +1190,22 @@ func (n *nomadFSM) applyDeploymentDelete(buf []byte, index uint64) interface{} { return nil } +// applyJobVersionTag is used to tag a job version for diffing and GC-prevention +func (n *nomadFSM) applyJobVersionTag(buf []byte, index uint64) interface{} { + defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_job_version_tag"}, time.Now()) + var req structs.JobApplyTagRequest + if err := structs.Decode(buf, &req); err != nil { + panic(fmt.Errorf("failed to decode request: %v", err)) + } + + if err := n.state.UpdateJobVersionTag(index, req.RequestNamespace(), &req); err != nil { + n.logger.Error("UpdateJobVersionTag failed", "error", err) + return err + } + + return nil +} + // applyJobStability is used to set the stability of a job func (n *nomadFSM) applyJobStability(buf []byte, index uint64) interface{} { defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_job_stability"}, time.Now()) diff --git a/nomad/job_endpoint.go b/nomad/job_endpoint.go index b8fbf4120ab..e30ab29a611 100644 --- a/nomad/job_endpoint.go +++ b/nomad/job_endpoint.go @@ -631,7 +631,6 @@ func (j *Job) Revert(args *structs.JobRevertRequest, reply *structs.JobRegisterR if err != nil { return err } - ws := memdb.NewWatchSet() cur, err := snap.JobByID(ws, args.RequestNamespace(), args.JobID) if err != nil { @@ -656,6 +655,10 @@ func (j *Job) Revert(args *structs.JobRevertRequest, reply *structs.JobRegisterR revJob := jobV.Copy() revJob.VaultToken = args.VaultToken // use vault token from revert to perform (re)registration revJob.ConsulToken = args.ConsulToken // use consul token from revert to perform (re)registration + + // Clear out the VersionTag to prevent tag duplication + revJob.VersionTag = nil + reg := &structs.JobRegisterRequest{ Job: revJob, WriteRequest: args.WriteRequest, @@ -1298,12 +1301,65 @@ func (j *Job) GetJobVersions(args *structs.JobVersionsRequest, // Setup the output reply.Versions = out if len(out) != 0 { - reply.Index = out[0].ModifyIndex + + var compareVersionNumber uint64 + var compareVersion *structs.Job + var compareSpecificVersion bool + + if args.Diffs { + if args.DiffTagName != "" { + compareSpecificVersion = true + compareVersion, err = state.JobVersionByTagName(ws, args.RequestNamespace(), args.JobID, args.DiffTagName) + if err != nil { + return fmt.Errorf("error looking up job version by tag: %v", err) + } + if compareVersion == nil { + return fmt.Errorf("tag %q not found", args.DiffTagName) + } + compareVersionNumber = compareVersion.Version + } else if args.DiffVersion != nil { + compareSpecificVersion = true + compareVersionNumber = *args.DiffVersion + } + } + + // Note: a previous assumption here was that the 0th job was the latest, and that we don't modify "old" versions. + // Adding version tags breaks this assumption (you can tag an old version, which should unblock /versions queries) so we now look for the highest ModifyIndex. + var maxModifyIndex uint64 + for _, job := range out { + if job.ModifyIndex > maxModifyIndex { + maxModifyIndex = job.ModifyIndex + } + if compareSpecificVersion && job.Version == compareVersionNumber { + compareVersion = job + } + } + reply.Index = maxModifyIndex + + if compareSpecificVersion && compareVersion == nil { + if args.DiffTagName != "" { + return fmt.Errorf("tag %q not found", args.DiffTagName) + } + return fmt.Errorf("version %d not found", *args.DiffVersion) + } // Compute the diffs + if args.Diffs { - for i := 0; i < len(out)-1; i++ { - old, new := out[i+1], out[i] + for i := 0; i < len(out); i++ { + var old, new *structs.Job + new = out[i] + + if compareSpecificVersion { + old = compareVersion + } else { + if i == len(out)-1 { + // Skip the last version if not comparing to a specific version + break + } + old = out[i+1] + } + d, err := old.Diff(new, true) if err != nil { return fmt.Errorf("failed to create job diff: %v", err) @@ -2391,3 +2447,42 @@ func (j *Job) GetServiceRegistrations( }, }) } + +func (j *Job) TagVersion(args *structs.JobApplyTagRequest, reply *structs.JobTagResponse) error { + authErr := j.srv.Authenticate(j.ctx, args) + if done, err := j.srv.forward("Job.TagVersion", args, args, reply); done { + return err + } + j.srv.MeasureRPCRate("job", structs.RateMetricWrite, args) + if authErr != nil { + return structs.ErrPermissionDenied + } + defer metrics.MeasureSince([]string{"nomad", "job", "tag_version"}, time.Now()) + + aclObj, err := j.srv.ResolveACL(args) + if err != nil { + return err + } + if !aclObj.AllowNsOp(args.RequestNamespace(), acl.NamespaceCapabilitySubmitJob) { + return structs.ErrPermissionDenied + } + + if args.Tag != nil { + args.Tag.TaggedTime = time.Now().UnixNano() + } + + _, index, err := j.srv.raftApply(structs.JobVersionTagRequestType, args) + if err != nil { + j.logger.Error("tagging version failed", "error", err) + return err + } + + reply.Index = index + if args.Tag != nil { + reply.Name = args.Tag.Name + reply.Description = args.Tag.Description + reply.TaggedTime = args.Tag.TaggedTime + } + + return nil +} diff --git a/nomad/state/schema.go b/nomad/state/schema.go index 7aaafd5cfef..2c798b06fbe 100644 --- a/nomad/state/schema.go +++ b/nomad/state/schema.go @@ -372,6 +372,11 @@ func jobIsGCable(obj interface{}) (bool, error) { return false, fmt.Errorf("Unexpected type: %v", obj) } + // job versions that are tagged should be kept + if j.VersionTag != nil { + return false, nil + } + // If the job is periodic or parameterized it is only garbage collectable if // it is stopped. periodic := j.Periodic != nil && j.Periodic.Enabled diff --git a/nomad/state/schema_test.go b/nomad/state/schema_test.go index 657cc808bd0..47415740fbc 100644 --- a/nomad/state/schema_test.go +++ b/nomad/state/schema_test.go @@ -242,6 +242,14 @@ func Test_jobIsGCable(t *testing.T) { expectedOutput: true, expectedOutputError: nil, }, + { + name: "tagged", + inputObj: &structs.Job{ + VersionTag: &structs.JobVersionTag{Name: "any"}, + }, + expectedOutput: false, + expectedOutputError: nil, + }, } for _, tc := range testCases { diff --git a/nomad/state/state_store.go b/nomad/state/state_store.go index a6f96e4d5fc..c9bfbcbffe7 100644 --- a/nomad/state/state_store.go +++ b/nomad/state/state_store.go @@ -2188,10 +2188,21 @@ func (s *StateStore) upsertJobVersion(index uint64, job *structs.Job, txn *txn) all[max-1], all[max] = all[max], all[max-1] } - // Delete the job outside of the set that are being kept. - d := all[max] - if err := txn.Delete("job_version", d); err != nil { - return fmt.Errorf("failed to delete job %v (%d) from job_version", d.ID, d.Version) + // Find the oldest non-tagged version to delete + deleteIdx := -1 + for i := len(all) - 1; i >= max; i-- { + if all[i].VersionTag == nil { + deleteIdx = i + break + } + } + + // If we found a non-tagged version to delete, delete it + if deleteIdx != -1 { + d := all[deleteIdx] + if err := txn.Delete("job_version", d); err != nil { + return fmt.Errorf("failed to delete job %v (%d) from job_version", d.ID, d.Version) + } } return nil @@ -2299,13 +2310,28 @@ func (s *StateStore) jobsByIDPrefixAllNamespaces(ws memdb.WatchSet, prefix strin return wrap, nil } -// JobVersionsByID returns all the tracked versions of a job. +// JobVersionsByID returns all the tracked versions of a job, sorted in from highest version to lowest. func (s *StateStore) JobVersionsByID(ws memdb.WatchSet, namespace, id string) ([]*structs.Job, error) { txn := s.db.ReadTxn() return s.jobVersionByID(txn, ws, namespace, id) } +// JobVersionByTagName returns a Job if it has a Tag with the passed name +func (s *StateStore) JobVersionByTagName(ws memdb.WatchSet, namespace, id string, tagName string) (*structs.Job, error) { + // First get all versions of the job + versions, err := s.JobVersionsByID(ws, namespace, id) + if err != nil { + return nil, err + } + for _, j := range versions { + if j.VersionTag != nil && j.VersionTag.Name == tagName { + return j, nil + } + } + return nil, nil +} + // jobVersionByID is the underlying implementation for retrieving all tracked // versions of a job and is called under an existing transaction. A watch set // can optionally be passed in to add the job histories to the watch set. @@ -4903,6 +4929,95 @@ func (s *StateStore) updateJobStabilityImpl(index uint64, namespace, jobID strin return s.upsertJobImpl(index, nil, copy, true, txn) } +func (s *StateStore) UpdateJobVersionTag(index uint64, namespace string, req *structs.JobApplyTagRequest) error { + jobID := req.JobID + jobVersion := req.Version + tag := req.Tag + name := req.Name + + txn := s.db.WriteTxn(index) + defer txn.Abort() + + // if no tag is present, this is a tag removal operation. + if tag == nil { + if err := s.unsetJobVersionTagImpl(index, namespace, jobID, name, txn); err != nil { + return err + } + } else { + if err := s.updateJobVersionTagImpl(index, namespace, jobID, jobVersion, tag, txn); err != nil { + return err + } + } + + return txn.Commit() +} + +func (s *StateStore) updateJobVersionTagImpl(index uint64, namespace, jobID string, jobVersion uint64, tag *structs.JobVersionTag, txn *txn) error { + // Note: could use JobByIDAndVersion to get the specific version we want here, + // but then we'd have to make a second lookup to make sure we're not applying a duplicate tag name + versions, err := s.JobVersionsByID(nil, namespace, jobID) + if err != nil { + return err + } + + var job *structs.Job + + for _, version := range versions { + // Allow for a tag to be updated (new description, for example) but otherwise don't allow a same-tagname to a different version. + if version.VersionTag != nil && version.VersionTag.Name == tag.Name && version.Version != jobVersion { + return fmt.Errorf("tag %q already exists on a different version of job %q", tag.Name, jobID) + } + if version.Version == jobVersion { + job = version + } + } + + if job == nil { + return fmt.Errorf("job %q version %d not found", jobID, jobVersion) + } + + versionCopy := job.Copy() + versionCopy.VersionTag = tag + versionCopy.ModifyIndex = index + + latestJob, err := s.JobByID(nil, namespace, jobID) + if err != nil { + return err + } + if versionCopy.Version == latestJob.Version { + if err := txn.Insert("jobs", versionCopy); err != nil { + return err + } + } + + return s.upsertJobVersion(index, versionCopy, txn) +} + +func (s *StateStore) unsetJobVersionTagImpl(index uint64, namespace, jobID string, name string, txn *txn) error { + job, err := s.JobVersionByTagName(nil, namespace, jobID, name) + if err != nil { + return err + } + if job == nil { + return fmt.Errorf("tag %q not found on job %q", name, jobID) + } + + versionCopy := job.Copy() + versionCopy.VersionTag = nil + versionCopy.ModifyIndex = index + latestJob, err := s.JobByID(nil, namespace, jobID) + if err != nil { + return err + } + if versionCopy.Version == latestJob.Version { + if err := txn.Insert("jobs", versionCopy); err != nil { + return err + } + } + + return s.upsertJobVersion(index, versionCopy, txn) +} + // UpdateDeploymentPromotion is used to promote canaries in a deployment and // potentially make a evaluation func (s *StateStore) UpdateDeploymentPromotion(msgType structs.MessageType, index uint64, req *structs.ApplyDeploymentPromoteRequest) error { diff --git a/nomad/state/state_store_test.go b/nomad/state/state_store_test.go index cbb81e50f1a..bc9a328fa5c 100644 --- a/nomad/state/state_store_test.go +++ b/nomad/state/state_store_test.go @@ -2901,6 +2901,152 @@ func TestStateStore_DeleteJobTxn_BatchDeletes(t *testing.T) { require.Equal(t, deletionIndex, index) } +// TestStatestore_JobVersionTag tests that job versions which are tagged +// do not count against the configured server.job_tracked_versions count, +// do not get deleted when new versions are created, +// and *do* get deleted immediately when its tag is removed. +func TestStatestore_JobVersionTag(t *testing.T) { + ci.Parallel(t) + + state := testStateStore(t) + // tagged versions should be excluded from this limit + state.config.JobTrackedVersions = 5 + + job := mock.MinJob() + job.Stable = true + + // helpers for readability + upsertJob := func(t *testing.T) { + t.Helper() + must.NoError(t, state.UpsertJob(structs.MsgTypeTestSetup, nextIndex(state), nil, job.Copy())) + } + + applyTag := func(t *testing.T, version uint64) { + t.Helper() + name := fmt.Sprintf("v%d", version) + desc := fmt.Sprintf("version %d", version) + req := &structs.JobApplyTagRequest{ + JobID: job.ID, + Name: name, + Tag: &structs.JobVersionTag{ + Name: name, + Description: desc, + }, + Version: version, + } + must.NoError(t, state.UpdateJobVersionTag(nextIndex(state), job.Namespace, req)) + + // confirm + got, err := state.JobVersionByTagName(nil, job.Namespace, job.ID, name) + must.NoError(t, err) + must.Eq(t, version, got.Version) + must.Eq(t, name, got.VersionTag.Name) + must.Eq(t, desc, got.VersionTag.Description) + } + unsetTag := func(t *testing.T, name string) { + t.Helper() + req := &structs.JobApplyTagRequest{ + JobID: job.ID, + Name: name, + Tag: nil, // this triggers unset + } + must.NoError(t, state.UpdateJobVersionTag(nextIndex(state), job.Namespace, req)) + } + + assertVersions := func(t *testing.T, expect []uint64) { + t.Helper() + jobs, err := state.JobVersionsByID(nil, job.Namespace, job.ID) + must.NoError(t, err) + vs := make([]uint64, len(jobs)) + for i, j := range jobs { + vs[i] = j.Version + } + must.Eq(t, expect, vs) + } + + // we want to end up with JobTrackedVersions (5) versions, + // 0-2 tagged and 3-4 untagged, but also interleave the tagging + // to be somewhat true to normal behavior in reality. + { + // upsert 3 jobs + for range 3 { + upsertJob(t) + } + assertVersions(t, []uint64{2, 1, 0}) + + // tag 2 of them + applyTag(t, 0) + applyTag(t, 1) + // nothing should change + assertVersions(t, []uint64{2, 1, 0}) + + // add 2 more, up to JobTrackedVersions (5) + upsertJob(t) + upsertJob(t) + assertVersions(t, []uint64{4, 3, 2, 1, 0}) + + // tag one more + applyTag(t, 2) + // again nothing should change + assertVersions(t, []uint64{4, 3, 2, 1, 0}) + } + + // removing a tag at this point should leave the version in place + { + unsetTag(t, "v2") + assertVersions(t, []uint64{4, 3, 2, 1, 0}) + } + + // adding more versions should replace 2-4, + // and leave 0-1 in place because they are tagged + { + for range 10 { + upsertJob(t) + } + assertVersions(t, []uint64{14, 13, 12, 11, 10, 1, 0}) + } + + // untagging version 1 now should delete it immediately, + // since we now have more than JobTrackedVersions + { + unsetTag(t, "v1") + assertVersions(t, []uint64{14, 13, 12, 11, 10, 0}) + } + + // test some error conditions + { + // job does not exist + err := state.UpdateJobVersionTag(nextIndex(state), job.Namespace, &structs.JobApplyTagRequest{ + JobID: "non-existent-job", + Tag: &structs.JobVersionTag{Name: "tag name"}, + Version: 0, + }) + must.ErrorContains(t, err, `job "non-existent-job" version 0 not found`) + + // version does not exist + err = state.UpdateJobVersionTag(nextIndex(state), job.Namespace, &structs.JobApplyTagRequest{ + JobID: job.ID, + Tag: &structs.JobVersionTag{Name: "tag name"}, + Version: 999, + }) + must.ErrorContains(t, err, fmt.Sprintf("job %q version 999 not found", job.ID)) + + // tag name already exists + err = state.UpdateJobVersionTag(nextIndex(state), job.Namespace, &structs.JobApplyTagRequest{ + JobID: job.ID, + Tag: &structs.JobVersionTag{Name: "v0"}, + Version: 10, + }) + must.ErrorContains(t, err, fmt.Sprintf(`"v0" already exists on a different version of job %q`, job.ID)) + } + + // deleting all versions should also delete tagged versions + txn := state.db.WriteTxn(nextIndex(state)) + must.NoError(t, state.deleteJobVersions(nextIndex(state), job, txn)) + must.NoError(t, txn.Commit()) + assertVersions(t, []uint64{}) +} + func TestStateStore_DeleteJob_MultipleVersions(t *testing.T) { ci.Parallel(t) diff --git a/nomad/structs/structs.go b/nomad/structs/structs.go index 96cb8cbb274..00282c8ba6b 100644 --- a/nomad/structs/structs.go +++ b/nomad/structs/structs.go @@ -1651,8 +1651,10 @@ type JobListResponse struct { // JobVersionsRequest is used to get a jobs versions type JobVersionsRequest struct { - JobID string - Diffs bool + JobID string + Diffs bool + DiffVersion *uint64 + DiffTagName string QueryOptions } @@ -4574,6 +4576,41 @@ type Job struct { // Links and Description fields for the Web UI UI *JobUIConfig + + // Metadata related to a tagged Job Version (which itself is really a Job) + VersionTag *JobVersionTag +} + +type JobVersionTag struct { + Name string + Description string + TaggedTime int64 +} + +type JobApplyTagRequest struct { + JobID string + Name string + Tag *JobVersionTag + Version uint64 + WriteRequest +} + +type JobTagResponse struct { + Name string + Description string + TaggedTime int64 + QueryMeta +} + +func (tv *JobVersionTag) Copy() *JobVersionTag { + if tv == nil { + return nil + } + return &JobVersionTag{ + Name: tv.Name, + Description: tv.Description, + TaggedTime: tv.TaggedTime, + } } type JobUIConfig struct { @@ -4730,6 +4767,7 @@ func (j *Job) Copy() *Job { nj.Affinities = CopySliceAffinities(j.Affinities) nj.Multiregion = j.Multiregion.Copy() nj.UI = j.UI.Copy() + nj.VersionTag = j.VersionTag.Copy() if j.TaskGroups != nil { tgs := make([]*TaskGroup, len(j.TaskGroups)) @@ -4827,6 +4865,12 @@ func (j *Job) Validate() error { } } + if j.VersionTag != nil { + if len(j.VersionTag.Description) > MaxDescriptionCharacters { + mErr.Errors = append(mErr.Errors, fmt.Errorf("Tagged version description must be under 1000 characters, currently %d", len(j.VersionTag.Description))) + } + } + // Check for duplicate task groups taskGroups := make(map[string]int) for idx, tg := range j.TaskGroups { diff --git a/nomad/structs/structs_test.go b/nomad/structs/structs_test.go index 803e57976e9..520252b9530 100644 --- a/nomad/structs/structs_test.go +++ b/nomad/structs/structs_test.go @@ -397,6 +397,18 @@ func TestJob_Validate(t *testing.T) { "Task Group web should have an ephemeral disk object", }, }, + { + name: "VersionTag Description length", + job: &Job{ + Type: JobTypeService, + VersionTag: &JobVersionTag{ + Description: strings.Repeat("a", 1001), + }, + }, + expErr: []string{ + "Tagged version description must be under 1000 characters", + }, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/ui/app/adapters/job.js b/ui/app/adapters/job.js index c0174ab25d0..1d195876cdd 100644 --- a/ui/app/adapters/job.js +++ b/ui/app/adapters/job.js @@ -171,6 +171,25 @@ export default class JobAdapter extends WatchableNamespaceIDs { }); } + getVersions(job, diffVersion) { + const url = addToPath( + this.urlForFindRecord(job.get('id'), 'job'), + '/versions' + ); + + const namespace = job.get('namespace.name') || 'default'; + + const query = { + namespace, + diffs: true, + }; + + if (diffVersion) { + query.diff_version = diffVersion; + } + return this.ajax(url, 'GET', { data: query }); + } + /** * * @param {import('../models/job').default} job diff --git a/ui/app/adapters/version-tag.js b/ui/app/adapters/version-tag.js new file mode 100644 index 00000000000..4ffc55f71cb --- /dev/null +++ b/ui/app/adapters/version-tag.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check + +import ApplicationAdapter from './application'; +import classic from 'ember-classic-decorator'; + +@classic +export default class VersionTagAdapter extends ApplicationAdapter { + urlForCreateRecord(_modelName, model) { + const tagName = model.attr('name'); + const jobName = model.attr('jobName'); + return `${this.buildURL()}/job/${jobName}/versions/${tagName}/tag`; + } + + async deleteTag(jobName, tagName) { + let deletion = this.ajax( + this.urlForDeleteRecord(jobName, tagName), + 'DELETE' + ); + return deletion; + } + + urlForDeleteRecord(jobName, tagName) { + return `${this.buildURL()}/job/${jobName}/versions/${tagName}/tag`; + } +} diff --git a/ui/app/components/job-version.js b/ui/app/components/job-version.js index e05c54b8f70..3dbfb7f7f06 100644 --- a/ui/app/components/job-version.js +++ b/ui/app/components/job-version.js @@ -3,30 +3,53 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Component from '@ember/component'; +import Component from '@glimmer/component'; import { action, computed } from '@ember/object'; -import { classNames } from '@ember-decorators/component'; +import { alias } from '@ember/object/computed'; import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; import { task } from 'ember-concurrency'; import messageForError from 'nomad-ui/utils/message-from-adapter-error'; -import classic from 'ember-classic-decorator'; const changeTypes = ['Added', 'Deleted', 'Edited']; -@classic -@classNames('job-version', 'boxed-section') export default class JobVersion extends Component { - version = null; - isOpen = false; + @service store; + @service notifications; + @service router; + + @alias('args.version') version; + @tracked isOpen = false; + @tracked isEditing = false; + @tracked editableTag; // Passes through to the job-diff component verbose = true; - @service router; + constructor() { + super(...arguments); + this.initializeEditableTag(); + if (this.args.diffsExpanded && this.version.diff) { + this.isOpen = true; + } + } + + initializeEditableTag() { + if (this.version.versionTag) { + this.editableTag = this.store.createRecord('versionTag', { + name: this.version.versionTag.name, + description: this.version.versionTag.description, + }); + } else { + this.editableTag = this.store.createRecord('versionTag'); + } + this.editableTag.versionNumber = this.version.number; + this.editableTag.jobName = this.version.get('job.plainId'); + } @computed('version.diff') get changeCount() { - const diff = this.get('version.diff'); + const diff = this.version.diff; const taskGroups = diff.TaskGroups || []; if (!diff) { @@ -44,36 +67,34 @@ export default class JobVersion extends Component { @computed('version.{number,job.version}') get isCurrent() { - return this.get('version.number') === this.get('version.job.version'); + return this.version.number === this.version.get('job.version'); } @action toggleDiff() { - this.toggleProperty('isOpen'); + this.isOpen = !this.isOpen; } @task(function* () { try { - const versionBeforeReversion = this.get('version.job.version'); - + const versionBeforeReversion = this.version.get('job.version'); yield this.version.revertTo(); - yield this.version.job.reload(); - - const versionAfterReversion = this.get('version.job.version'); + yield this.version.get('job').reload(); + const versionAfterReversion = this.version.get('job.version'); if (versionBeforeReversion === versionAfterReversion) { - this.handleError({ + this.args.handleError({ level: 'warn', title: 'Reversion Had No Effect', description: 'Reverting to an identical older version doesn’t produce a new version', }); } else { - const job = this.get('version.job'); + const job = this.version.get('job'); this.router.transitionTo('jobs.job.index', job.get('idWithNamespace')); } } catch (e) { - this.handleError({ + this.args.handleError({ level: 'danger', title: 'Could Not Revert', description: messageForError(e, 'revert'), @@ -81,6 +102,80 @@ export default class JobVersion extends Component { } }) revertTo; + + @action + handleKeydown(event) { + if (event.key === 'Escape') { + this.cancelEditTag(); + } + } + + @action + toggleEditTag() { + this.isEditing = !this.isEditing; + } + + @action + async saveTag(event) { + event.preventDefault(); + try { + if (!this.editableTag.name) { + this.notifications.add({ + title: 'Error Tagging Job Version', + message: 'Tag name is required', + color: 'critical', + }); + return; + } + const savedTag = await this.editableTag.save(); + this.version.versionTag = savedTag; + this.version.versionTag.setProperties({ + ...savedTag.toJSON(), + }); + this.initializeEditableTag(); + this.isEditing = false; + + this.notifications.add({ + title: 'Job Version Tagged', + color: 'success', + }); + } catch (error) { + console.log('error tagging job version', error); + this.notifications.add({ + title: 'Error Tagging Job Version', + message: messageForError(error), + color: 'critical', + }); + } + } + + @action + cancelEditTag() { + this.isEditing = false; + this.initializeEditableTag(); + } + + @action + async deleteTag() { + try { + await this.store + .adapterFor('version-tag') + .deleteTag(this.editableTag.jobName, this.editableTag.name); + this.notifications.add({ + title: 'Job Version Un-Tagged', + color: 'success', + }); + this.version.versionTag = null; + this.initializeEditableTag(); + this.isEditing = false; + } catch (error) { + this.notifications.add({ + title: 'Error Un-Tagging Job Version', + message: messageForError(error), + color: 'critical', + }); + } + } } const flatten = (accumulator, array) => accumulator.concat(array); diff --git a/ui/app/controllers/jobs/job/versions.js b/ui/app/controllers/jobs/job/versions.js index 1eb725fee46..7a14cc434b4 100644 --- a/ui/app/controllers/jobs/job/versions.js +++ b/ui/app/controllers/jobs/job/versions.js @@ -3,11 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ +// @ts-check + import Controller from '@ember/controller'; import WithNamespaceResetting from 'nomad-ui/mixins/with-namespace-resetting'; import { alias } from '@ember/object/computed'; import { action, computed } from '@ember/object'; import classic from 'ember-classic-decorator'; +import { tracked } from '@glimmer/tracking'; + +import { serialize } from 'nomad-ui/utils/qp-serialize'; const alertClassFallback = 'is-info'; @@ -24,6 +29,8 @@ export default class VersionsController extends Controller.extend( @alias('model') job; + queryParams = ['diffVersion']; + @computed('error.level') get errorLevelClass() { return ( @@ -39,4 +46,27 @@ export default class VersionsController extends Controller.extend( handleError(errorObject) { this.set('error', errorObject); } + + @tracked diffVersion = ''; + + get optionsDiff() { + return this.job.versions.map((version) => { + return { + label: version.versionTag?.name || `version ${version.number}`, + value: String(version.number), + }; + }); + } + + get diffsExpanded() { + return this.diffVersion !== ''; + } + + @action setDiffVersion(label) { + if (!label) { + this.diffVersion = ''; + } else { + this.diffVersion = serialize(label); + } + } } diff --git a/ui/app/models/job-version.js b/ui/app/models/job-version.js index c4a0ef5f729..91f6cc55556 100644 --- a/ui/app/models/job-version.js +++ b/ui/app/models/job-version.js @@ -4,6 +4,7 @@ */ import Model from '@ember-data/model'; +import { fragment } from 'ember-data-model-fragments/attributes'; import { attr, belongsTo } from '@ember-data/model'; export default class JobVersion extends Model { @@ -12,6 +13,7 @@ export default class JobVersion extends Model { @attr('date') submitTime; @attr('number') number; @attr() diff; + @fragment('version-tag') versionTag; revertTo() { return this.store.adapterFor('job-version').revertTo(this); diff --git a/ui/app/models/job.js b/ui/app/models/job.js index e2ab8de0392..eee53d64728 100644 --- a/ui/app/models/job.js +++ b/ui/app/models/job.js @@ -431,7 +431,7 @@ export default class Job extends Model { @attr('number') version; - @hasMany('job-versions') versions; + @hasMany('job-versions', { async: true }) versions; @hasMany('allocations') allocations; @hasMany('deployments') deployments; @hasMany('evaluations') evaluations; @@ -573,6 +573,9 @@ export default class Job extends Model { return this.store.adapterFor('job').update(this); } + getVersions(diffVersion) { + return this.store.adapterFor('job').getVersions(this, diffVersion); + } parse() { const definition = this._newDefinition; const variables = this._newDefinitionVariables; diff --git a/ui/app/models/version-tag.js b/ui/app/models/version-tag.js new file mode 100644 index 00000000000..6aec72a602c --- /dev/null +++ b/ui/app/models/version-tag.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Fragment from 'ember-data-model-fragments/fragment'; +import { attr } from '@ember-data/model'; + +export default class VersionTagModel extends Fragment { + @attr() name; + @attr() description; + @attr() taggedTime; + @attr('number') versionNumber; + @attr('string') jobName; +} diff --git a/ui/app/routes/jobs/job/versions.js b/ui/app/routes/jobs/job/versions.js index 489e5b8e58f..3f3a507e996 100644 --- a/ui/app/routes/jobs/job/versions.js +++ b/ui/app/routes/jobs/job/versions.js @@ -15,9 +15,22 @@ import { inject as service } from '@ember/service'; export default class VersionsRoute extends Route.extend(WithWatchers) { @service store; - model() { + queryParams = { + diffVersion: { + refreshModel: true, + }, + }; + + async model(params) { const job = this.modelFor('jobs.job'); - return job && job.get('versions').then(() => job); + const versions = await job.getVersions(params.diffVersion); + + job.versions = job.versions.map((v, i) => { + const diff = versions.Diffs[i]; + v.set('diff', diff); + return v; + }); + return job; } startWatchers(controller, model) { diff --git a/ui/app/serializers/job-version.js b/ui/app/serializers/job-version.js index 4982501588e..814b3b4e655 100644 --- a/ui/app/serializers/job-version.js +++ b/ui/app/serializers/job-version.js @@ -13,6 +13,13 @@ export default class JobVersionSerializer extends ApplicationSerializer { number: 'Version', }; + normalize(typeHash, hash) { + if (hash.TaggedVersion) { + hash.TaggedVersion.VersionNumber = hash.Version; + } + return super.normalize(typeHash, hash); + } + normalizeFindHasManyResponse(store, modelClass, hash, id, requestType) { const zippedVersions = hash.Versions.map((version, index) => assign({}, version, { diff --git a/ui/app/serializers/version-tag.js b/ui/app/serializers/version-tag.js new file mode 100644 index 00000000000..d6b4ab3e9c6 --- /dev/null +++ b/ui/app/serializers/version-tag.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +// @ts-check +import ApplicationSerializer from './application'; +import { inject as service } from '@ember/service'; + +export default class VersionTagSerializer extends ApplicationSerializer { + @service store; + + serialize(snapshot, options) { + const hash = super.serialize(snapshot, options); + hash.Version = hash.VersionNumber; + return hash; + } +} diff --git a/ui/app/styles/components/timeline.scss b/ui/app/styles/components/timeline.scss index bb60059d306..89908d66693 100644 --- a/ui/app/styles/components/timeline.scss +++ b/ui/app/styles/components/timeline.scss @@ -61,5 +61,80 @@ > .boxed-section { margin-bottom: 0; } + + .job-version { + margin-bottom: 0; + & > .boxed-section { + box-shadow: var(--token-surface-high-box-shadow); + border-radius: 0.25rem; + header, + footer { + border: none; + padding: 0.75rem; + } + + footer { + background-color: var(--token-color-surface-faint); + border-top: 1px solid var(--token-color-border-faint); + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: 0.5rem; + & > .tag-options { + justify-self: start; + display: grid; + grid-template-areas: 'name description save cancel delete'; + grid-template-columns: auto 1fr auto auto auto; + gap: 0.5rem; + align-items: center; + + // Match the height of HDS:Button's @size="small" + input { + padding: 0.375rem 0.6875rem; + line-height: 100%; + } + + .tag-button-primary { + grid-area: name; + background-color: var(--token-color-surface-highlight); + color: var(--token-color-foreground-highlight-on-surface); + border-color: var(--token-color-foreground-highlight); + &:focus:before { + border-color: var(--token-color-foreground-highlight); + } + &:hover { + background-color: var(--token-color-border-highlight); + } + } + .tag-button-secondary { + grid-area: name; + } + .tag-description { + grid-area: description; + font-style: italic; + font-size: 0.875rem; + white-space: no-wrap; + overflow: hidden; + text-overflow: ellipsis; + text-wrap: nowrap; + max-width: 100%; + } + & > .tag-name { + grid-area: name; + } + } + & > .version-options { + justify-self: end; + } + + &.editing { + grid-template-columns: 1fr; + & > .tag-options { + width: 100%; + } + } + } + } + } } } diff --git a/ui/app/templates/components/job-version.hbs b/ui/app/templates/components/job-version.hbs index 19cceffe0ca..275b9794c0f 100644 --- a/ui/app/templates/components/job-version.hbs +++ b/ui/app/templates/components/job-version.hbs @@ -3,55 +3,112 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
- Version #{{this.version.number}} - - Stable - {{this.version.stable}} - - - Submitted - {{format-ts this.version.submitTime}} - -
- {{#unless this.isCurrent}} - {{#if (can "run job" namespace=this.version.job.namespace)}} - +
+
+
+ Version #{{this.version.number}} + + Stable + {{this.version.stable}} + + + Submitted + {{format-ts this.version.submitTime}} + +
+ {{#if this.version.diff}} + + {{else}} +
No Changes
+ {{/if}} +
+
+ {{#if this.isOpen}} +
+ +
+ {{/if}} +
+ {{#if this.isEditing}} +
+ {{! template-lint-disable no-down-event-binding }} + + + {{! template-lint-enable no-down-event-binding }} + + + {{#if this.version.versionTag}} + + {{/if}} + + {{else}} - +
+ {{#if this.version.versionTag}} + + {{else}} + + {{/if}} + + {{this.version.versionTag.description}} + +
+
+ {{#unless this.isCurrent}} + {{#if (can "run job" namespace=this.version.job.namespace)}} + + {{else}} + + {{/if}} + {{/unless}} +
{{/if}} - {{/unless}} - - {{#if this.version.diff}} - - {{else}} -
No Changes
- {{/if}} -
-
-{{#if this.isOpen}} -
- +
-{{/if}} + diff --git a/ui/app/templates/components/job-versions-stream.hbs b/ui/app/templates/components/job-versions-stream.hbs index c13517b66bf..32d8cf60912 100644 --- a/ui/app/templates/components/job-versions-stream.hbs +++ b/ui/app/templates/components/job-versions-stream.hbs @@ -10,6 +10,6 @@ {{/if}}
  • - +
  • {{/each}} diff --git a/ui/app/templates/components/two-step-button.hbs b/ui/app/templates/components/two-step-button.hbs index 4931e3983b7..a25a2935376 100644 --- a/ui/app/templates/components/two-step-button.hbs +++ b/ui/app/templates/components/two-step-button.hbs @@ -6,7 +6,7 @@ {{#if this.isIdle}}
    + + + + + + + + previous version + + {{#each this.optionsDiff key="label" as |option|}} + + {{option.label}} + + {{else}} + + No versions + + {{/each}} + + + + + {{#if this.error}}
    @@ -20,5 +58,5 @@
    {{/if}} - +
    diff --git a/ui/mirage/config.js b/ui/mirage/config.js index dcb3155e880..2067aa081a6 100644 --- a/ui/mirage/config.js +++ b/ui/mirage/config.js @@ -457,6 +457,31 @@ export default function () { return this.serialize(jobVersions.where({ jobId: params.id })); }); + this.post( + '/job/:id/versions/:version/tag', + function ({ jobVersions }, { params }) { + // Create a new version tag + const tag = server.create('version-tag', { + jobVersion: jobVersions.findBy({ + jobId: params.id, + version: params.version, + }), + name: params.name, + description: params.description, + }); + return this.serialize(tag); + } + ); + + this.delete( + '/job/:id/versions/:version/tag', + function ({ jobVersions }, { params }) { + return this.serialize( + jobVersions.findBy({ jobId: params.id, version: params.version }) + ); + } + ); + this.get('/job/:id/deployments', function ({ deployments }, { params }) { return this.serialize(deployments.where({ jobId: params.id })); }); diff --git a/ui/mirage/factories/job-version.js b/ui/mirage/factories/job-version.js index caadbf74508..4dfa99e327b 100644 --- a/ui/mirage/factories/job-version.js +++ b/ui/mirage/factories/job-version.js @@ -31,6 +31,9 @@ export default Factory.extend({ // Directive to restrict any related deployments from having a status other than 'running' activeDeployment: false, + // version tags + versionTag: null, + afterCreate(version, server) { const args = [ 'deployment', diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js index bcd33646a95..6a069657f18 100644 --- a/ui/mirage/scenarios/default.js +++ b/ui/mirage/scenarios/default.js @@ -341,6 +341,42 @@ function smallCluster(server) { //#endregion Active Deployment + // #region Version Tags + const versionTaggedJob = server.create('job', { + name: 'version-tag-job', + id: 'version-tag-job', + namespaceId: 'default', + noDeployments: true, + }); + + server.create('job-version', { + job: versionTaggedJob, + namespace: 'default', + version: 0, + }); + + server.create('job-version', { + job: versionTaggedJob, + namespace: 'default', + version: 1, + versionTag: { + Name: 'burrito', + Description: 'A delicious version', + }, + }); + + server.create('job-version', { + job: versionTaggedJob, + namespace: 'default', + version: 2, + versionTag: { + Name: 'enchilada', + Description: 'A version with just a hint of spice', + }, + }); + + // #endregion Version Tags + server.create('job', { name: 'hcl-definition-job', id: 'display-hcl', diff --git a/ui/tests/acceptance/job-versions-test.js b/ui/tests/acceptance/job-versions-test.js index 06fc98ab94d..b16ac6eda88 100644 --- a/ui/tests/acceptance/job-versions-test.js +++ b/ui/tests/acceptance/job-versions-test.js @@ -5,7 +5,7 @@ /* eslint-disable qunit/require-expect */ /* eslint-disable qunit/no-conditional-assertions */ -import { currentURL } from '@ember/test-helpers'; +import { currentURL, click, typeIn } from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { setupMirage } from 'ember-cli-mirage/test-support'; @@ -13,7 +13,7 @@ import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit'; import Versions from 'nomad-ui/tests/pages/jobs/job/versions'; import Layout from 'nomad-ui/tests/pages/layout'; import moment from 'moment'; - +import percySnapshot from '@percy/ember'; let job; let namespace; let versions; @@ -30,6 +30,21 @@ module('Acceptance | job versions', function (hooks) { job = server.create('job', { namespaceId: namespace.id, createAllocations: false, + noDeployments: true, + }); + + // Create some versions + server.create('job-version', { + job: job, + version: 0, + }); + server.create('job-version', { + job: job, + version: 1, + versionTag: { + Name: 'test-tag', + Description: 'A tag with a brief description', + }, }); versions = server.db.jobVersions.where({ jobId: job.id }); @@ -168,7 +183,142 @@ module('Acceptance | job versions', function (hooks) { 'The URL persists' ); assert.ok(Versions.error.isPresent, 'Error message is shown'); - assert.equal(Versions.error.title, 'Not Found', 'Error message is for 404'); + }); + + test('version tags are displayed', async function (assert) { + // Both a tagged version and an untagged version are present + assert.dom('[data-test-tagged-version="true"]').exists(); + assert.dom('[data-test-tagged-version="false"]').exists(); + + // The tagged version has a button indicating a tag name and description + assert + .dom('[data-test-tagged-version="true"] .tag-button-primary') + .hasText('test-tag'); + assert + .dom('[data-test-tagged-version="true"] .tag-description') + .hasText('A tag with a brief description'); + + // The untagged version has no tag button or description + assert + .dom('[data-test-tagged-version="false"] .tag-button-primary') + .doesNotExist(); + assert + .dom('[data-test-tagged-version="false"] .tag-description') + .hasText('', 'Tag description is empty'); + + await percySnapshot(assert, { + percyCSS: ` + .timeline-note { + display: none; + } + .submit-date { + visibility: hidden; + } + `, + }); + }); + + test('existing version tags can be edited', async function (assert) { + // Clicking the tag button puts it into edit mode + assert + .dom('[data-test-tagged-version="true"] .boxed-section-foot') + .doesNotHaveClass('editing'); + await click('[data-test-tagged-version="true"] .tag-button-primary'); + assert + .dom('[data-test-tagged-version="true"] .boxed-section-foot') + .hasClass('editing'); + + // equivalent of backspacing existing + document.querySelector('[data-test-tag-name-input]').value = ''; + document.querySelector('[data-test-tag-description-input]').value = ''; + + await typeIn( + '[data-test-tagged-version="true"] [data-test-tag-name-input]', + 'new-tag' + ); + await typeIn( + '[data-test-tagged-version="true"] [data-test-tag-description-input]', + 'new-description' + ); + + // Clicking the save button commits the changes + await click( + '[data-test-tagged-version="true"] [data-test-tag-save-button]' + ); + assert + .dom('[data-test-tagged-version="true"] .tag-button-primary') + .hasText('new-tag'); + assert + .dom('[data-test-tagged-version="true"] .tag-description') + .hasText('new-description'); + + assert + .dom('.flash-message.alert.alert-success') + .exists('Shows a success toast notification on edit.'); + + // Tag can subsequently be deleted + await click('[data-test-tagged-version="true"] .tag-button-primary'); + await click( + '[data-test-tagged-version="true"] [data-test-tag-delete-button]' + ); + assert.dom('[data-test-tagged-version="true"]').doesNotExist(); + }); + + test('new version tags can be created', async function (assert) { + // Clicking the tag button puts it into edit mode + assert + .dom('[data-test-tagged-version="false"] .boxed-section-foot') + .doesNotHaveClass('editing'); + await click('[data-test-tagged-version="false"] .tag-button-secondary'); + assert + .dom('[data-test-tagged-version="false"] .boxed-section-foot') + .hasClass('editing'); + + assert + .dom('[data-test-tagged-version="false"] [data-test-tag-delete-button]') + .doesNotExist(); + + // Clicking the save button commits the changes + await click( + '[data-test-tagged-version="false"] [data-test-tag-save-button]' + ); + + assert + .dom('.flash-message.alert.alert-critical') + .exists('Shows an error toast notification without a tag name.'); + + await typeIn( + '[data-test-tagged-version="false"] [data-test-tag-name-input]', + 'new-tag' + ); + await typeIn( + '[data-test-tagged-version="false"] [data-test-tag-description-input]', + 'new-description' + ); + + // Clicking the save button commits the changes + await click( + '[data-test-tagged-version="false"] [data-test-tag-save-button]' + ); + + assert + .dom('[data-test-tagged-version="false"]') + .doesNotExist('Both versions now have tags'); + + assert + .dom('.flash-message.alert.alert-success') + .exists('Shows a success toast notification on edit.'); + + await percySnapshot(assert, { + percyCSS: ` + .timeline-note { + display: none; + } + .submit-date { + visibility: hidden; + } + `, + }); }); }); diff --git a/website/content/docs/commands/job/history.mdx b/website/content/docs/commands/job/history.mdx index 9544f64140c..3adf4e57a09 100644 --- a/website/content/docs/commands/job/history.mdx +++ b/website/content/docs/commands/job/history.mdx @@ -36,6 +36,9 @@ run the command with a job prefix instead of the exact job ID. - `-version`: Display only the history for the given version. - `-json` : Output the job versions in its JSON format. - `-t` : Format and display the job versions using a Go template. +- `-diff-version`: Compare the job with a specific version. +- `-diff-tag`: Compare the job with a specific tag. + ## Examples @@ -81,3 +84,60 @@ v2: 512 v1: 256 v0: 256 ``` + +Compare the current job with a specific older version: + +```shell-session +$ nomad job history -version=3 -diff-version=1 example +Version = 3 +Stable = false +Submit Date = 07/25/17 20:35:43 UTC +Diff = ++/- Job: "example" ++/- Task Group: "cache" + +/- Task: "redis" + +/- Resources { + CPU: "500" + DiskMB: "0" + +/- MemoryMB: "256" => "512" + } +``` + +Compare all job versions with a specific version by tag name: + +```shell-session +$ nomad job history -diff-tag=low-energy-version example + +Version = 3 +Stable = false +Submit Date = 2024-09-09T16:41:53-04:00 +Diff = ++/- Job: "example" ++/- Task Group: "group" + +/- Count: "3" => "4" + Task: "task" + +Version = 2 +Stable = false +Submit Date = 2024-09-09T16:41:53-04:00 +Tag Name = low-energy-version +Tag Description = test description + +Version = 1 +Stable = false +Submit Date = 2024-09-09T16:41:53-04:00 +Diff = ++/- Job: "example" ++/- Task Group: "group" + +/- Count: "3" => "2" + Task: "task" + +Version = 0 +Stable = false +Submit Date = 2024-09-09T16:41:53-04:00 +Diff = ++/- Job: "example" ++/- Task Group: "group" + +/- Count: "3" => "1" + Task: "task" +``` diff --git a/website/content/docs/operations/metrics-reference.mdx b/website/content/docs/operations/metrics-reference.mdx index c71839e981d..4ab64495a73 100644 --- a/website/content/docs/operations/metrics-reference.mdx +++ b/website/content/docs/operations/metrics-reference.mdx @@ -356,6 +356,7 @@ those listed in [Key Metrics](#key-metrics) above. | `nomad.nomad.fsm.apply_deployment_promotion` | Time elapsed to apply `ApplyDeploymentPromotion` raft entry | Milliseconds | Timer | host | | `nomad.nomad.fsm.apply_deployment_status_update` | Time elapsed to apply `ApplyDeploymentStatusUpdate` raft entry | Milliseconds | Timer | host | | `nomad.nomad.fsm.apply_job_stability` | Time elapsed to apply `ApplyJobStability` raft entry | Milliseconds | Timer | host | +| `nomad.nomad.fsm.apply_job_version_tag` | Time elapsed to apply `ApplyJobVersionTag` raft entry | Milliseconds | Timer | host | | `nomad.nomad.fsm.apply_namespace_delete` | Time elapsed to apply `ApplyNamespaceDelete` raft entry | Milliseconds | Timer | host | | `nomad.nomad.fsm.apply_namespace_upsert` | Time elapsed to apply `ApplyNamespaceUpsert` raft entry | Milliseconds | Timer | host | | `nomad.nomad.fsm.apply_node_pool_upsert` | Time elapsed to apply `ApplyNodePoolUpsert` raft entry | Milliseconds | Timer | host |