Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for deploying a pre-rendered jobspec. #445

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
87 changes: 74 additions & 13 deletions command/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package command

import (
"fmt"
"os"
"strings"

"github.com/hashicorp/levant/helper"
Expand Down Expand Up @@ -33,6 +34,9 @@ Arguments:

TEMPLATE nomad job template
If no argument is given we look for a single *.nomad file
Note that the -pre-rendered flag changes the expectation for this arg
(See below).


General Options:

Expand Down Expand Up @@ -84,6 +88,12 @@ General Options:
Specify the format of Levant's logs. Valid values are HUMAN or JSON. The
default is HUMAN.

-pre-rendered
When used, TEMPLATE arg must instead specify a .json file, which must
contain a pre-rendered jobspec.
Alternatively you can omit the TEMPLATE arg and pipe JSON to levant deploy.
Not compatible with -var, -var-file, -vault, -vault-token, -consul-address.

-var-file=<file>
Used in conjunction with the -job-file will deploy a templated job to your
Nomad cluster. You can repeat this flag multiple times to supply multiple var-files.
Expand All @@ -102,6 +112,7 @@ func (c *DeployCommand) Run(args []string) int {

var err error
var level, format string
var preRendered bool

config := &levant.DeployConfig{
Client: &structs.ClientConfig{},
Expand All @@ -123,12 +134,14 @@ func (c *DeployCommand) Run(args []string) int {
flags.BoolVar(&config.Plan.IgnoreNoChanges, "ignore-no-changes", false, "")
flags.StringVar(&level, "log-level", "INFO", "")
flags.StringVar(&format, "log-format", "HUMAN", "")
flags.BoolVar(&preRendered, "pre-rendered", false, "")
flags.StringVar(&config.Deploy.VaultToken, "vault-token", "", "")
flags.BoolVar(&config.Deploy.EnvVault, "vault", false, "")

flags.Var((*helper.FlagStringSlice)(&config.Template.VariableFiles), "var-file", "")

if err = flags.Parse(args); err != nil {
c.UI.Error("ERROR: CLI flag parsing error.")
return 1
}

Expand All @@ -145,24 +158,72 @@ func (c *DeployCommand) Run(args []string) int {
return 1
}

if len(args) == 1 {
config.Template.TemplateFile = args[0]
} else if len(args) == 0 {
if config.Template.TemplateFile = helper.GetDefaultTmplFile(); config.Template.TemplateFile == "" {
if preRendered {
if config.Deploy.EnvVault ||
config.Client.ConsulAddr != "" ||
config.Deploy.VaultToken != "" ||
len(c.Meta.flagVars) > 0 ||
len(config.Template.VariableFiles) > 0 {

c.UI.Error(c.Help())
c.UI.Error("\nERROR: Template arg missing and no default template found")
c.UI.Error("\nERROR: -pre-rendered cannot be used with -consul-addr, -var, -var-file, -vault, or -vault-token.")
return 1
}

isPipedInput, err := helper.IsPipedInput()
if err != nil {
c.UI.Error(err.Error())
return 1
}

if len(args) == 1 {
if isPipedInput {
c.UI.Error(c.Help())
c.UI.Error("\nERROR: Cannot pass rendered JSON file and piped input at the same time.")
return 1
}
jobFile := args[0]
config.Template.Job, err = helper.GetJobspecFromFile(jobFile)

} else if len(args) == 0 {
if isPipedInput {
config.Template.Job, err = helper.GetJobspecFromIOReader(os.Stdin)
} else {
c.UI.Error(c.Help())
c.UI.Error("\nERROR: No rendered JSON file specified and no piped input found.")
return 1
}

} else {
c.UI.Error(c.Help())
return 1
}

if err != nil {
c.UI.Error(fmt.Sprintf("[ERROR] levant/command: %v", err))
return 1
}

} else {
c.UI.Error(c.Help())
return 1
}
if len(args) == 1 {
config.Template.TemplateFile = args[0]
} else if len(args) == 0 {
if config.Template.TemplateFile = helper.GetDefaultTmplFile(); config.Template.TemplateFile == "" {
c.UI.Error(c.Help())
c.UI.Error("\nERROR: Template arg missing and no default template found")
return 1
}
} else {
c.UI.Error(c.Help())
return 1
}

config.Template.Job, err = template.RenderJob(config.Template.TemplateFile,
config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars)
if err != nil {
c.UI.Error(fmt.Sprintf("[ERROR] levant/command: %v", err))
return 1
config.Template.Job, err = template.RenderJob(config.Template.TemplateFile,
config.Template.VariableFiles, config.Client.ConsulAddr, &c.Meta.flagVars)
if err != nil {
c.UI.Error(fmt.Sprintf("[ERROR] levant/command: %v", err))
return 1
}
}

if config.Deploy.Canary > 0 {
Expand Down
8 changes: 8 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ Levant supports a number of command line arguments which provide control over th

* **-log-format** (string: "HUMAN") Specify the format of Levant's logs. Valid values are HUMAN or JSON

* **-pre-rendered** (bool: "false") Pass a pre-rendered jobspec to levant instead of a template file. TEMPLATE arg must instead refer to a JSON file containing valid jobspec JSON. Alternatively you can omit the TEMPLATE arg and pipe JSON directly to levant deploy. **Not compatible with -var, -var-file, -vault, -vault-token, -consul-address**. See full example below.

* **-var-file** (string: "") The variables file to render the template with. This flag can be specified multiple times to supply multiple variables files.

* **-vault** (bool: false) This flag makes Levant load the Vault token from the current ENV. It can not be used at the same time as the `vault-token` flag.
Expand All @@ -40,6 +42,12 @@ Full example:
levant deploy -log-level=debug -address=nomad.devoops -var-file=var.yaml -var 'var=test' example.nomad
```

Example of -pre-rendered, using nomad/HCL2 to render and levant to deploy:

```
nomad job run -output -var-file=vars/dev.hcl job.hcl | levant deploy -pre-rendered -log-level=DEBUG -force-count
```

### Dispatch: `dispatch`

`dispatch` allows you to dispatch an instance of a Nomad parameterized job and utilise Levant's advanced job checking features to ensure the job reaches the correct running state.
Expand Down
63 changes: 63 additions & 0 deletions helper/files.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
package helper

import (
"bufio"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"

nomad "github.com/hashicorp/nomad/api"
"github.com/rs/zerolog/log"
)

// JobJSON is used to unmarshal pre-rendered/parsed jobspec JSON
type JobJSON struct {
Job nomad.Job `json:"Job"`
}

// GetDefaultTmplFile checks the current working directory for *.nomad files.
// If only 1 is found we return the match.
func GetDefaultTmplFile() (templateFile string) {
Expand Down Expand Up @@ -42,3 +53,55 @@ func GetDefaultVarFile() (varFile string) {
log.Debug().Msg("helper/files: no default var-file found")
return ""
}

// GetJobspecFromBytes converts JSON passed as bytes to a nomad job the same
// as levant's RenderTemplate would return.
func GetJobspecFromBytes(src []byte) (job *nomad.Job, err error) {
var jobspec JobJSON

err = json.Unmarshal(src, &jobspec)
if err != nil {
err = fmt.Errorf("helper/files: error parsing JSON: %w", err)
}

return &jobspec.Job, err
}

// GetJobspecFromFile converts a JSON file to a nomad job the same as levant's
// RenderTemplate would return.
func GetJobspecFromFile(jobFile string) (job *nomad.Job, err error) {
src, err := ioutil.ReadFile(jobFile)
if err != nil {
return nil, err
}

return GetJobspecFromBytes(src)
}

// IsPipedInput determines if there is piped input to stdin.
func IsPipedInput() (isPiped bool, err error) {
info, err := os.Stdin.Stat()
if err != nil {
return false, err
}

return info.Mode()&os.ModeCharDevice == 0, err
}

// getJobspecFromIOReader reads bytes from a Reader and returns a nomad Job.
// Intended for os.Stdin but can be tested with any io.Reader.
// JSON must be valid and conform to the nomad.api.Job struct.
func GetJobspecFromIOReader(r io.Reader) (job *nomad.Job, err error) {
var runes []rune
reader := bufio.NewReader(r)

for {
input, _, err := reader.ReadRune()
if err != nil && err == io.EOF {
break
}
runes = append(runes, input)
}

return GetJobspecFromBytes([]byte(string(runes)))
}
48 changes: 48 additions & 0 deletions helper/files_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package helper

import (
"bytes"
"io/ioutil"
"os"
"reflect"
Expand Down Expand Up @@ -81,3 +82,50 @@ func TestHelper_GetDefaultVarFile(t *testing.T) {
os.Remove(tc.VarFile)
}
}

func TestHelper_GetJobspecFromFile(t *testing.T) {
cases := []struct {
JobFile string
JobName string
}{
{"test-fixtures/demojob.json", "demojob"},
}

for i, tc := range cases {
job, err := GetJobspecFromFile(tc.JobFile)
if err != nil {
t.Fatalf("case %d failed: %v", i, err)
}

if !reflect.DeepEqual(*job.Name, tc.JobName) {
t.Fatalf("got: %#v, expected %#v", *job.Name, tc.JobName)
}

}
}

func TestHelper_GetJobspecFromIOReader(t *testing.T) {
cases := []struct {
JobFile string
JobName string
}{
{"test-fixtures/demojob.json", "demojob"},
}

for i, tc := range cases {
src, err := ioutil.ReadFile(tc.JobFile)
if err != nil {
t.Fatalf("case %d failed: %v", i, err)
}

job, err := GetJobspecFromIOReader(bytes.NewBuffer(src))
if err != nil {
t.Fatalf("case %d failed: %v", i, err)
}

if !reflect.DeepEqual(*job.Name, tc.JobName) {
t.Fatalf("got: %#v, expected %#v", *job.Name, tc.JobName)
}

}
}
Loading