From fe240c60f50fd52fdb230f4d07e3fd155fb3c02c Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Fri, 16 Oct 2026 12:52:36 +0000 Subject: [PATCH 1/2] feat(ui): watch the deploy of a job until it ends with w --- README.md | 2 + docs/jobs.md | 23 ++ docs/keys.md | 8 + internal/nomad/deployments.go | 27 +- internal/nomad/deployments_test.go | 39 +++ internal/nomad/evaluation.go | 41 ++- internal/nomad/evaluation_test.go | 34 ++ internal/nomad/version.go | 6 + internal/nomad/version_test.go | 3 +- internal/ui/app_test.go | 19 ++ internal/ui/deployment.go | 7 +- internal/ui/evaluation.go | 8 +- internal/ui/hints_test.go | 13 + internal/ui/joblogs.go | 10 +- internal/ui/jobs.go | 6 +- internal/ui/jobs_test.go | 1 + internal/ui/roles.go | 2 + internal/ui/watch_deploy.go | 256 +++++++++++++++ internal/ui/watch_deploy_test.go | 497 +++++++++++++++++++++++++++++ internal/ui/writes_test.go | 4 +- 20 files changed, 990 insertions(+), 16 deletions(-) create mode 100644 internal/ui/watch_deploy.go create mode 100644 internal/ui/watch_deploy_test.go diff --git a/README.md b/README.md index 54de39c..25e7eb5 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ One binary, no config to write, no browser. - Lists jobs, allocations, tasks, task groups, deployments, evaluations, services, namespaces, variables, clients, node pools, servers, volumes, CSI plugins and the ACL objects. +- Watches the deploy of a job with `w`: the newest evaluation, the deployment + and its allocations, until it ends. - Updates the screen when the cluster changes, using the Nomad event stream, and shows the stream itself on `:events`. - Counts on one screen what needs attention: dead jobs, failed allocations, diff --git a/docs/jobs.md b/docs/jobs.md index d248a51..9afc3a5 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -232,3 +232,26 @@ nothing left to promote, pause or fail. System jobs have deployments since Nomad 1.11. They have no canaries, so their Canaries and Promoted columns show `-`. + +## Watching a deploy + +`w` on a job opens `Deploy (Job: web, Version: 7)`: the screen of the +deployment of the version the job runs, which updates as the deploy goes. At +the top, urga shows the newest evaluation of the job and, when it blocked or +failed to place something, why, in the words of the evaluation screen. Under +it, one line says how the deploy stands: + +- `Waiting for the deployment of version 7.` until the job has a deployment + of that version. The table lists the allocations of the job at that + version, with the keys of an allocation. +- The status and description of the deployment while it runs, then + `Deployment successful.`, `Deployment failed: ...` or + `Deployment cancelled: ...` when it ends. +- `web has no deployment for version 7: its allocations are below.` for a + batch or sysbatch job, and for a version that made none: the newest + evaluation of the job saw the version, completed, and no deployment came of + it (a stopped job, an update with `max_parallel = 0`). + +Below are the groups of the deployment and its allocations, with the keys of +the deployment screen. `esc` leaves the screen; the deploy goes on. `w` is not +shown on a periodic or parameterized job, and it works in read-only mode. diff --git a/docs/keys.md b/docs/keys.md index 06cffe2..f358b79 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -214,6 +214,7 @@ start, stop, restart or drain then applies to every marked row. | `h` | Job file the job was submitted with | | | `v` | Versions | | | `l` | Logs of a task in every allocation of the job. Not shown for a periodic or parameterized job. | | +| `w` | Watch the deploy of the job at the version it runs. Not shown for a periodic or parameterized job. | | | `e` | Edit the job in your editor | yes | | `ctrl-s` | Start a stopped job, or stop a running one | yes | | `ctrl-p` | Purge a stopped job: it leaves the cluster with its versions. Shown on a stopped job. | yes | @@ -393,6 +394,13 @@ keys of [Allocations](#allocations), and these: These keys are shown only while the deployment is active. `p` is shown only when the group under the cursor has canaries waiting to be promoted. +### A deploy + +The deploy screen, opened with `w` on a job, has the keys of +[A deployment](#a-deployment) once the job has a deployment of the version it +watches. Before that it has the keys of [Allocations](#allocations). `esc` +leaves it; the deploy goes on. + ### Namespaces and services | Key | What it does | Changes | diff --git a/internal/nomad/deployments.go b/internal/nomad/deployments.go index a4448bd..e0ceb6e 100644 --- a/internal/nomad/deployments.go +++ b/internal/nomad/deployments.go @@ -4,6 +4,8 @@ import ( "context" "sort" "time" + + "github.com/hashicorp/nomad/api" ) // Active says the deployment is not over: it can still be promoted, paused @@ -56,6 +58,29 @@ func (c *Client) Deployment(ctx context.Context, namespace, deploymentID string) return DeploymentDetail{}, err } + return newDeploymentDetail(d), nil +} + +// JobDeployments are the deployments of a job, the newest first: one for +// each version that made one. A batch job has none. +func (c *Client) JobDeployments(ctx context.Context, namespace, jobID string) ([]DeploymentDetail, error) { + list, _, err := c.api.Jobs().Deployments(jobID, false, c.query(ctx, namespace)) + if err != nil { + return nil, err + } + + out := make([]DeploymentDetail, 0, len(list)) + + for _, d := range list { + if d != nil { + out = append(out, newDeploymentDetail(d)) + } + } + + return out, nil +} + +func newDeploymentDetail(d *api.Deployment) DeploymentDetail { detail := DeploymentDetail{Deployment: Deployment{ ID: d.ID, JobID: d.JobID, @@ -87,7 +112,7 @@ func (c *Client) Deployment(ctx context.Context, namespace, deploymentID string) sort.Slice(detail.Groups, func(i, j int) bool { return detail.Groups[i].Name < detail.Groups[j].Name }) - return detail, nil + return detail } // DeploymentAllocations are the allocations a deployment placed, with how diff --git a/internal/nomad/deployments_test.go b/internal/nomad/deployments_test.go index 0d2c7da..d3012da 100644 --- a/internal/nomad/deployments_test.go +++ b/internal/nomad/deployments_test.go @@ -122,3 +122,42 @@ func TestDeployment_Active(t *testing.T) { r.False(nomad.Deployment{Status: status}.Active(), status) } } + +func TestJobDeployments_NewestFirst(t *testing.T) { + r := require.New(t) + + client, asked := recorder(t, `[ + {"ID": "dep-7", "JobID": "web", "JobVersion": 7, "Status": "cancelled", "CreateIndex": 20, + "StatusDescription": "Cancelled due to newer version of job"}, + {"ID": "dep-8", "JobID": "web", "JobVersion": 8, "Status": "running", "CreateIndex": 30, + "TaskGroups": {"web": {"DesiredTotal": 2}, "api": {"DesiredTotal": 1}}}, + {"ID": "dep-6", "JobID": "web", "JobVersion": 6, "Status": "successful", "CreateIndex": 10} + ]`) + + deployments, err := client.JobDeployments(context.Background(), "production", "web") + r.NoError(err) + + r.Equal("/v1/job/web/deployments", asked.URL.Path) + r.Equal("production", asked.URL.Query().Get("namespace")) + r.Equal("false", asked.URL.Query().Get("all")) + + ids := []string{} + for _, d := range deployments { + ids = append(ids, d.ID) + } + + r.Equal([]string{"dep-8", "dep-7", "dep-6"}, ids) + r.Equal(uint64(7), deployments[1].JobVersion) + r.Equal("Cancelled due to newer version of job", deployments[1].StatusDescription) + r.Equal([]string{"api", "web"}, []string{deployments[0].Groups[0].Name, deployments[0].Groups[1].Name}) +} + +func TestJobDeployments_WhenTheJobHasNone(t *testing.T) { + r := require.New(t) + + client, _ := recorder(t, `[]`) + + deployments, err := client.JobDeployments(context.Background(), "production", "batch-job") + r.NoError(err) + r.Empty(deployments) +} diff --git a/internal/nomad/evaluation.go b/internal/nomad/evaluation.go index f78560c..df44d90 100644 --- a/internal/nomad/evaluation.go +++ b/internal/nomad/evaluation.go @@ -16,6 +16,9 @@ type EvaluationDetail struct { StatusDescription string + // CreateIndex is the index of the cluster when the evaluation was made. + CreateIndex uint64 + // BlockedEval waits for room to place what this one could not; // PreviousEval and NextEval are the decisions before and after it. BlockedEval string @@ -68,6 +71,7 @@ func newEvaluationDetail(eval *api.Evaluation) EvaluationDetail { detail := EvaluationDetail{ Evaluation: newEvaluation(eval), StatusDescription: eval.StatusDescription, + CreateIndex: eval.CreateIndex, BlockedEval: eval.BlockedEval, PreviousEval: eval.PreviousEval, NextEval: eval.NextEval, @@ -137,10 +141,39 @@ func (c *Client) FailedPlacement(ctx context.Context, namespace, jobID string) ( return EvaluationDetail{}, err } + newest := newestOf(evals, func(eval *api.Evaluation) bool { return len(eval.FailedTGAllocs) > 0 }) + + if newest == nil { + return EvaluationDetail{}, ErrNoFailures + } + + return newEvaluationDetail(newest), nil +} + +// NewestEvaluation is the newest evaluation of the job, nil when it has none. +func (c *Client) NewestEvaluation(ctx context.Context, namespace, jobID string) (*EvaluationDetail, error) { + evals, _, err := c.api.Jobs().Evaluations(jobID, c.query(ctx, namespace)) + if err != nil { + return nil, err + } + + newest := newestOf(evals, func(*api.Evaluation) bool { return true }) + if newest == nil { + return nil, nil + } + + detail := newEvaluationDetail(newest) + + return &detail, nil +} + +// newestOf is the evaluation with the highest create index among those that +// pass wanted, nil when none does. +func newestOf(evals []*api.Evaluation, wanted func(*api.Evaluation) bool) *api.Evaluation { var newest *api.Evaluation for _, eval := range evals { - if eval == nil || len(eval.FailedTGAllocs) == 0 { + if eval == nil || !wanted(eval) { continue } @@ -149,11 +182,7 @@ func (c *Client) FailedPlacement(ctx context.Context, namespace, jobID string) ( } } - if newest == nil { - return EvaluationDetail{}, ErrNoFailures - } - - return newEvaluationDetail(newest), nil + return newest } // EvaluationAllocations are the allocations an evaluation placed or changed. diff --git a/internal/nomad/evaluation_test.go b/internal/nomad/evaluation_test.go index 2e12340..5e1b848 100644 --- a/internal/nomad/evaluation_test.go +++ b/internal/nomad/evaluation_test.go @@ -142,3 +142,37 @@ func TestEvaluationAllocations(t *testing.T) { r.Equal("web", allocs[0].TaskGroup) r.Equal("failed", allocs[1].Status) } + +func TestNewestEvaluation(t *testing.T) { + r := require.New(t) + + client, asked := recorder(t, `[ + {"ID": "old", "JobID": "web", "Status": "complete", "CreateIndex": 5}, + {"ID": "blocked", "JobID": "web", "Status": "blocked", "CreateIndex": 30, "JobModifyIndex": 25, + "FailedTGAllocs": {"web": {"NodesEvaluated": 3, "NodesExhausted": 3}}}, + {"ID": "middle", "JobID": "web", "Status": "complete", "CreateIndex": 20} + ]`) + + eval, err := client.NewestEvaluation(context.Background(), "production", "web") + r.NoError(err) + + r.Equal("/v1/job/web/evaluations", asked.URL.Path) + r.Equal("production", asked.URL.Query().Get("namespace")) + + r.NotNil(eval) + r.Equal("blocked", eval.ID) + // When it was made says it is of a version or not, even for a submit that + // changed nothing, which gives the evaluation no job index. + r.Equal(uint64(30), eval.CreateIndex) + r.Equal(3, eval.Failures[0].NodesExhausted) +} + +func TestNewestEvaluation_WhenTheJobHasNone(t *testing.T) { + r := require.New(t) + + client, _ := recorder(t, `[]`) + + eval, err := client.NewestEvaluation(context.Background(), "production", "web") + r.NoError(err) + r.Nil(eval) +} diff --git a/internal/nomad/version.go b/internal/nomad/version.go index 07176d1..a49eb15 100644 --- a/internal/nomad/version.go +++ b/internal/nomad/version.go @@ -30,6 +30,10 @@ type JobVersion struct { Submitted time.Time + // JobModifyIndex is the index of the cluster when the version was made: + // an evaluation of the job at or above it has seen this version. + JobModifyIndex uint64 + // Changes is how many fields differ from the version before this one. Changes int } @@ -53,6 +57,8 @@ func (c *Client) JobVersions(ctx context.Context, namespace, jobID string) ([]Jo Version: valueOf(job.Version), Stable: valueOf(job.Stable), Current: i == 0, + + JobModifyIndex: valueOf(job.JobModifyIndex), } if job.VersionTag != nil { diff --git a/internal/nomad/version_test.go b/internal/nomad/version_test.go index 3635ab1..2278f9f 100644 --- a/internal/nomad/version_test.go +++ b/internal/nomad/version_test.go @@ -13,7 +13,7 @@ const jobVersions = `{ "Versions": [ { "ID": "web", "Name": "web", "Namespace": "production", - "Version": 3, "Stable": true, "SubmitTime": 1758499200000000000, + "Version": 3, "Stable": true, "SubmitTime": 1758499200000000000, "JobModifyIndex": 40, "VersionTag": {"Name": "golden", "Description": "the one that works"} }, {"ID": "web", "Name": "web", "Version": 2, "Stable": false, "SubmitTime": 1758412800000000000}, @@ -54,6 +54,7 @@ func TestJobVersions_Read(t *testing.T) { r.True(versions[0].Current) r.True(versions[0].Stable) r.Equal("golden", versions[0].Tag) + r.Equal(uint64(40), versions[0].JobModifyIndex) r.False(versions[0].Submitted.IsZero()) r.False(versions[1].Current) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 1308935..85b2350 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -173,6 +173,12 @@ type fakeClient struct { deploymentNamespace string deploymentID string + // jobDeployments are the deployments of a job, the newest first. + jobDeployments []nomad.DeploymentDetail + + // newest is the newest evaluation of a job; nil is none. + newest *nomad.EvaluationDetail + // promotedGroups are the groups promoted on their own, paused whether // each call asked to pause or to resume. promotedGroups []string @@ -406,6 +412,7 @@ func (f *fakeClient) NodeAllocations(_ context.Context, nodeID string) ([]nomad. func (f *fakeClient) JobVersions(_ context.Context, namespace, jobID string) ([]nomad.JobVersion, error) { f.askedNamespace, f.askedJobID = namespace, jobID + f.listed("versions", namespace) return f.versions, f.err } @@ -994,6 +1001,18 @@ func (f *fakeClient) DeploymentAllocations(_ context.Context, namespace, id stri return f.deploymentAllocs, f.err } +func (f *fakeClient) JobDeployments(_ context.Context, namespace, jobID string) ([]nomad.DeploymentDetail, error) { + f.listed("job deployments", namespace+"/"+jobID) + + return f.jobDeployments, f.err +} + +func (f *fakeClient) NewestEvaluation(_ context.Context, namespace, jobID string) (*nomad.EvaluationDetail, error) { + f.listed("newest evaluation", namespace+"/"+jobID) + + return f.newest, f.err +} + func (f *fakeClient) Deployments(_ context.Context, namespace string) ([]nomad.Deployment, error) { f.askedNamespace = namespace f.listed("deployments", namespace) diff --git a/internal/ui/deployment.go b/internal/ui/deployment.go index 29be50b..133b968 100644 --- a/internal/ui/deployment.go +++ b/internal/ui/deployment.go @@ -249,6 +249,11 @@ type deploymentPage struct { read bool allocs []nomad.Alloc + + // version narrows the logs to the allocations of that version, when + // versioned: the watch of a deploy has rows of one version only. + version uint64 + versioned bool } // deploymentOf is the page of a deployment in its namespace, which is where @@ -329,7 +334,7 @@ func (deploymentPage) reading(ctx context.Context, client Client, ref rowRef) (n // logsOf: the logs of the allocations the deployment placed for its job. func (p deploymentPage) logsOf(env) logScope { - return logScope{namespace: p.namespace, jobID: p.jobID, deploymentID: p.deploymentID} + return logScope{namespace: p.namespace, jobID: p.jobID, deploymentID: p.deploymentID, version: p.version, versioned: p.versioned} } // inView is the deployment the page read. diff --git a/internal/ui/evaluation.go b/internal/ui/evaluation.go index 370e5b7..929b8a2 100644 --- a/internal/ui/evaluation.go +++ b/internal/ui/evaluation.go @@ -286,7 +286,7 @@ func placementLines(failures []nomad.PlacementFailure) []string { lines = append(lines, "") } - lines = append(lines, fmt.Sprintf("Task group %q failed to place %s:", failure.Group, plural(failure.Unplaced, "allocation"))) + lines = append(lines, failureHeading(failure)) for _, reason := range placementReasons(failure) { lines = append(lines, " * "+reason) @@ -296,6 +296,12 @@ func placementLines(failures []nomad.PlacementFailure) []string { return lines } +// failureHeading says which group was not placed, and how many of its +// allocations. +func failureHeading(failure nomad.PlacementFailure) string { + return fmt.Sprintf("Task group %q failed to place %s:", failure.Group, plural(failure.Unplaced, "allocation")) +} + // placementReasons are the reasons one group was not placed: first the // nodes that were excluded, then the ones that ran out of room. Map keys // are read in sorted order, so the lines keep their places from one diff --git a/internal/ui/hints_test.go b/internal/ui/hints_test.go index 842b537..9e28d39 100644 --- a/internal/ui/hints_test.go +++ b/internal/ui/hints_test.go @@ -39,6 +39,15 @@ func everyScreen(t *testing.T) map[string]Model { return open } +// runningAtVersionThree is the deployment of the list as the newest of web, +// at the version it runs. +func runningAtVersionThree() []nomad.DeploymentDetail { + return []nomad.DeploymentDetail{{ + Deployment: nomad.Deployment{ID: "dep-1", JobID: "web", Namespace: "production", JobVersion: 3, Status: "running"}, + Groups: []nomad.DeploymentGroup{{Name: "frontend", DesiredTotal: 2, Placed: 1, DesiredCanaries: 1, PlacedCanaries: 1}}, + }} +} + // everyScreenClient is a cluster with something on every screen. func everyScreenClient() *fakeClient { return &fakeClient{ @@ -71,6 +80,7 @@ func everyScreenClient() *fakeClient { deploymentAllocs: []nomad.Alloc{ {ID: "canary-1", Namespace: "production", JobID: "web", TaskGroup: "frontend", Status: "running", Canary: true}, }, + jobDeployments: runningAtVersionThree(), // The directory of the task server, with a file to open. files: map[string][]nomad.File{ @@ -170,6 +180,9 @@ func openOfAJob(open map[string]Model) { tasks, _ := allocs.update(enter()) open["tasks"] = tasks + watch, cmd := jobs.update(key('w')) + open["deployWatch"] = drain(watch, cmd) + groups, _ := jobs.update(key('t')) groups, _ = groups.update(taskGroupsMsg(twoGroups())) open["taskgroups"] = groups diff --git a/internal/ui/joblogs.go b/internal/ui/joblogs.go index 550460c..080998e 100644 --- a/internal/ui/joblogs.go +++ b/internal/ui/joblogs.go @@ -54,6 +54,11 @@ type logScope struct { // names it. volumeKind, volumeID string + // version narrows the allocations to those of that version, when + // versioned. + version uint64 + versioned bool + // fault narrows the allocations to those in that state, for the logs of // a list the overview opened. fault fault @@ -191,11 +196,12 @@ func (c logChoice) path() string { return c.namespace + "/" + c.job + "/" + c.group + "/" + c.task } -// inScope says the allocation is in the job, the group and the state of the -// scope. +// inScope says the allocation is in the job, the group, the version and the +// state of the scope. func inScope(scope logScope, alloc nomad.Alloc) bool { return (scope.jobID == "" || alloc.JobID == scope.jobID) && (scope.group == "" || alloc.TaskGroup == scope.group) && + (!scope.versioned || alloc.JobVersion == scope.version) && allocHasFault(alloc, scope.fault, scope.read) } diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 389de5d..bd91715 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -183,6 +183,7 @@ var jobsKeys = []pageKey[jobsPage]{ {press: "u", label: "Revert", do: revertJob, writes: true}, {press: "v", label: "Versions", do: openVersions}, {press: "l", label: "Logs", do: jobLogs, offered: runsItself}, + {press: "w", label: "Watch", do: watchJob, offered: runsItself}, {press: "e", label: "Edit", do: editJob, writes: true}, {press: "p", label: "Placement", do: jobPlacement, offered: jobWaits}, } @@ -266,8 +267,9 @@ const ( statusFailed = "failed" statusPaused = "paused" - typeService = "service" - typeBatch = "batch" + typeService = "service" + typeBatch = "batch" + typeSysbatch = "sysbatch" ) // openJobAllocations drills into the job under the cursor: the allocations diff --git a/internal/ui/jobs_test.go b/internal/ui/jobs_test.go index 61ee408..f7c6e47 100644 --- a/internal/ui/jobs_test.go +++ b/internal/ui/jobs_test.go @@ -89,6 +89,7 @@ func TestJobs_TheKeysOfAJob(t *testing.T) { {Key: "", Description: "Revert"}, {Key: "", Description: "Versions"}, {Key: "", Description: "Logs"}, + {Key: "", Description: "Watch"}, {Key: "", Description: "Edit"}, } r.Equal(keys, m.hints()) diff --git a/internal/ui/roles.go b/internal/ui/roles.go index a2bef3b..67ddbe4 100644 --- a/internal/ui/roles.go +++ b/internal/ui/roles.go @@ -108,6 +108,7 @@ type ( deploymentsClient interface { Deployments(ctx context.Context, namespace string) ([]nomad.Deployment, error) Deployment(ctx context.Context, namespace, deploymentID string) (nomad.DeploymentDetail, error) + JobDeployments(ctx context.Context, namespace, jobID string) ([]nomad.DeploymentDetail, error) DescribeDeployment(ctx context.Context, namespace, deploymentID string) (string, error) PromoteDeployment(ctx context.Context, namespace, deploymentID string) error PromoteGroups(ctx context.Context, namespace, deploymentID string, groups []string) error @@ -155,6 +156,7 @@ type ( Evaluations(ctx context.Context, namespace string) ([]nomad.Evaluation, error) Evaluation(ctx context.Context, namespace, evalID string) (nomad.EvaluationDetail, error) FailedPlacement(ctx context.Context, namespace, jobID string) (nomad.EvaluationDetail, error) + NewestEvaluation(ctx context.Context, namespace, jobID string) (*nomad.EvaluationDetail, error) } variablesClient interface { diff --git a/internal/ui/watch_deploy.go b/internal/ui/watch_deploy.go new file mode 100644 index 0000000..625719c --- /dev/null +++ b/internal/ui/watch_deploy.go @@ -0,0 +1,256 @@ +package ui + +import ( + "context" + "fmt" + "slices" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/ingvarch/urga/internal/nomad" +) + +// deployWatchMsg is what one read of a watched deploy found. +type deployWatchMsg struct { + namespace, jobID string + version uint64 + + // versionIndex is the JobModifyIndex of the version. + versionIndex uint64 + + // eval is the newest evaluation of the job, deployment the deployment + // of the version, both nil when there is none. + eval *nomad.EvaluationDetail + deployment *nomad.DeploymentDetail + + // allocs are those of the deployment, or the job's at the version. + allocs []nomad.Alloc +} + +// watchClient is what the watch of a deploy reads. +type watchClient interface { + jobsClient + allocsClient + deploymentsClient + evaluationsClient +} + +// deployWatchPage is the deploy of one version of a job as it goes: the +// newest evaluation and how the deployment stands, above the allocations of +// the deployment. Before the deployment exists the rows are the job's +// allocations at that version, and the keys those of an allocation. +type deployWatchPage struct { + namespace, jobID, jobType string + + // version is the version watched. A page opened without one reads the + // version the job runs on its first fetch; versioned says it has. + version uint64 + versionIndex uint64 + versioned bool + + // eval is the newest evaluation of the job. read says the page has read + // the job once: until then there is no panel. + eval *nomad.EvaluationDetail + read bool + + // deployment is the deployment screen the watch takes the place of: its + // panel, rows and keys are the watch's once the deployment exists. + deployment deploymentPage +} + +func (p deployWatchPage) title(_ env, count int) string { + if !p.versioned { + return sprintf("Deploy (Job: %s) [%d]", p.jobID, count) + } + + return sprintf("Deploy (Job: %s, Version: %d) [%d]", p.jobID, p.version, count) +} + +func (p deployWatchPage) titles() []string { return p.deployment.titles() } + +func (p deployWatchPage) where(env) string { return p.namespace } + +func (deployWatchPage) topics() []string { + return []string{nomad.TopicJob, nomad.TopicEvaluation, nomad.TopicDeployment, nomad.TopicAllocation} +} + +func (p deployWatchPage) fetch(e env) tea.Cmd { + client := e.client + + return request(func(ctx context.Context) (deployWatchMsg, error) { + return readDeploy(ctx, client, p) + }, func(msg deployWatchMsg) tea.Msg { return msg }) +} + +// readDeploy reads the job in its namespace: the version, when the page has +// none yet, the newest evaluation, the deployment of that version and the +// allocations. +func readDeploy(ctx context.Context, client watchClient, p deployWatchPage) (deployWatchMsg, error) { + read := deployWatchMsg{namespace: p.namespace, jobID: p.jobID, version: p.version, versionIndex: p.versionIndex} + + if !p.versioned { + versions, err := client.JobVersions(ctx, p.namespace, p.jobID) + if err != nil { + return read, err + } + + if len(versions) == 0 { + return read, fmt.Errorf("%s has no versions", p.jobID) + } + + read.version, read.versionIndex = versions[0].Version, versions[0].JobModifyIndex + } + + // The evaluation goes first: the scheduler writes the deployment before + // it completes the evaluation, so an evaluation seen complete means the + // deployment read after it is there. + var err error + if read.eval, err = client.NewestEvaluation(ctx, p.namespace, p.jobID); err != nil { + return read, err + } + + deployments, err := client.JobDeployments(ctx, p.namespace, p.jobID) + if err != nil { + return read, err + } + + // The newest deployment of the job may be of a newer version. + if i := slices.IndexFunc(deployments, func(d nomad.DeploymentDetail) bool { return d.JobVersion == read.version }); i >= 0 { + read.deployment = &deployments[i] + } + + read.allocs, err = deployAllocs(ctx, client, p.namespace, p.jobID, read) + + return read, err +} + +// deployAllocs are the allocations the deployment placed, or without one +// those of the job at the version. +func deployAllocs(ctx context.Context, client watchClient, namespace, jobID string, read deployWatchMsg) ([]nomad.Alloc, error) { + if read.deployment != nil { + return client.DeploymentAllocations(ctx, namespace, read.deployment.ID) + } + + allocs, err := client.Allocations(ctx, namespace, jobID) + + return keep(allocs, func(a nomad.Alloc) bool { return a.JobVersion == read.version }), err +} + +func (p deployWatchPage) take(msg tea.Msg, _ env) (page, outcome, bool) { + read, ok := msg.(deployWatchMsg) + if !ok || read.namespace != p.namespace || read.jobID != p.jobID { + return p, outcome{}, false + } + + // A read asked before the version was known may bring a newer one. + if p.versioned && read.version != p.version { + return p, outcome{}, true + } + + p.version, p.versionIndex, p.versioned, p.eval, p.read = read.version, read.versionIndex, true, read.eval, true + p.deployment = deploymentPage{namespace: p.namespace, jobID: p.jobID, allocs: read.allocs, version: p.version, versioned: true} + + if read.deployment != nil { + p.deployment.deployment, p.deployment.read = *read.deployment, true + p.deployment.deploymentID = read.deployment.ID + } + + return p, outcome{}, true +} + +// panel is what the watch adds, then the groups of the deployment. +func (p deployWatchPage) panel(_ env, width, room int) []string { + if !p.read { + return nil + } + + head := []string{} + + if p.eval != nil { + head = append(head, " "+watchedEvaluation(*p.eval, width-1)) + } + + head = append(head, " "+truncate(p.standing(), width-1)) + + return fitPanel(head, groupBlock(p.deployment.deployment.Groups, p.deployment.active(), width), room) +} + +// standing is one line on how the deploy stands. +func (p deployWatchPage) standing() string { + d := p.deployment.deployment + + switch { + case !p.deployment.read && p.makesNoDeployment(): + return fmt.Sprintf("%s has no deployment for version %d: its allocations are below.", p.jobID, p.version) + case !p.deployment.read: + return fmt.Sprintf("Waiting for the deployment of version %d.", p.version) + case d.Active(): + return fmt.Sprintf("Deployment %s: %s", d.Status, d.StatusDescription) + case d.Status == "successful": + return "Deployment successful." + } + + return fmt.Sprintf("Deployment %s: %s.", d.Status, d.StatusDescription) +} + +// makesNoDeployment says the version will not get a deployment: its job is +// of a type that has none, no evaluation is left of it (a submit always +// writes one, so they were collected), or an evaluation that saw the version +// completed and made none. +func (p deployWatchPage) makesNoDeployment() bool { + if p.jobType == typeBatch || p.jobType == typeSysbatch { + return true + } + + return p.eval == nil || p.eval.Status == "complete" && p.eval.CreateIndex >= p.versionIndex +} + +// watchedEvaluation is the newest evaluation: its status, and when it did not +// place everything, why, in the words of the evaluation screen. +func watchedEvaluation(eval nomad.EvaluationDetail, width int) string { + line := fmt.Sprintf("Evaluation %s %s", shortID(eval.ID), eval.Status) + + failures := make([]string, 0, len(eval.Failures)) + for _, failure := range eval.Failures { + failures = append(failures, failureHeading(failure)+" "+strings.Join(placementReasons(failure), ", ")) + } + + if len(failures) > 0 { + line += ": " + strings.Join(failures, " ") + } + + return truncate(line, width) +} + +func (p deployWatchPage) rows(e env) []tableRow { return p.deployment.rows(e) } + +func (p deployWatchPage) ids(e env) []string { return p.deployment.ids(e) } + +func (p deployWatchPage) readings(e env) []rowRef { return p.deployment.readings(e) } + +func (p deployWatchPage) reading(ctx context.Context, client Client, ref rowRef) (nomad.ResourceUse, error) { + return p.deployment.reading(ctx, client, ref) +} + +func (p deployWatchPage) keys(e env) []keyHint { return p.deployment.keys(e) } + +func (p deployWatchPage) press(k string, e env) (page, outcome, bool) { + next, out, ok := p.deployment.press(k, e) + if ok { + p.deployment = next.(deploymentPage) + } + + return p, out, ok +} + +// watchJob opens the watch of the deploy of the job under the cursor, at the +// version it runs. +func watchJob(p jobsPage, e env) (jobsPage, outcome) { + job, ok := p.picked(e) + if !ok { + return p, outcome{} + } + + return p, then(openMsg{deployWatchPage{namespace: job.Namespace, jobID: job.ID, jobType: job.Type}}) +} diff --git a/internal/ui/watch_deploy_test.go b/internal/ui/watch_deploy_test.go new file mode 100644 index 0000000..df24914 --- /dev/null +++ b/internal/ui/watch_deploy_test.go @@ -0,0 +1,497 @@ +package ui + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// versionSeven is the version web runs. +func versionSeven() []nomad.JobVersion { + return []nomad.JobVersion{{Version: 7, Current: true, JobModifyIndex: 70}, {Version: 6, JobModifyIndex: 60}} +} + +// completeEval is the newest evaluation of web, which placed everything. +func completeEval() *nomad.EvaluationDetail { + return &nomad.EvaluationDetail{Evaluation: nomad.Evaluation{ + ID: "e1a2b3c4-0000-0000-0000-000000000000", JobID: "web", Namespace: "production", Status: "complete", + }} +} + +// pendingEval is the newest evaluation of web, still at it. +func pendingEval() *nomad.EvaluationDetail { + eval := completeEval() + eval.Status = "pending" + + return eval +} + +// onWatch is the watch of web, opened with w from the list of jobs. +func onWatch(t *testing.T, client *fakeClient, jobs ...nomad.Job) Model { + t.Helper() + + if len(jobs) == 0 { + jobs = []nomad.Job{twoJobs()[0]} + } + + client.jobs, client.versions = jobs, versionSeven() + + m := newTestModel(client) + m, _ = m.update(jobsMsg(jobs)) + + m, cmd := m.update(key('w')) + + return drain(m, cmd) +} + +func TestWatch_AJobWithARunningDeployment(t *testing.T) { + r := require.New(t) + + running := waitingCanary() + client := &fakeClient{jobDeployments: []nomad.DeploymentDetail{running}, newest: completeEval(), deploymentAllocs: canaryAllocs()} + + m := onWatch(t, client) + out := plain(m.render()) + + r.Contains(out, "Deploy (Job: web, Version: 7) [2]") + r.Contains(out, "Evaluation e1a2b3c4 complete") + r.Contains(out, "Deployment running: Deployment is running but requires manual promotion") + + // The progress of each group, and the allocations of the deployment. + r.Contains(out, "Unhealthy") + r.Contains(out, "Canaries") + r.Contains(fileRow(t, m, 0), "checking") + + // The deployment screen's keys, offered as there: the canary of web + // waits under the cursor. + r.True(offers(m, "p")) + r.True(offers(m, "ctrl-p")) + r.True(offers(m, "enter")) + r.Equal("5d1a2b3c-0000-0000-0000-000000000000", client.deploymentID) +} + +func TestWatch_PromoteTheCanaryFromTheWatch(t *testing.T) { + r := require.New(t) + + running := waitingCanary() + client := &fakeClient{jobDeployments: []nomad.DeploymentDetail{running}, deploymentAllocs: canaryAllocs()} + + m := onWatch(t, client) + + m, _ = m.update(key('p')) + r.Contains(plain(m.render()), "Really promote the canaries of group web of web?") + + confirmed(t, m) + + r.Equal([]string{"web"}, client.promotedGroups) + r.Equal("production", client.askedNamespace) +} + +func TestWatch_NoDeploymentYet(t *testing.T) { + r := require.New(t) + + client := &fakeClient{newest: pendingEval(), allocs: []nomad.Alloc{ + {ID: "11111111-0000-0000-0000-000000000000", JobID: "web", Namespace: "production", TaskGroup: "web", Status: "pending", JobVersion: 7}, + {ID: "22222222-0000-0000-0000-000000000000", JobID: "web", Namespace: "production", TaskGroup: "web", Status: "running", JobVersion: 6}, + }} + + // The newest deployment is the one of the version before. + older := waitingCanary() + older.JobVersion = 6 + client.jobDeployments = []nomad.DeploymentDetail{older} + + m := onWatch(t, client) + out := plain(m.render()) + + r.Contains(out, "Waiting for the deployment of version 7.") + r.NotContains(out, "Unhealthy") + + // The allocations of the job at that version, and the keys of an + // allocation, none of a deployment. + r.Contains(out, "[1]") + r.Contains(fileRow(t, m, 0), "11111111") + r.True(offers(m, "enter")) + r.False(offers(m, "p")) + r.False(offers(m, "ctrl-p")) + r.False(offers(m, "f")) + + // The next read finds the deployment of version 7. + running := waitingCanary() + client.jobDeployments, client.deploymentAllocs = []nomad.DeploymentDetail{running}, canaryAllocs() + + m = drain(m, m.fetch()) + + out = plain(m.render()) + r.NotContains(out, "Waiting for the deployment") + r.Contains(out, "Deployment running:") + + // The allocation under the cursor is gone from the rows: the cursor + // goes back on the first one. + m, _ = m.update(key('k')) + r.True(offers(m, "p")) +} + +func TestWatch_ADeploymentThatEnded(t *testing.T) { + for name, tc := range map[string]struct{ status, description, line string }{ + "successful": {"successful", "Deployment completed successfully", "Deployment successful."}, + "failed": {"failed", "Failed due to progress deadline", "Deployment failed: Failed due to progress deadline."}, + "cancelled": {"cancelled", "Cancelled because job is stopped", "Deployment cancelled: Cancelled because job is stopped."}, + } { + t.Run(name, func(t *testing.T) { + r := require.New(t) + + done := waitingCanary() + done.Status, done.StatusDescription = tc.status, tc.description + + client := &fakeClient{jobDeployments: []nomad.DeploymentDetail{done}, deploymentAllocs: canaryAllocs()} + + m := onWatch(t, client) + + r.Contains(plain(m.render()), tc.line) + + // Nothing is left to promote, fail, pause or mark; the allocations + // keep their keys. + for _, press := range []string{"p", "ctrl-p", "f", "ctrl-s", "h", "u"} { + r.False(offers(m, press), press) + } + + r.True(offers(m, "enter")) + }) + } +} + +func TestWatch_ABatchJobHasNoDeployment(t *testing.T) { + for _, jobType := range []string{"batch", "sysbatch"} { + t.Run(jobType, func(t *testing.T) { aBatchJobHasNoDeployment(t, jobType) }) + } +} + +func aBatchJobHasNoDeployment(t *testing.T, jobType string) { + r := require.New(t) + + job := nomad.Job{ID: "web", Name: "web", Namespace: "production", Type: jobType, Status: "running"} + client := &fakeClient{allocs: []nomad.Alloc{ + {ID: "11111111-0000-0000-0000-000000000000", JobID: "web", Namespace: "production", TaskGroup: "web", Status: "running", JobVersion: 7}, + }} + + m := onWatch(t, client, job) + out := plain(m.render()) + + r.Contains(out, "web has no deployment for version 7: its allocations are below.") + r.NotContains(out, "Waiting for the deployment") + r.Contains(fileRow(t, m, 0), "11111111") +} + +func TestWatch_AVersionThatMadeNoDeployment(t *testing.T) { + // A stopped service job, or an update with max_parallel = 0: the newest + // evaluation completed after the version was submitted and no deployment + // came of it. + line := "web has no deployment for version 7: its allocations are below." + + for name, tc := range map[string]struct { + status string + index uint64 + waits bool + }{ + "completed after the version": {"complete", 70, false}, + // A submit that changes nothing makes an evaluation with no job + // index; it is still made after the version. + "after a submit of no change": {"complete", 75, false}, + "completed above it": {"complete", 71, false}, + "older than the version": {"complete", 69, true}, + "not complete": {"pending", 70, true}, + } { + t.Run(name, func(t *testing.T) { + r := require.New(t) + + eval := completeEval() + eval.Status, eval.CreateIndex = tc.status, tc.index + + m := onWatch(t, &fakeClient{newest: eval}) + out := plain(m.render()) + + if tc.waits { + r.Contains(out, "Waiting for the deployment of version 7.") + r.NotContains(out, line) + + return + } + + r.Contains(out, line) + r.NotContains(out, "Waiting for the deployment") + }) + } +} + +func TestWatch_ABlockedEvaluationSaysWhy(t *testing.T) { + r := require.New(t) + + blocked := completeEval() + blocked.Status = "blocked" + blocked.Failures = []nomad.PlacementFailure{{Group: "web", Unplaced: 2, NodesEvaluated: 3, NodesExhausted: 3}} + + client := &fakeClient{newest: blocked} + + m := onWatch(t, client) + out := plain(m.render()) + + r.Contains(out, "Evaluation e1a2b3c4 blocked") + r.Contains(out, `Task group "web" failed to place 2 allocations: Resources exhausted on 3 nodes`) +} + +func TestWatch_EveryReadIsInTheNamespaceOfTheJob(t *testing.T) { + r := require.New(t) + + running := waitingCanary() + client := &fakeClient{jobDeployments: []nomad.DeploymentDetail{running}, newest: completeEval(), deploymentAllocs: canaryAllocs()} + client.jobs, client.versions = twoJobs()[:1], versionSeven() + + m := newTestModel(client) + m, _ = m.update(key('0')) + r.Equal(nomad.AllNamespaces, m.namespace) + + m, _ = m.update(jobsMsg(client.jobs)) + m, cmd := m.update(key('w')) + m = drain(m, cmd) + + r.Contains(plain(m.render()), "Deploy (Job: web, Version: 7)") + + r.Equal([]string{"production"}, client.lists["versions"]) + + for _, read := range []string{"job deployments", "newest evaluation"} { + r.Equal([]string{"production/web"}, client.lists[read], read) + } + + r.Equal("production", client.deploymentNamespace) + r.Equal("production", namespaceOf(m.screen.page, m.env())) +} + +func TestWatch_AJobWithoutADeploymentReadsItsAllocationsInItsNamespace(t *testing.T) { + r := require.New(t) + + client := &fakeClient{newest: pendingEval()} + client.jobs, client.versions = twoJobs()[:1], versionSeven() + + m := newTestModel(client) + m, _ = m.update(key('0')) + m, _ = m.update(jobsMsg(client.jobs)) + m, cmd := m.update(key('w')) + m = drain(m, cmd) + + r.Contains(plain(m.render()), "Waiting for the deployment of version 7.") + r.Equal([]string{"production/web"}, client.lists["allocations"]) +} + +func TestWatch_KeepsTheVersionItOpenedOn(t *testing.T) { + r := require.New(t) + + client := &fakeClient{} + + m := onWatch(t, client) + r.Contains(plain(m.render()), "Version: 7") + + // Version 8 is submitted while the watch is open. + newer := waitingCanary() + newer.JobVersion = 8 + client.versions, client.jobDeployments = []nomad.JobVersion{{Version: 8, Current: true}, {Version: 7}}, []nomad.DeploymentDetail{newer} + + m = drain(m, m.fetch()) + + r.Contains(plain(m.render()), "Version: 7") + r.NotContains(plain(m.render()), "Version: 8") +} + +func TestWatch_DropsAnAnswerOfAnotherVersion(t *testing.T) { + r := require.New(t) + + m := onWatch(t, &fakeClient{}) + r.Contains(plain(m.render()), "Version: 7") + + // A read asked before the watch knew its version can answer after it, + // with the version a newer submit made. + m, _ = m.update(deployWatchMsg{namespace: "production", jobID: "web", version: 8}) + + r.Contains(plain(m.render()), "Version: 7") + r.NotContains(plain(m.render()), "Version: 8") +} + +func TestWatch_ANewerVersionCancelsTheDeploymentWatched(t *testing.T) { + r := require.New(t) + + running := waitingCanary() + client := &fakeClient{jobDeployments: []nomad.DeploymentDetail{running}, deploymentAllocs: canaryAllocs()} + + m := onWatch(t, client) + r.Contains(plain(m.render()), "Deployment running:") + + // Version 8 is submitted: its deployment is the newest, and Nomad + // cancelled the one of version 7. + cancelled := waitingCanary() + cancelled.Status, cancelled.StatusDescription = "cancelled", "Cancelled due to newer version of job" + + newer := waitingCanary() + newer.ID, newer.JobVersion = "8e2f0000-0000-0000-0000-000000000000", 8 + + client.jobDeployments = []nomad.DeploymentDetail{newer, cancelled} + + m = drain(m, m.fetch()) + out := plain(m.render()) + + r.Contains(out, "Version: 7") + r.Contains(out, "Deployment cancelled: Cancelled due to newer version of job.") + r.NotContains(out, "Waiting for the deployment") + r.Equal("5d1a2b3c-0000-0000-0000-000000000000", client.deploymentID) +} + +func TestWatch_BeforeItsFirstReadItHasNoPanel(t *testing.T) { + r := require.New(t) + + client := &fakeClient{} + client.jobs, client.versions = twoJobs()[:1], versionSeven() + + m := newTestModel(client) + m, _ = m.update(jobsMsg(client.jobs)) + m, _ = m.update(key('w')) + + out := plain(m.render()) + r.Contains(out, "Deploy (Job: web) [0]") + r.NotContains(out, "Waiting") +} + +func TestWatch_AJobWithNoVersionsIsAnError(t *testing.T) { + r := require.New(t) + + client := &fakeClient{} + + _, err := readDeploy(context.Background(), client, deployWatchPage{namespace: "production", jobID: "web"}) + + r.EqualError(err, "web has no versions") + r.Empty(client.lists["job deployments"]) + r.Empty(client.lists["newest evaluation"]) + r.Empty(client.lists["allocations"]) +} + +func TestWatch_WatchesTheJobItsEvaluationsItsDeploymentAndItsAllocations(t *testing.T) { + r := require.New(t) + + m := onWatch(t, &fakeClient{}) + + r.ElementsMatch([]string{nomad.TopicJob, nomad.TopicEvaluation, nomad.TopicDeployment, nomad.TopicAllocation}, m.screen.page.topics()) +} + +func TestWatch_NotOfferedOnAJobThatLaunchesOthers(t *testing.T) { + r := require.New(t) + + jobs := []nomad.Job{ + {ID: "cron", Name: "cron", Namespace: "production", Type: "batch", Status: "running", Periodic: true}, + {ID: "mail", Name: "mail", Namespace: "production", Type: "batch", Status: "running", Parameterized: true}, + twoJobs()[0], + } + + m := newTestModel(&fakeClient{jobs: jobs}) + m, _ = m.update(jobsMsg(jobs)) + + r.False(offers(m, "w")) + + m, _ = m.update(key('j')) + r.False(offers(m, "w")) + + m, _ = m.update(key('j')) + r.True(offers(m, "w")) +} + +func TestWatch_IsOfferedInReadOnlyMode(t *testing.T) { + r := require.New(t) + + m := newTestModel(&fakeClient{jobs: twoJobs()[:1], versions: versionSeven()}) + m.opts.ReadOnly = true + m, _ = m.update(jobsMsg(twoJobs()[:1])) + + r.True(offers(m, "w")) + + m, cmd := m.update(key('w')) + m = drain(m, cmd) + + r.Contains(plain(m.render()), "Deploy (Job: web, Version: 7)") +} + +// schedulerRacing is a cluster whose scheduler writes the deployment while +// the evaluation is read: the deployment is there for any read of it after +// the evaluation was read. +type schedulerRacing struct { + *fakeClient + + evalRead bool +} + +func (c *schedulerRacing) NewestEvaluation(ctx context.Context, namespace, jobID string) (*nomad.EvaluationDetail, error) { + c.evalRead = true + + return c.fakeClient.NewestEvaluation(ctx, namespace, jobID) +} + +func (c *schedulerRacing) JobDeployments(ctx context.Context, namespace, jobID string) ([]nomad.DeploymentDetail, error) { + if !c.evalRead { + return nil, nil + } + + return c.fakeClient.JobDeployments(ctx, namespace, jobID) +} + +func TestWatch_ReadsTheEvaluationBeforeTheDeployments(t *testing.T) { + r := require.New(t) + + eval := completeEval() + eval.CreateIndex = 70 + + jobs := []nomad.Job{twoJobs()[0]} + client := &schedulerRacing{fakeClient: &fakeClient{ + jobs: jobs, versions: versionSeven(), newest: eval, + jobDeployments: []nomad.DeploymentDetail{waitingCanary()}, deploymentAllocs: canaryAllocs(), + }} + + m := newTestModel(client) + m, _ = m.update(jobsMsg(jobs)) + m, cmd := m.update(key('w')) + out := plain(drain(m, cmd).render()) + + r.Contains(out, "Deployment running:") + r.NotContains(out, "has no deployment") +} + +func TestWatch_AServiceJobWithNoEvaluationHasNoDeploymentToWaitFor(t *testing.T) { + r := require.New(t) + + // A stopped job whose evaluation and deployment were collected. + out := plain(onWatch(t, &fakeClient{}).render()) + + r.Contains(out, "web has no deployment for version 7: its allocations are below.") + r.NotContains(out, "Waiting for the deployment") +} + +func TestWatch_LogsAreOfTheAllocationsOfTheRows(t *testing.T) { + r := require.New(t) + + alloc := func(id string, version uint64, task string) nomad.Alloc { + return nomad.Alloc{ID: id, JobID: "web", Namespace: "production", TaskGroup: "web", Status: statusRunning, + JobVersion: version, Tasks: []nomad.Task{{Name: task}}} + } + + // Version 7 has no deployment yet: its evaluation is still pending. + client := &fakeClient{newest: pendingEval(), allocs: []nomad.Alloc{ + alloc("11111111-0000-0000-0000-000000000000", 7, "new"), + alloc("22222222-0000-0000-0000-000000000000", 6, "old"), + }, logsByAlloc: map[string]*nomad.LogStream{"11111111-0000-0000-0000-000000000000": writing()}} + + m := onWatch(t, client) + r.Contains(plain(m.render()), "Waiting for the deployment of version 7.") + + m, cmd := m.update(key('l')) + m = playOut(m, cmd) + + r.IsType(jobLogsPage{}, m.screen.page) + r.Equal("new", m.screen.page.(jobLogsPage).task) +} diff --git a/internal/ui/writes_test.go b/internal/ui/writes_test.go index 803b0e4..089886b 100644 --- a/internal/ui/writes_test.go +++ b/internal/ui/writes_test.go @@ -31,13 +31,13 @@ var ( } clientReads = []string{ - "Address", "Region", "Agent", "Regions", "Datacenters", "AllocationChecks", "Files", "File", "Deployment", "DeploymentAllocations", "Token", "ServiceInstances", + "Address", "Region", "Agent", "Regions", "Datacenters", "AllocationChecks", "Files", "File", "Deployment", "JobDeployments", "DeploymentAllocations", "Token", "ServiceInstances", "Jobs", "NextLaunch", "JobSpec", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", "DescribeJob", "DescribeAllocation", "DescribeDeployment", "DescribeService", "Allocations", "NodeAllocations", "Allocation", "Logs", "Usage", "AllocationUsage", "TaskUsage", "NodeUsage", "Node", "NodeDetail", "NodeMeta", "NodeMetaSpec", "Nodes", "NodePools", "PoolJobs", "PoolNodes", - "NamespaceSpec", "Namespaces", "Deployments", "Services", "Evaluations", "Evaluation", "EvaluationAllocations", "FailedPlacement", "Variables", "Variable", "VariableSpec", + "NamespaceSpec", "Namespaces", "Deployments", "Services", "Evaluations", "Evaluation", "EvaluationAllocations", "FailedPlacement", "NewestEvaluation", "Variables", "Variable", "VariableSpec", "Servers", "Server", "RaftPeers", "Events", "Volumes", "Volume", "DescribeVolume", "Plugins", "Plugin", "DescribePlugin", "Find", "DispatchForm", "ServerHealth", From 65b83106a5548cf25fc8dc9f2ab204990ae33023 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Fri, 16 Oct 2026 13:26:15 +0000 Subject: [PATCH 2/2] feat(ui): open the watch of a deploy after a plan is submitted --- docs/jobs.md | 6 ++ docs/keys.md | 2 +- internal/ui/actions.go | 2 +- internal/ui/edit.go | 2 + internal/ui/launches.go | 5 +- internal/ui/plan.go | 17 +++- internal/ui/plan_test.go | 180 ++++++++++++++++++++++++++++++++++++++- internal/ui/version.go | 7 +- 8 files changed, 210 insertions(+), 11 deletions(-) diff --git a/docs/jobs.md b/docs/jobs.md index 9afc3a5..043b022 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -139,6 +139,12 @@ urga submits exactly what was planned. If someone changed the job after the plan, the cluster refuses the submit, and urga asks you to plan again with `r`. +After a submit or a revert, urga goes back to where the edit or the revert +started (the job list or the versions screen) and opens the watch of the deploy +of the job on top of it. `esc` leaves the watch; the deploy goes +on. A periodic or parameterized job has no deploy of its own, so after its +submit urga only goes back. + ## Versions and reverting `v` lists the versions the cluster keeps for a job. `enter` shows what a diff --git a/docs/keys.md b/docs/keys.md index f358b79..15b0538 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -260,7 +260,7 @@ On what was done to the count of a group, `d` describes its policy. | --- | --- | --- | | `left`, `right`, `tab` | Choose Cancel or Submit | | | `enter` | Press the chosen button | | -| `y` | Submit, or revert, at once | yes | +| `y` | Submit, or revert, at once, and watch the deploy of a job that runs itself | yes | | `r` | Plan again | | | `esc` | Go back without submitting | | | `w` | Wrap long lines | | diff --git a/internal/ui/actions.go b/internal/ui/actions.go index c9ad7d6..e6da4b0 100644 --- a/internal/ui/actions.go +++ b/internal/ui/actions.go @@ -213,7 +213,7 @@ func revertJob(p jobsPage, e env) (jobsPage, outcome) { return p, outcome{} } - return p, outcome{cmd: planFor(e.client, planState{revert: true, namespace: job.Namespace, jobID: job.ID})} + return p, outcome{cmd: planFor(e.client, planState{revert: true, namespace: job.Namespace, jobID: job.ID, jobType: job.Type, launches: launchesOthers(job)})} } // allocAction is what a key does to each allocation, and how the question diff --git a/internal/ui/edit.go b/internal/ui/edit.go index dfce94d..cb3c4b8 100644 --- a/internal/ui/edit.go +++ b/internal/ui/edit.go @@ -116,6 +116,8 @@ func jobFile(client jobsClient, job nomad.Job) load { return planFor(client, planState{ namespace: job.Namespace, jobID: job.ID, + jobType: job.Type, + launches: launchesOthers(job), source: source, vars: spec.Variables, read: spec.Index, diff --git a/internal/ui/launches.go b/internal/ui/launches.go index 1a59fce..ee1cbff 100644 --- a/internal/ui/launches.go +++ b/internal/ui/launches.go @@ -75,9 +75,12 @@ func launcherColor(job nomad.Job, last map[string]nomad.Job) color.Color { func launchesJobs(p jobsPage, e env) bool { job, ok := p.picked(e) - return ok && (job.Periodic || job.Parameterized) + return ok && launchesOthers(job) } +// launchesOthers says the job is periodic or parameterized. +func launchesOthers(job nomad.Job) bool { return job.Periodic || job.Parameterized } + // runsItself says the job under the cursor has allocations of its own, when // there is one. func runsItself(p jobsPage, e env) bool { return !launchesJobs(p, e) } diff --git a/internal/ui/plan.go b/internal/ui/plan.go index 3ba840f..bb3c8db 100644 --- a/internal/ui/plan.go +++ b/internal/ui/plan.go @@ -19,6 +19,11 @@ type planState struct { namespace string jobID string + // jobType is the type of the job when it was planned, launches says it + // launches jobs of its own: the watch opened after a submit needs both. + jobType string + launches bool + // source and vars are the job file of an edit, read is the index the // job had when the file was read, index the one it had when planned. source string @@ -102,7 +107,8 @@ func (p planPage) text(env) textContent { return p.content } // take puts a new plan of the same job in place of this one, not on top of // it: escape still goes back to where the plan was asked from. After a plan -// is submitted, the screen goes back to where the edit started. +// is submitted, the screen goes back to where the edit started, and the +// watch of the deploy opens on top. func (p planPage) take(msg tea.Msg, _ env) (page, outcome, bool) { switch msg := msg.(type) { case openMsg: @@ -118,7 +124,14 @@ func (p planPage) take(msg tea.Msg, _ env) (page, outcome, bool) { return p, outcome{}, false } - return p, outcome{now: []tea.Msg{backMsg{}}, reading: true}, true + // The deploys of a job that launches others are those of its launches. + if p.launches { + return p, outcome{now: []tea.Msg{backMsg{}}, reading: true}, true + } + + watch := deployWatchPage{namespace: p.namespace, jobID: p.jobID, jobType: p.jobType} + + return p, outcome{now: []tea.Msg{backMsg{}, openMsg{watch}}, reading: true}, true } return p, outcome{}, false diff --git a/internal/ui/plan_test.go b/internal/ui/plan_test.go index c0de841..4f28a6c 100644 --- a/internal/ui/plan_test.go +++ b/internal/ui/plan_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/require" @@ -183,8 +184,8 @@ func TestPlan_SubmitsAtItsIndex(t *testing.T) { r.Equal("job \"web\" {\n type = \"batch\"\n}", client.submittedSource) r.Equal(uint64(42), client.submittedIndex) - // The plan is spent: the screen goes back to the job list. - r.IsType(jobsPage{}, m.screen.page) + // The plan is spent: the watch of the deploy is on top of the job list. + r.IsType(deployWatchPage{}, m.screen.page) r.Contains(plain(m.render()), "Job web submitted") } @@ -346,7 +347,7 @@ func TestPlan_EnterOnSubmitSendsIt(t *testing.T) { r.Equal(1, client.submitted) r.Equal(uint64(42), client.submittedIndex) - r.IsType(jobsPage{}, m.screen.page) + r.IsType(deployWatchPage{}, m.screen.page) } func TestPlan_WhatCannotBePlacedIsNotSent(t *testing.T) { @@ -627,3 +628,176 @@ func TestPlan_OfAnotherJobGoesOnTop(t *testing.T) { m, _ = m.update(escape()) r.Contains(plain(m.render()), "Revert (Job: web, Version: 3)") } + +func TestPlan_ASubmitOpensTheWatchOfTheDeploy(t *testing.T) { + r := require.New(t) + + client := &fakeClient{ + jobs: twoJobs(), versions: versionSeven(), + spec: nomad.JobSource{Source: "job \"web\" {}", Index: 42}, plan: changedEnv(), + } + m := planned(t, client) + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + + r.Equal(1, client.submitted) + r.IsType(deployWatchPage{}, m.screen.page) + r.Equal("web", m.screen.page.(deployWatchPage).jobID) + r.Contains(plain(m.render()), "Job web submitted.") + + m, _ = m.update(escape()) + + r.IsType(jobsPage{}, m.screen.page) +} + +func TestPlan_ARevertOpensTheWatchOfTheDeploy(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), versions: versionSeven(), plan: nomad.Plan{To: 3, Version: 4, Index: 42}} + m := newTestModel(client) + m, _ = m.update(jobsMsg(twoJobs())) + + m, cmd := m.update(key('u')) + m = drain(m, cmd) + + m, cmd = m.update(key('y')) + m = drain(m, cmd) + + r.NotZero(client.revertedTo) + r.IsType(deployWatchPage{}, m.screen.page) + r.Contains(plain(m.render()), "reverted to version") + + m, _ = m.update(escape()) + + r.IsType(jobsPage{}, m.screen.page) +} + +func TestPlan_ASubmitThatFailsOpensNoWatch(t *testing.T) { + r := require.New(t) + + client := &fakeClient{ + jobs: twoJobs(), + spec: nomad.JobSource{Source: "job \"web\" {}", Index: 42}, + plan: changedEnv(), + actionErr: errors.New("Unexpected response code: 500 (rpc error)"), + } + m := planned(t, client) + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + + r.IsType(planPage{}, m.screen.page) +} + +func TestPlan_TheWatchAfterASubmitReadsInTheNamespaceOfTheJob(t *testing.T) { + r := require.New(t) + + client := &fakeClient{ + jobs: twoJobs(), versions: versionSeven(), + spec: nomad.JobSource{Source: "job \"web\" {}", Index: 42}, plan: changedEnv(), + } + m, editor := editModel(t, client) + editor.replace = "job \"web\" {\n type = \"batch\"\n}" + + m, _ = m.update(key('0')) + r.Equal(nomad.AllNamespaces, m.namespace) + + m, cmd := m.update(key('e')) + m = follow(m, cmd, 6) + + m, cmd = m.update(key('y')) + m = drain(m, cmd) + m = drain(m, m.screen.page.fetch(m.env())) + + r.IsType(deployWatchPage{}, m.screen.page) + r.Equal([]string{"production"}, client.lists["versions"]) + r.Equal("production", namespaceOf(m.screen.page, m.env())) +} + +// submittedFrom is the screen after y on the plan of a job, reached the way +// the route says: the edit of its file, a revert from the list of jobs, or a +// revert from its versions. +func submittedFrom(t *testing.T, route string, job nomad.Job) Model { + t.Helper() + + client := &fakeClient{ + jobs: []nomad.Job{job}, versions: versionSeven(), + spec: nomad.JobSource{Source: "job \"" + job.ID + "\" {}", Index: 42}, plan: nomad.Plan{To: 6, Version: 7, Index: 42}, + } + + editor := &fakeEditor{replace: "job \"" + job.ID + "\" {\n type = \"batch\"\n}"} + + m := New(client, Options{Namespace: "production", Version: "v-test", Editor: editor, PollEvery: time.Millisecond}) + m, _ = m.update(sizeMsg()) + m, _ = m.update(jobsMsg(client.jobs)) + + switch route { + case "edit": + client.plan = changedEnv() + m, cmd := m.update(key('e')) + m = follow(m, cmd, 6) + + return confirmedPlan(m) + + case "revert": + m, cmd := m.update(key('u')) + + return confirmedPlan(drain(m, cmd)) + } + + m, cmd := m.update(key('v')) + m, cmd = drain(m, cmd).update(key('u')) + + return confirmedPlan(drain(m, cmd)) +} + +func confirmedPlan(m Model) Model { + m, cmd := m.update(key('y')) + + return drain(m, cmd) +} + +func TestPlan_TheWatchAfterASubmitOfABatchJobHasNoDeployment(t *testing.T) { + job := nomad.Job{ID: "nightly", Name: "nightly", Namespace: "production", Type: "batch", Status: "running"} + + for _, route := range []string{"edit", "revert", "versions"} { + t.Run(route, func(t *testing.T) { + r := require.New(t) + + m := submittedFrom(t, route, job) + + r.IsType(deployWatchPage{}, m.screen.page) + + m = drain(m, m.screen.page.fetch(m.env())) + + out := plain(m.render()) + r.Contains(out, "nightly has no deployment for version 7: its allocations are below.") + r.NotContains(out, "Waiting for the deployment") + }) + } +} + +func TestPlan_ASubmitOfAJobThatLaunchesOthersOpensNoWatch(t *testing.T) { + for name, job := range map[string]nomad.Job{ + "periodic": {ID: "cron", Name: "cron", Namespace: "production", Type: "batch", Status: "running", Periodic: true}, + "parameterized": {ID: "mail", Name: "mail", Namespace: "production", Type: "batch", Status: "running", Parameterized: true}, + } { + for _, route := range []string{"edit", "revert", "versions"} { + t.Run(name+" "+route, func(t *testing.T) { + r := require.New(t) + + m := submittedFrom(t, route, job) + + // Back where the plan started. + if route == "versions" { + r.IsType(versionsPage{}, m.screen.page) + } else { + r.IsType(jobsPage{}, m.screen.page) + } + + r.Contains(plain(m.render()), "Job "+job.ID) + }) + } + } +} diff --git a/internal/ui/version.go b/internal/ui/version.go index e62a5f0..7a53b1e 100644 --- a/internal/ui/version.go +++ b/internal/ui/version.go @@ -17,7 +17,8 @@ var versionTitles = []string{"Version", "State", "Tag", "Changes", "Age"} // versionsPage lists the versions of one job. type versionsPage struct { - namespace, jobID string + namespace, jobID, jobType string + launches bool versions []nomad.JobVersion } @@ -82,7 +83,7 @@ func openVersions(p jobsPage, e env) (jobsPage, outcome) { return p, outcome{} } - return p, then(openMsg{versionsPage{namespace: job.Namespace, jobID: job.ID}}) + return p, then(openMsg{versionsPage{namespace: job.Namespace, jobID: job.ID, jobType: job.Type, launches: launchesOthers(job)}}) } func versionRows(versions []nomad.JobVersion) []tableRow { @@ -167,7 +168,7 @@ func revertToVersion(p versionsPage, e env) (versionsPage, outcome) { to := version.Version - return p, outcome{cmd: planFor(e.client, planState{revert: true, to: &to, namespace: p.namespace, jobID: p.jobID})} + return p, outcome{cmd: planFor(e.client, planState{revert: true, to: &to, namespace: p.namespace, jobID: p.jobID, jobType: p.jobType, launches: p.launches})} } // tagVersion asks for the name to give the version under the cursor, filled