Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ 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.

`: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:
Expand Down
2 changes: 2 additions & 0 deletions docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -216,6 +217,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 |
Expand Down
2 changes: 1 addition & 1 deletion internal/nomad/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
81 changes: 77 additions & 4 deletions internal/nomad/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
125 changes: 125 additions & 0 deletions internal/nomad/target_test.go
Original file line number Diff line number Diff line change
@@ -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, "#")
}
8 changes: 8 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type fakeClient struct {
submittedIndex uint64

plan nomad.Plan
target nomad.JobTarget
targetAskedIn string
planCalls int
plannedSource string
plannedVars nomad.JobVariables
Expand Down Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions internal/ui/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 "<resource> [namespace]", for example "jobs production",
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down
22 changes: 20 additions & 2 deletions internal/ui/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}

Expand All @@ -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)
}
}
Loading
Loading