From f35f24ca60678556c726e3e7a6ad3727503581bf Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Fri, 16 Oct 2026 14:21:27 +0000 Subject: [PATCH 1/2] feat(ui): create a new job from a template with n --- docs/jobs.md | 14 ++ docs/keys.md | 1 + internal/nomad/actions.go | 2 +- internal/nomad/edit.go | 81 +++++++++++- internal/nomad/target_test.go | 125 ++++++++++++++++++ internal/ui/app_test.go | 8 ++ internal/ui/edit.go | 12 ++ internal/ui/jobs.go | 1 + internal/ui/jobs_test.go | 1 + internal/ui/newjob_test.go | 234 ++++++++++++++++++++++++++++++++++ internal/ui/plan.go | 73 ++++++++++- internal/ui/roles.go | 1 + internal/ui/writes_test.go | 2 +- 13 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 internal/nomad/target_test.go create mode 100644 internal/ui/newjob_test.go diff --git a/docs/jobs.md b/docs/jobs.md index 043b022..1729787 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -114,6 +114,20 @@ the cluster for a plan and shows it. If the cluster cannot plan the file, for example because of a syntax error, urga opens the editor again with your edit and the reason at the top. See [Editor](configuration.md#editor). +## Creating a job + +`n` opens a template of a job in your editor: a service with one group `web` +and one task `server` that runs `nginx`. Change it into your job and save. If +you save the file as it was, nothing is sent. + +Like an edit, the saved file is planned first and submitted with `y` from the +plan. The job goes to the namespace its file names. A file that names none +goes to the namespace of the session, and to `default` when the session looks +at all of them. + +A new job never replaces one. If a job with the same name exists, the plan +says so and offers no submit. + ## The plan The plan shows, from top to bottom: diff --git a/docs/keys.md b/docs/keys.md index 15b0538..d745365 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -216,6 +216,7 @@ start, stop, restart or drain then applies to every marked row. | `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 | +| `n` | New job: opens a job template 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 | | `ctrl-e` | Evaluate the job again: the scheduler places what it can now | yes | diff --git a/internal/nomad/actions.go b/internal/nomad/actions.go index d3ce152..5accd6c 100644 --- a/internal/nomad/actions.go +++ b/internal/nomad/actions.go @@ -80,7 +80,7 @@ func (c *Client) restoreScaling(ctx context.Context, namespace string, job *api. return nil } - submitted, _, err := c.parseJob(ctx, namespace, kept.Source, JobVariables{Flags: kept.VariableFlags, File: kept.Variables}) + submitted, _, err := c.parseJob(ctx, namespace, kept.Source, JobVariables{Flags: kept.VariableFlags, File: kept.Variables}, true) if err != nil { return fmt.Errorf("the scaling policies could not be turned back on: %w", err) } diff --git a/internal/nomad/edit.go b/internal/nomad/edit.go index cd2f22b..e64b0b8 100644 --- a/internal/nomad/edit.go +++ b/internal/nomad/edit.go @@ -40,7 +40,7 @@ func (c *Client) SubmitJob(ctx context.Context, namespace, source string, vars J // jobOf reads a job file and puts the job in the namespace it is sent to. func (c *Client) jobOf(ctx context.Context, namespace, source string, vars JobVariables) (*api.Job, *api.JobSubmission, error) { - job, kept, err := c.parseJob(ctx, namespace, source, vars) + job, kept, err := c.parseJob(ctx, namespace, source, vars, true) if err != nil { return nil, nil, err } @@ -53,8 +53,9 @@ func (c *Client) jobOf(ctx context.Context, namespace, source string, vars JobVa } // parseJob reads a job file the way the cluster does, and returns what of it -// to keep with the version. -func (c *Client) parseJob(ctx context.Context, namespace, source string, vars JobVariables) (*api.Job, *api.JobSubmission, error) { +// to keep with the version. A canonical job has every field the file leaves +// out filled in, its namespace too. +func (c *Client) parseJob(ctx context.Context, namespace, source string, vars JobVariables, canonicalize bool) (*api.Job, *api.JobSubmission, error) { if strings.HasPrefix(strings.TrimSpace(source), "{") { // The file holds the job or wraps it in a Job key. The cluster takes // both, and keeps the file as it was written. @@ -81,7 +82,7 @@ func (c *Client) parseJob(ctx context.Context, namespace, source string, vars Jo return nil, nil, err } - request := &api.JobsParseRequest{JobHCL: source, Variables: variables, Canonicalize: true} + request := &api.JobsParseRequest{JobHCL: source, Variables: variables, Canonicalize: canonicalize} // The client of the API parses without a namespace, which is the default // one, and a token may be allowed to parse jobs in its own namespace only. @@ -214,3 +215,75 @@ func namespaceOf(source string) (*api.Namespace, error) { return namespace, nil } + +// JobTemplate is the file a new job starts from. +const JobTemplate = `job "web" { + type = "service" + + group "web" { + count = 1 + + network { + port "http" { + to = 80 + } + } + + task "server" { + driver = "docker" + + config { + image = "nginx:1.27" + ports = ["http"] + } + + resources { + cpu = 100 + memory = 128 + } + } + } +} +` + +// JobTarget is where a job file sends its job: the job, the namespace it goes +// to, and what the job is. +type JobTarget struct { + ID string + Namespace string + Type string + + // Launches says the job launches jobs of its own. + Launches bool +} + +// JobTarget reads a job file for where it goes. The namespace is the one the +// file names, or else the one of the session, and default when the session +// looks at all of them. +func (c *Client) JobTarget(ctx context.Context, namespace, source string) (JobTarget, error) { + if namespace == "" || namespace == AllNamespaces { + namespace = DefaultNamespace + } + + job, _, err := c.parseJob(ctx, namespace, source, JobVariables{}, false) + if err != nil { + return JobTarget{}, err + } + + target := JobTarget{ + ID: valueOf(job.ID), + Namespace: namespace, + Type: valueOf(job.Type), + Launches: job.IsPeriodic() || job.IsParameterized(), + } + + if named := valueOf(job.Namespace); named != "" { + target.Namespace = named + } + + if target.Type == "" { + target.Type = api.JobTypeService + } + + return target, nil +} diff --git a/internal/nomad/target_test.go b/internal/nomad/target_test.go new file mode 100644 index 0000000..d99a984 --- /dev/null +++ b/internal/nomad/target_test.go @@ -0,0 +1,125 @@ +package nomad_test + +import ( + "context" + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +func TestJobTarget_HCLNamesItsNamespace(t *testing.T) { + r := require.New(t) + + client, asked := clusterServer(t, map[string]string{ + "/v1/jobs/parse": `{"ID": "web", "Name": "web", "Namespace": "staging", "Type": "batch"}`, + }) + + target, err := client.JobTarget(context.Background(), "production", "job \"web\" {}") + r.NoError(err) + + r.Equal(nomad.JobTarget{ID: "web", Namespace: "staging", Type: "batch"}, target) + + // The file is read as written: a canonical job has a namespace even when + // the file names none. + r.Len(*asked, 1) + r.Equal(false, (*asked)[0].body["Canonicalize"]) +} + +func TestJobTarget_NoNamespaceInTheFileIsTheSessions(t *testing.T) { + r := require.New(t) + + client, asked := clusterServer(t, map[string]string{"/v1/jobs/parse": `{"ID": "web", "Name": "web"}`}) + + target, err := client.JobTarget(context.Background(), "production", "job \"web\" {}") + r.NoError(err) + + r.Equal("production", target.Namespace) + r.Equal("production", (*asked)[0].namespace) +} + +func TestJobTarget_EveryNamespaceGoesToDefault(t *testing.T) { + for _, session := range []string{"", nomad.AllNamespaces} { + r := require.New(t) + + client, asked := clusterServer(t, map[string]string{"/v1/jobs/parse": `{"ID": "web", "Name": "web"}`}) + + target, err := client.JobTarget(context.Background(), session, "job \"web\" {}") + r.NoError(err) + + r.Equal(nomad.DefaultNamespace, target.Namespace) + r.Equal(nomad.DefaultNamespace, (*asked)[0].namespace) + } +} + +func TestJobTarget_ATypelessJobIsAService(t *testing.T) { + r := require.New(t) + + client, _ := clusterServer(t, map[string]string{"/v1/jobs/parse": `{"ID": "web", "Name": "web"}`}) + + target, err := client.JobTarget(context.Background(), "production", "job \"web\" {}") + r.NoError(err) + + r.Equal("service", target.Type) + r.False(target.Launches) +} + +func TestJobTarget_AJobThatLaunchesOthers(t *testing.T) { + for name, answer := range map[string]string{ + "periodic": `{"ID": "cron", "Type": "batch", "Periodic": {"Enabled": true}}`, + "parameterized": `{"ID": "cron", "Type": "batch", "ParameterizedJob": {}}`, + } { + t.Run(name, func(t *testing.T) { + client, _ := clusterServer(t, map[string]string{"/v1/jobs/parse": answer}) + + target, err := client.JobTarget(context.Background(), "production", "job \"cron\" {}") + require.NoError(t, err) + require.True(t, target.Launches) + }) + } +} + +func TestJobTarget_JSONIsReadWithoutTheCluster(t *testing.T) { + r := require.New(t) + + client, asked := clusterServer(t, nil) + + target, err := client.JobTarget(context.Background(), "production", + `{"Job": {"ID": "web", "Namespace": "staging", "Type": "system"}}`) + r.NoError(err) + + r.Equal(nomad.JobTarget{ID: "web", Namespace: "staging", Type: "system"}, target) + r.Empty(*asked) + + target, err = client.JobTarget(context.Background(), "", `{"ID": "web"}`) + r.NoError(err) + r.Equal(nomad.DefaultNamespace, target.Namespace) +} + +func TestJobTarget_AFileTheClusterDoesNotParse(t *testing.T) { + r := require.New(t) + + client, _ := clusterServer(t, nil) + + _, err := client.JobTarget(context.Background(), "production", "job {") + r.ErrorContains(err, "the job was not accepted") + + _, err = client.JobTarget(context.Background(), "production", "{not json") + r.ErrorContains(err, "the job is not valid JSON") +} + +func TestJobTemplate_IsAJobFile(t *testing.T) { + r := require.New(t) + + file, diags := hclsyntax.ParseConfig([]byte(nomad.JobTemplate), "job.hcl", hcl.InitialPos) + r.False(diags.HasErrors(), diags.Error()) + + blocks := file.Body.(*hclsyntax.Body).Blocks + r.Len(blocks, 1) + r.Equal("job", blocks[0].Type) + r.Equal([]string{"web"}, blocks[0].Labels) + r.NotContains(nomad.JobTemplate, "#") +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 85b2350..0c0eba8 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -72,6 +72,8 @@ type fakeClient struct { submittedIndex uint64 plan nomad.Plan + target nomad.JobTarget + targetAskedIn string planCalls int plannedSource string plannedVars nomad.JobVariables @@ -878,6 +880,12 @@ func (f *fakeClient) PlanJob(_ context.Context, namespace, source string, vars n return f.plan, f.refused(f.err) } +func (f *fakeClient) JobTarget(_ context.Context, namespace, _ string) (nomad.JobTarget, error) { + f.targetAskedIn = namespace + + return f.target, f.refused(f.err) +} + func (f *fakeClient) SubmitJob(_ context.Context, namespace, source string, vars nomad.JobVariables, index uint64) error { f.wrote("SubmitJob") f.askedNamespace, f.submittedSource, f.submittedVars = namespace, source, vars diff --git a/internal/ui/edit.go b/internal/ui/edit.go index cb3c4b8..59aacf8 100644 --- a/internal/ui/edit.go +++ b/internal/ui/edit.go @@ -78,6 +78,18 @@ func editJob(p jobsPage, e env) (jobsPage, outcome) { return p, outcome{cmd: openEditor(jobFile(e.client, job))} } +// newJob opens the template of a job in the editor. What is saved is planned +// in the namespace its file names. +func newJob(p jobsPage, e env) (jobsPage, outcome) { + client, namespace := e.client, e.namespace + + return p, outcome{cmd: openEditor(func(context.Context) (file, error) { + return file{extension: "hcl", content: nomad.JobTemplate, submit: reopening("hcl", func(source string) tea.Cmd { + return planNew(client, namespace, source) + })}, nil + })} +} + func editMeta(p nodeMetaPage, e env) (nodeMetaPage, outcome) { return p, outcome{cmd: openEditor(metaFile(e.client, p.nodeID, p.name))} } diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index bd91715..3cf84dc 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -185,6 +185,7 @@ var jobsKeys = []pageKey[jobsPage]{ {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: "n", label: "New", do: newJob, writes: true}, {press: "p", label: "Placement", do: jobPlacement, offered: jobWaits}, } diff --git a/internal/ui/jobs_test.go b/internal/ui/jobs_test.go index f7c6e47..0422a98 100644 --- a/internal/ui/jobs_test.go +++ b/internal/ui/jobs_test.go @@ -91,6 +91,7 @@ func TestJobs_TheKeysOfAJob(t *testing.T) { {Key: "", Description: "Logs"}, {Key: "", Description: "Watch"}, {Key: "", Description: "Edit"}, + {Key: "", Description: "New"}, } r.Equal(keys, m.hints()) diff --git a/internal/ui/newjob_test.go b/internal/ui/newjob_test.go new file mode 100644 index 0000000..d294a68 --- /dev/null +++ b/internal/ui/newjob_test.go @@ -0,0 +1,234 @@ +package ui + +import ( + "errors" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// newJobModel is the job list of a session in namespace, with an editor that +// saves edited in place of the template. +func newJobModel(client *fakeClient, namespace, edited string) (Model, *fakeEditor) { + editor := &fakeEditor{replace: edited} + + m := New(client, Options{Namespace: namespace, Version: "v-test", Editor: editor, PollEvery: time.Millisecond}) + m, _ = m.update(sizeMsg()) + m, _ = m.update(jobsMsg(twoJobs())) + + return m, editor +} + +// editedTemplate is the template with something typed into it: a file saved +// as it was sent nothing. +var editedTemplate = strings.Replace(nomad.JobTemplate, "nginx:1.27", "nginx:1.28", 1) + +// plannedNew presses n, saves the edited template and returns the plan that opens. +func plannedNew(t *testing.T, client *fakeClient, namespace string) Model { + t.Helper() + + m, _ := newJobModel(client, namespace, editedTemplate) + + m, cmd := m.update(key('n')) + + return follow(m, cmd, 6) +} + +func TestNewJob_OpensTheTemplateAndPlansWhatIsSaved(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Groups: []nomad.PlanGroup{{Name: "web", Place: 1}}}, + target: nomad.JobTarget{ID: "web", Namespace: "production", Type: "service"}} + m, editor := newJobModel(client, "production", editedTemplate) + + m, cmd := m.update(key('n')) + m = follow(m, cmd, 6) + + r.Equal([]string{nomad.JobTemplate}, editor.seen) + r.IsType(planPage{}, m.screen.page) + r.Equal(editedTemplate, client.plannedSource) + r.Equal("production", client.targetAskedIn) + r.Equal("production", client.askedNamespace) + r.Zero(client.submitted) + r.Contains(plain(m.render()), "Plan (Job: web)") + + // A job that does not exist yet is submitted at index 0. + m, cmd = m.update(key('y')) + drain(m, cmd) + + r.Equal(1, client.submitted) + r.Equal(editedTemplate, client.submittedSource) + r.Equal("production", client.askedNamespace) + r.Zero(client.submittedIndex) +} + +func TestNewJob_GoesToTheNamespaceTheFileNames(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, + target: nomad.JobTarget{ID: "web", Namespace: "staging", Type: "service"}} + m := plannedNew(t, client, "production") + + r.Equal("staging", client.askedNamespace) + + m, cmd := m.update(key('y')) + drain(m, cmd) + + r.Equal("staging", client.askedNamespace) + r.Equal(1, client.submitted) +} + +func TestNewJob_FromAllNamespacesGoesToTheOneTheClientDecided(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, + target: nomad.JobTarget{ID: "web", Namespace: nomad.DefaultNamespace, Type: "service"}} + m := plannedNew(t, client, "") + + r.Equal("", client.targetAskedIn) + r.Equal(nomad.DefaultNamespace, client.askedNamespace) + + m, cmd := m.update(key('y')) + drain(m, cmd) + + r.Equal(nomad.DefaultNamespace, client.askedNamespace) +} + +func TestNewJob_ThatExistsIsNotSent(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: changedEnv(), target: nomad.JobTarget{ID: "web", Namespace: "production", Type: "service"}} + m := plannedNew(t, client, "production") + + out := plain(m.render()) + r.Contains(out, "A job named web exists already: a new one cannot replace it.") + r.NotContains(out, "Changed after the file was read") + + line, _ := buttons(m) + r.Contains(line, "Can't submit: job exists") + r.False(offers(m, "y")) + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + r.IsType(planPage{}, m.screen.page) + + // The button that sends cannot be chosen, and enter cancels. + m, _ = m.update(tea.KeyPressMsg{Code: tea.KeyTab}) + r.Contains(m.render(), opening(styleButtonOn)+" Cancel ") + + m, cmd = m.update(enter()) + m = drain(m, cmd) + + r.Zero(client.submitted) +} + +func TestNewJob_AReplanKeepsWhereTheFileWasPlanned(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, target: nomad.JobTarget{ID: "web", Namespace: "staging", Type: "service"}} + m := plannedNew(t, client, "production") + + m, cmd := m.update(key('r')) + m = follow(m, cmd, 3) + + r.Equal(2, client.planCalls) + r.Equal("staging", client.askedNamespace) + + m, cmd = m.update(key('y')) + drain(m, cmd) + r.Zero(client.submittedIndex) +} + +func TestNewJob_ReadOnlyOffersNoNew(t *testing.T) { + r := require.New(t) + + m, editor := newJobModel(&fakeClient{jobs: twoJobs()}, "production", editedTemplate) + r.True(offers(m, "n")) + + m.opts.ReadOnly = true + r.False(offers(m, "n")) + + m, cmd := m.update(key('n')) + m = drain(m, cmd) + + r.Contains(plain(m.render()), "read-only: New is off") + r.Empty(editor.seen) +} + +func TestNewJob_AFileTheClusterDoesNotAcceptOpensAgain(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), refusals: []error{errors.New("the job was not accepted")}} + m, editor := newJobModel(client, "production", editedTemplate) + editor.edits = []string{editedTemplate} + + m, cmd := m.update(key('n')) + m = follow(m, cmd, 8) + + // The edit is not lost: the editor opens on it again with the reason. + r.IsType(jobsPage{}, m.screen.page) + r.Len(editor.seen, 2) + r.Contains(editor.seen[1], "Not saved:") + r.Contains(editor.seen[1], editedTemplate) +} + +func TestNewJob_APlanTheClusterRefusesOpensTheEditorAgain(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), target: nomad.JobTarget{ID: "web", Namespace: "staging", Type: "service"}, + refusals: []error{nil, errors.New("namespace staging does not exist")}} + m, editor := newJobModel(client, "production", editedTemplate) + editor.edits = []string{editedTemplate} + + m, cmd := m.update(key('n')) + m = follow(m, cmd, 8) + + r.IsType(jobsPage{}, m.screen.page) + r.Len(editor.seen, 2) + r.Contains(editor.seen[1], "Not saved: namespace staging does not exist.") + r.Contains(editor.seen[1], editedTemplate) +} + +func TestNewJob_TheWatchOpensForAJobThatRunsItself(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, target: nomad.JobTarget{ID: "web", Namespace: "staging", Type: "batch"}} + m := plannedNew(t, client, "production") + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + + r.Equal(deployWatchPage{namespace: "staging", jobID: "web", jobType: "batch"}, m.screen.page) +} + +func TestNewJob_SubmitsAtIndexZeroWhateverThePlanIndexIs(t *testing.T) { + r := require.New(t) + + client := &fakeClient{} + m := New(client, Options{Version: "v-test"}) + + _, out := submitPlan(planPage{planState: planState{fresh: true, index: 7, jobID: "web"}}, m.env()) + out.cmd() + + r.Equal(1, client.submitted) + r.Zero(client.submittedIndex) +} + +func TestNewJob_ThatLaunchesOthersOpensNoWatch(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, + target: nomad.JobTarget{ID: "cron", Namespace: "production", Type: "batch", Launches: true}} + m := plannedNew(t, client, "production") + + m, cmd := m.update(key('y')) + m = drain(m, cmd) + + r.IsType(jobsPage{}, m.screen.page) + r.Contains(plain(m.render()), "Job cron submitted") +} diff --git a/internal/ui/plan.go b/internal/ui/plan.go index bb3c8db..6af01b5 100644 --- a/internal/ui/plan.go +++ b/internal/ui/plan.go @@ -37,10 +37,17 @@ type planState struct { to *uint64 from uint64 + // fresh says the plan is of a new job: it is submitted at index 0, and + // a job of that name already there is not replaced. + fresh bool + // failed says the cluster has no room for what was planned: there is // nothing to send. failed bool + // exists says a new job has the name of one that is there. + exists bool + // choice is the button at the foot of the plan the cursor is on. It // starts on cancel: enter out of habit then sends nothing. choice int @@ -62,6 +69,7 @@ type planPage struct { func planOf(plan nomad.Plan, state planState) planPage { state.index = plan.Index state.failed = len(plan.Failures) > 0 + state.exists = state.fresh && plan.Index != 0 // A revert is planned to a version and from one: the one it goes back // to is known now, and the one the job has is what submitting checks. @@ -71,7 +79,11 @@ func planOf(plan nomad.Plan, state planState) planPage { } lines := planText(plan, state.revert) - if !state.revert && state.read != state.index { + switch { + case state.exists: + lines = append(existsAlready(state.jobID), lines...) + + case !state.revert && state.read != state.index: lines = append(changedSinceRead(state.jobID), lines...) } @@ -90,6 +102,14 @@ func changedSinceRead(jobID string) []paintedLine { } } +// existsAlready warns that a new job has the name of one that is there. +func existsAlready(jobID string) []paintedLine { + return []paintedLine{ + {text: fmt.Sprintf("A job named %s exists already: a new one cannot replace it.", jobID), style: &styleWarn}, + {}, + } +} + // title says whose plan it is, and for a revert, to which version. func (p planPage) title(env, int) string { if p.revert && p.to != nil { @@ -150,12 +170,16 @@ func (p planPage) press(k string, e env) (page, outcome, bool) { return pressOf(p, e, planKeys, k) } -func planOfAnEdit(p planPage, _ env) bool { return !p.revert && !p.failed } +func planOfAnEdit(p planPage, _ env) bool { return !p.revert && !p.refused() } -func planOfARevert(p planPage, _ env) bool { return p.revert && !p.failed } +func planOfARevert(p planPage, _ env) bool { return p.revert && !p.refused() } + +// refused says the plan cannot be sent: the cluster has no room for it, or +// a new job has the name of one that is there. +func (p planPage) refused() bool { return p.failed || p.exists } // sends says there is something to send, and a key to send it with. -func (p planPage) sends(e env) bool { return !p.failed && !e.readOnly } +func (p planPage) sends(e env) bool { return !p.refused() && !e.readOnly } // button moves between the buttons at the foot of a plan, and presses the // one the cursor is on. @@ -196,8 +220,12 @@ func (p planPage) bar(_ env, width int) []string { } send := button(verb, p.choice == buttonConfirm) - if p.failed { + switch { + case p.failed: send = styleButtonOff.Render(fmt.Sprintf(" Can't %s: placement failed ", strings.ToLower(verb))) + + case p.exists: + send = styleButtonOff.Render(fmt.Sprintf(" Can't %s: job exists ", strings.ToLower(verb))) } buttons := button("Cancel", p.choice == buttonCancel) + " " + send @@ -221,6 +249,29 @@ func planFor(client jobsClient, state planState) tea.Cmd { }, func(plan nomad.Plan) tea.Msg { return openMsg{planOf(plan, state)} }) } +// planNew reads where a new job goes, and opens the plan of it there. +func planNew(client jobsClient, namespace, source string) tea.Cmd { + return request(func(ctx context.Context) (planPage, error) { + target, err := client.JobTarget(ctx, namespace, source) + if err != nil { + return planPage{}, err + } + + state := planState{ + namespace: target.Namespace, + jobID: target.ID, + jobType: target.Type, + launches: target.Launches, + source: source, + fresh: true, + } + + plan, err := client.PlanJob(ctx, state.namespace, state.source, state.vars) + + return planOf(plan, state), err + }, func(page planPage) tea.Msg { return openMsg{page} }) +} + // submitPlan sends what was planned: the file at the index it was planned // at, or the revert from the version it was planned from. The result shows // whether or not the plan is still open; when the job changed since the @@ -239,7 +290,7 @@ func submitPlan(p planPage, e env) (planPage, outcome) { said = fmt.Sprintf("Job %s reverted to version %d.", state.jobID, *state.to) err = client.RevertJobTo(ctx, state.namespace, state.jobID, *state.to, state.from) } else { - err = client.SubmitJob(ctx, state.namespace, state.source, state.vars, state.index) + err = client.SubmitJob(ctx, state.namespace, state.source, state.vars, submitIndex(state)) } switch { @@ -257,6 +308,16 @@ func submitPlan(p planPage, e env) (planPage, outcome) { }} } +// submitIndex is the index a plan is submitted at: a new job at 0, which the +// cluster refuses when a job of that name has been made since. +func submitIndex(state planState) uint64 { + if state.fresh { + return 0 + } + + return state.index +} + // replan asks about the same file, or the same version, again. func replan(p planPage, e env) (planPage, outcome) { return p, outcome{cmd: planFor(e.client, p.planState)} diff --git a/internal/ui/roles.go b/internal/ui/roles.go index 67ddbe4..49a0b6d 100644 --- a/internal/ui/roles.go +++ b/internal/ui/roles.go @@ -58,6 +58,7 @@ type ( JobSpec(ctx context.Context, namespace, jobID string) (nomad.JobSource, error) TaskGroups(ctx context.Context, namespace, jobID string) ([]nomad.TaskGroup, error) PlanJob(ctx context.Context, namespace, source string, vars nomad.JobVariables) (nomad.Plan, error) + JobTarget(ctx context.Context, namespace, source string) (nomad.JobTarget, error) PlanRevert(ctx context.Context, namespace, jobID string, to *uint64) (nomad.Plan, error) SubmitJob(ctx context.Context, namespace, source string, vars nomad.JobVariables, index uint64) error StartJob(ctx context.Context, namespace, jobID string) error diff --git a/internal/ui/writes_test.go b/internal/ui/writes_test.go index 089886b..f090bed 100644 --- a/internal/ui/writes_test.go +++ b/internal/ui/writes_test.go @@ -32,7 +32,7 @@ var ( clientReads = []string{ "Address", "Region", "Agent", "Regions", "Datacenters", "AllocationChecks", "Files", "File", "Deployment", "JobDeployments", "DeploymentAllocations", "Token", "ServiceInstances", - "Jobs", "NextLaunch", "JobSpec", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", + "Jobs", "NextLaunch", "JobSpec", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", "JobTarget", "DescribeJob", "DescribeAllocation", "DescribeDeployment", "DescribeService", "Allocations", "NodeAllocations", "Allocation", "Logs", "Usage", "AllocationUsage", "TaskUsage", "NodeUsage", From e6de26c4010f6e11c421e4e47f2a7f661a602c7b Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Fri, 16 Oct 2026 14:46:31 +0000 Subject: [PATCH 2/2] feat(ui): plan and run a job from a local file with :run --- docs/jobs.md | 7 ++ docs/keys.md | 1 + internal/ui/command.go | 19 ++++- internal/ui/command_test.go | 22 ++++- internal/ui/overlay.go | 4 + internal/ui/plan.go | 35 ++++---- internal/ui/run.go | 62 +++++++++++++++ internal/ui/run_test.go | 155 ++++++++++++++++++++++++++++++++++++ 8 files changed, 284 insertions(+), 21 deletions(-) create mode 100644 internal/ui/run.go create mode 100644 internal/ui/run_test.go diff --git a/docs/jobs.md b/docs/jobs.md index 1729787..6c954b5 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -128,6 +128,13 @@ at all of them. A new job never replaces one. If a job with the same name exists, the plan says so and offers no submit. +`:run web.nomad.hcl` does the same for a job file on your disk: HCL, or JSON +when the file starts with `{`. The rest of the line is the path, spaces +included. A path that starts with `~/` is in your home directory, a relative +one is from where urga runs. The file opens as a plan, and `y` submits it. +A file that cannot be read says why, and the screen stays. In read-only mode +`:run` is off. + ## The plan The plan shows, from top to bottom: diff --git a/docs/keys.md b/docs/keys.md index d745365..4a70c09 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -56,6 +56,7 @@ Other commands: | `ctx` | List the clusters and pick one. | | `find web` | Find anything in the cluster by its name or ID. `search` works too. See [Find](#find). | | `gc` | Collect the garbage of the cluster now, after you confirm: dead jobs, evaluations and allocations that ended, and clients that are down. Not in read-only mode. | +| `run web.nomad.hcl` | Plan a job file from your disk as a new job; `y` submits it. Not in read-only mode. See [Creating a job](jobs.md#creating-a-job). | | `q`, `quit`, `exit` | Quit. | In a list of regions, datacenters or clusters, the current one is marked. diff --git a/internal/ui/command.go b/internal/ui/command.go index 78a0d6b..af7190e 100644 --- a/internal/ui/command.go +++ b/internal/ui/command.go @@ -74,6 +74,9 @@ var findNames = []string{"find", "search"} // last. var gcNames = []string{"gc"} +// runNames are the words that plan a job file, offered after the others. +var runNames = []string{"run"} + var scopeAliases = map[string]scope{ "ctx": scopeCluster, "cluster": scopeCluster, @@ -97,6 +100,9 @@ type command struct { // collecting says the line collects the garbage of the cluster. collecting bool + + // running says the line plans a job file, text is its path. + running bool } // parseCommand reads " [namespace]", for example "jobs production", @@ -119,11 +125,16 @@ func parseCommand(input string) (command, bool) { return command{bail: true}, true } - // What to find is the rest of the line, spaces and all. + // What to find, and the path to run, is the rest of the line, spaces and + // all. + rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(input), fields[0])) + if slices.Contains(findNames, word) { - rest := strings.TrimPrefix(strings.TrimSpace(input), fields[0]) + return command{finding: true, text: rest}, true + } - return command{finding: true, text: strings.TrimSpace(rest)}, true + if slices.Contains(runNames, word) { + return command{running: true, text: rest}, true } if slices.Contains(gcNames, word) { @@ -200,7 +211,7 @@ func matchingCommands(typed string) []string { matches = append(matches, nameOf(named)) } - for _, name := range slices.Concat(commandNames, scopeNames, findNames[:1], gcNames) { + for _, name := range slices.Concat(commandNames, scopeNames, findNames[:1], gcNames, runNames) { if strings.HasPrefix(name, word) && !slices.Contains(matches, name) { matches = append(matches, name) } diff --git a/internal/ui/command_test.go b/internal/ui/command_test.go index ab99220..247507a 100644 --- a/internal/ui/command_test.go +++ b/internal/ui/command_test.go @@ -77,7 +77,7 @@ func TestMatchingCommands(t *testing.T) { r.Equal([]string{ "about", "allocations", "authmethods", "bindingrules", "clients", "deployments", "evaluations", "events", "jobs", "namespaces", "nodepools", "overview", "plugins", "policies", "roles", - "scaling", "scheduler", "servers", "services", "tokens", "variables", "volumes", "ctx", "dc", "region", "find", "gc", + "scaling", "scheduler", "servers", "services", "tokens", "variables", "volumes", "ctx", "dc", "region", "find", "gc", "run", }, matchingCommands("")) // A word that is an alias of its own comes first, whatever other names @@ -137,7 +137,8 @@ func TestMatchingCommands_OfferTheSwitchesAfterTheResources(t *testing.T) { r.Equal([]string{"deployments", "dc"}, matchingCommands("d")) r.Equal([]string{"dc"}, matchingCommands("dc")) - r.Equal([]string{"roles", "region"}, matchingCommands("r")) + r.Equal([]string{"roles", "region", "run"}, matchingCommands("r")) + r.Equal([]string{"run"}, matchingCommands("ru")) r.Equal([]string{"region"}, matchingCommands("region eu")) } @@ -161,3 +162,20 @@ func TestParseCommand_Find(t *testing.T) { r.Equal([]string{"find"}, matchingCommands("f")) } + +func TestParseCommand_Run(t *testing.T) { + r := require.New(t) + + // The rest of the line is the path, spaces and all. + for input, path := range map[string]string{ + "run web.nomad.hcl": "web.nomad.hcl", + " run my jobs/a.hcl ": "my jobs/a.hcl", + "RUN ~/web.hcl": "~/web.hcl", + "run": "", + } { + cmd, ok := parseCommand(input) + r.True(ok, input) + r.True(cmd.running, input) + r.Equal(path, cmd.text, input) + } +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 7ecac9f..03b038b 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -333,6 +333,10 @@ func (m Model) commit() (Model, tea.Cmd) { return m.collectGarbage() } + if cmd.running { + return m.run(cmd.text) + } + switch cmd.switching { case scopeRegion: return m.regionCommand(cmd.name) diff --git a/internal/ui/plan.go b/internal/ui/plan.go index 6af01b5..7a18649 100644 --- a/internal/ui/plan.go +++ b/internal/ui/plan.go @@ -252,24 +252,29 @@ func planFor(client jobsClient, state planState) tea.Cmd { // planNew reads where a new job goes, and opens the plan of it there. func planNew(client jobsClient, namespace, source string) tea.Cmd { return request(func(ctx context.Context) (planPage, error) { - target, err := client.JobTarget(ctx, namespace, source) - if err != nil { - return planPage{}, err - } + return planNewJob(ctx, client, namespace, source) + }, func(page planPage) tea.Msg { return openMsg{page} }) +} - state := planState{ - namespace: target.Namespace, - jobID: target.ID, - jobType: target.Type, - launches: target.Launches, - source: source, - fresh: true, - } +// planNewJob plans a job file as a new job, in the namespace it names. +func planNewJob(ctx context.Context, client jobsClient, namespace, source string) (planPage, error) { + target, err := client.JobTarget(ctx, namespace, source) + if err != nil { + return planPage{}, err + } + + state := planState{ + namespace: target.Namespace, + jobID: target.ID, + jobType: target.Type, + launches: target.Launches, + source: source, + fresh: true, + } - plan, err := client.PlanJob(ctx, state.namespace, state.source, state.vars) + plan, err := client.PlanJob(ctx, state.namespace, state.source, state.vars) - return planOf(plan, state), err - }, func(page planPage) tea.Msg { return openMsg{page} }) + return planOf(plan, state), err } // submitPlan sends what was planned: the file at the index it was planned diff --git a/internal/ui/run.go b/internal/ui/run.go new file mode 100644 index 0000000..d116cc4 --- /dev/null +++ b/internal/ui/run.go @@ -0,0 +1,62 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// run reads a job file and opens the plan of it as a new job. +func (m Model) run(path string) (Model, tea.Cmd) { + switch { + case m.opts.ReadOnly: + return m.warn("read-only: :run is off"), nil + + case path == "": + return m.warn(":run needs a job file"), nil + } + + client, namespace := m.client, m.namespace + + return m, request(func(ctx context.Context) (planPage, error) { + source, err := readJobFile(path) + if err != nil { + return planPage{}, err + } + + return planNewJob(ctx, client, namespace, source) + }, func(page planPage) tea.Msg { return openMsg{page} }) +} + +// readJobFile reads the file at path, which can start with ~/ for the home +// directory. +func readJobFile(path string) (string, error) { + full := path + + if rest, ok := strings.CutPrefix(path, "~/"); ok { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot read %s: %w", path, err) + } + + full = filepath.Join(home, rest) + } + + content, err := os.ReadFile(full) + if err != nil { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + err = pathErr.Err + } + + return "", fmt.Errorf("cannot read %s: %w", path, err) + } + + return string(content), nil +} diff --git a/internal/ui/run_test.go b/internal/ui/run_test.go new file mode 100644 index 0000000..d4bee22 --- /dev/null +++ b/internal/ui/run_test.go @@ -0,0 +1,155 @@ +package ui + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// commandLine types a command line, runs it and follows what it asks for. +func commandLine(m Model, line string) Model { + m, _ = m.update(key(':')) + m = typeIn(m, line) + m, cmd := m.update(enter()) + + return follow(m, cmd, 4) +} + +// runModel is the job list of a session in production, with a client that +// plans a new job called web. +func runModel(readOnly bool) (Model, *fakeClient) { + client := &fakeClient{jobs: twoJobs(), plan: nomad.Plan{Index: 0}, + target: nomad.JobTarget{ID: "web", Namespace: "production", Type: "service"}} + m, _ := newJobModel(client, "production", "") + m.opts.ReadOnly = readOnly + + return m, client +} + +func TestRun_PlansTheFileAndSubmitsItAtIndexZero(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + r.NoError(os.WriteFile("web.nomad.hcl", []byte(nomad.JobTemplate), 0o600)) + + m, client := runModel(false) + m = commandLine(m, "run web.nomad.hcl") + + r.IsType(planPage{}, m.screen.page) + r.Equal(nomad.JobTemplate, client.plannedSource) + r.Equal("production", client.askedNamespace) + r.Equal("production", client.targetAskedIn) + + m, cmd := m.update(key('y')) + drain(m, cmd) + + r.Equal(1, client.submitted) + r.Equal(nomad.JobTemplate, client.submittedSource) + r.Zero(client.submittedIndex) +} + +func TestRun_ThatExistsIsNotSent(t *testing.T) { + r := require.New(t) + + t.Chdir(t.TempDir()) + r.NoError(os.WriteFile("web.nomad.hcl", []byte(nomad.JobTemplate), 0o600)) + + m, client := runModel(false) + client.plan = nomad.Plan{Index: 7} + m = commandLine(m, "run web.nomad.hcl") + + r.IsType(planPage{}, m.screen.page) + r.Contains(plain(m.render()), "A job named web exists already: a new one cannot replace it.") + r.False(offers(m, "y")) +} + +func TestRun_TheFileGoesToTheNamespaceItNames(t *testing.T) { + r := require.New(t) + + t.Chdir(t.TempDir()) + r.NoError(os.WriteFile("web.nomad.hcl", []byte(nomad.JobTemplate), 0o600)) + + m, client := runModel(false) + client.target.Namespace = "staging" + commandLine(m, "run web.nomad.hcl") + + r.Equal("staging", client.askedNamespace) +} + +func TestRun_ExpandsTheHomeDirectory(t *testing.T) { + r := require.New(t) + + home := t.TempDir() + // The home directory, where each system reads it from: Windows from + // USERPROFILE, the others from HOME. + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + r.NoError(os.WriteFile(filepath.Join(home, "web.nomad.hcl"), []byte(nomad.JobTemplate), 0o600)) + + m, client := runModel(false) + m = commandLine(m, "run ~/web.nomad.hcl") + + r.IsType(planPage{}, m.screen.page) + r.Equal(nomad.JobTemplate, client.plannedSource) +} + +func TestRun_SaysWhyItPlansNothing(t *testing.T) { + t.Run("a file that is not there", func(t *testing.T) { + r := require.New(t) + + t.Chdir(t.TempDir()) + + m, client := runModel(false) + m = commandLine(m, "run web.nomad.hcl") + + r.IsType(jobsPage{}, m.screen.page) + r.Contains(plain(m.render()), "cannot read web.nomad.hcl: "+syscall.ENOENT.Error()) + r.Zero(client.planCalls) + }) + + t.Run("no home directory", func(t *testing.T) { + r := require.New(t) + + t.Setenv("HOME", "") + t.Setenv("USERPROFILE", "") + + m, client := runModel(false) + m = commandLine(m, "run ~/web.nomad.hcl") + + r.IsType(jobsPage{}, m.screen.page) + r.Contains(plain(m.render()), "cannot read ~/web.nomad.hcl:") + r.Zero(client.planCalls) + }) + + t.Run("no path", func(t *testing.T) { + r := require.New(t) + + m, client := runModel(false) + m = commandLine(m, "run") + + r.IsType(jobsPage{}, m.screen.page) + r.Contains(plain(m.render()), ":run needs a job file") + r.Zero(client.planCalls) + }) + + t.Run("read-only mode", func(t *testing.T) { + r := require.New(t) + + t.Chdir(t.TempDir()) + r.NoError(os.WriteFile("web.nomad.hcl", []byte(nomad.JobTemplate), 0o600)) + + m, client := runModel(true) + m = commandLine(m, "run web.nomad.hcl") + + r.IsType(jobsPage{}, m.screen.page) + r.Contains(plain(m.render()), "read-only: :run is off") + r.Zero(client.planCalls) + r.Empty(client.plannedSource) + }) +}