Skip to content

Commit

Permalink
Add new fields to jobfile, better validation, unit tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
gnutix committed Oct 31, 2023
1 parent 72d1c3d commit 6a6aaaa
Show file tree
Hide file tree
Showing 25 changed files with 417 additions and 97 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
name: CI
on: [push]
jobs:
tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.21.x'
- name: Client tests
run: make test
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ proto:
.PHONY: run-server
run-server: proto
reflex -s -t 120s -r '\.go' -- bash -c "make bin/alfred-server && bin/alfred-server"

.PHONY: test
test:
go test -v ./...
65 changes: 63 additions & 2 deletions client/job/jobfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ package job

import (
"fmt"
"os"
"path"
"regexp"
"strconv"
"time"
)

const JobfileVersion = "1"

type Jobfile struct {
Path string
Version string
Name string
Image JobfileImage
Expand All @@ -27,7 +33,10 @@ type JobfileService struct {
}

type JobfileServiceHealth struct {
Cmd []string
Cmd []string
Timeout string
Interval string
Retries string
}

func (jobfile Jobfile) Validate() error {
Expand All @@ -36,16 +45,68 @@ func (jobfile Jobfile) Validate() error {
}

if jobfile.Name == "" {
return fmt.Errorf("job name is required")
return fmt.Errorf("name is required")
}

if jobfile.Image.Dockerfile == "" {
return fmt.Errorf("image.dockerfile is required")
}
if _, err := os.Stat(path.Join(jobfile.Path, jobfile.Image.Dockerfile)); os.IsNotExist(err) {
return fmt.Errorf("image.dockerfile must be an existing file on disk")
}

if jobfile.Image.Context == "" {
return fmt.Errorf("image.context is required")
}
if _, err := os.Stat(path.Join(jobfile.Path, jobfile.Image.Context)); os.IsNotExist(err) {
return fmt.Errorf("image.context must be an existing folder on disk")
}

serviceNameRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]+$`)
serviceEnvKeyRegex := regexp.MustCompile(`^[A-Z][A-Z0-9_]+$`)

for name, service := range jobfile.Services {
if !serviceNameRegex.MatchString(name) {
return fmt.Errorf("services names must be valid identifiers")
}

if service.Image == "" {
return fmt.Errorf("services[%s].image is required", name)
}

for key, _ := range service.Env {
if !serviceEnvKeyRegex.MatchString(key) {
return fmt.Errorf("services[%s].env[%s] must be a valid environment variable identifier", name, key)
}
}

// If none of the health fields are specified, skip validation
if service.Health.Cmd == nil && service.Health.Timeout == "" && service.Health.Interval == "" && service.Health.Retries == "" {
continue
}

if len(service.Health.Cmd) < 1 {
return fmt.Errorf("services[%s].health.cmd is required", name)
}

if service.Health.Interval != "" {
if _, err := time.ParseDuration(service.Health.Interval); err != nil {
return fmt.Errorf("services[%s].health.interval is not a valid duration: %w", name, err)
}
}

if service.Health.Timeout != "" {
if _, err := time.ParseDuration(service.Health.Timeout); err != nil {
return fmt.Errorf("services[%s].health.timeout is not a valid duration: %w", name, err)
}
}

if service.Health.Retries != "" {
if _, err := strconv.ParseInt(service.Health.Retries, 10, 64); err != nil {
return fmt.Errorf("services[%s].health.retries is not a valid numeric: %w", name, err)
}
}
}

return nil
}
55 changes: 55 additions & 0 deletions client/job/jobfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package job

import (
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"os"
"path"
"testing"
)

var flagtests = []struct {
file string
expected string
}{
{"tests/jobfile/valid_minimalist.yaml", ""},
{"tests/jobfile/valid_full_featured.yaml", ""},

{"tests/jobfile/invalid_missing_name.yaml", "name is required"},
{"tests/jobfile/invalid_missing_image_dockerfile.yaml", "image.dockerfile is required"},
{"tests/jobfile/invalid_missing_image_context.yaml", "image.context is required"},
{"tests/jobfile/invalid_missing_services_image.yaml", "services[mysql].image is required"},
{"tests/jobfile/invalid_missing_services_health_cmd.yaml", "services[mysql].health.cmd is required"},

{"tests/jobfile/invalid_version.yaml", "unsupported version '42'"},
{"tests/jobfile/invalid_image_dockerfile.yaml", "image.dockerfile must be an existing file on disk"},
{"tests/jobfile/invalid_image_context.yaml", "image.context must be an existing folder on disk"},
{"tests/jobfile/invalid_services_map.yaml", "yaml: unmarshal errors:\n line 7: cannot unmarshal !!seq into map[string]job.JobfileService"},
{"tests/jobfile/invalid_services_keys.yaml", "services names must be valid identifiers"},
{"tests/jobfile/invalid_services_env_keys.yaml", "services[mysql].env[0/2] must be a valid environment variable identifier"},
}

func TestJobValidate(t *testing.T) {
var buf []byte
var err error

for _, tt := range flagtests {
t.Run(tt.file, func(t *testing.T) {
buf = lo.Must(os.ReadFile(tt.file))

var jobfile Jobfile
if err = yaml.Unmarshal(buf, &jobfile); err != nil {
assert.Equal(t, tt.expected, err.Error())
return
}
jobfile.Path = path.Dir(tt.file)
if err = jobfile.Validate(); err != nil {
assert.Equal(t, tt.expected, err.Error())
return
}

assert.Equal(t, "", tt.expected)
})
}
}
28 changes: 22 additions & 6 deletions client/job/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ package job

import (
"fmt"
"github.com/gammadia/alfred/proto"
"github.com/samber/lo"
"google.golang.org/protobuf/types/known/durationpb"
"gopkg.in/yaml.v3"
"os"
"os/exec"
"path"
"strconv"
"strings"

"github.com/gammadia/alfred/proto"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
"time"
)

func Read(p string, overrides Overrides) (job *proto.Job, err error) {
Expand All @@ -28,6 +30,7 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) {
err = fmt.Errorf("unmarshal: %w", err)
return
}
jobfile.Path = path.Dir(p)
if err = jobfile.Validate(); err != nil {
err = fmt.Errorf("validate: %w", err)
return
Expand Down Expand Up @@ -62,7 +65,7 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) {

// Services
for name, service := range jobfile.Services {
job.Services = append(job.Services, &proto.Job_Service{
jobService := &proto.Job_Service{
Name: name,
Image: service.Image,
Env: lo.MapToSlice(service.Env, func(key string, value string) *proto.Job_Env {
Expand All @@ -75,7 +78,20 @@ func Read(p string, overrides Overrides) (job *proto.Job, err error) {
Cmd: service.Health.Cmd[0],
Args: service.Health.Cmd[1:],
}),
})
}

if service.Health.Timeout != "" {
jobService.Health.Timeout = durationpb.New(lo.Must(time.ParseDuration(service.Health.Timeout)))
}
if service.Health.Interval != "" {
jobService.Health.Interval = durationpb.New(lo.Must(time.ParseDuration(service.Health.Interval)))
}
if service.Health.Retries != "" {
retries := lo.Must(strconv.ParseUint(service.Health.Retries, 10, 64))
jobService.Health.Retries = &retries
}

job.Services = append(job.Services, jobService)
}

return
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions client/job/tests/jobfile/invalid_image_context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "./toto"
5 changes: 5 additions & 0 deletions client/job/tests/jobfile/invalid_image_dockerfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "1"
name: "test"
image:
dockerfile: "toto.dockerfile"
context: "."
4 changes: 4 additions & 0 deletions client/job/tests/jobfile/invalid_missing_image_context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: "1"
name: "test"
image:
context: "."
4 changes: 4 additions & 0 deletions client/job/tests/jobfile/invalid_missing_name.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: "1"
image:
dockerfile: "a.dockerfile"
context: "."
10 changes: 10 additions & 0 deletions client/job/tests/jobfile/invalid_missing_services_health_cmd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
mysql:
image: mysql
health:
retries: 1
9 changes: 9 additions & 0 deletions client/job/tests/jobfile/invalid_missing_services_image.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
mysql:
env:
TOTO: toto
10 changes: 10 additions & 0 deletions client/job/tests/jobfile/invalid_services_env_keys.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
mysql:
image: mysql
env:
0/2: foo
10 changes: 10 additions & 0 deletions client/job/tests/jobfile/invalid_services_health_timeout.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
mysql:
image: mysql
health:
timeout:
8 changes: 8 additions & 0 deletions client/job/tests/jobfile/invalid_services_keys.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
0/2:
image: mysql
8 changes: 8 additions & 0 deletions client/job/tests/jobfile/invalid_services_map.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
- mysql
- redis
5 changes: 5 additions & 0 deletions client/job/tests/jobfile/invalid_version.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "42"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
24 changes: 24 additions & 0 deletions client/job/tests/jobfile/valid_full_featured.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
services:
redis:
image: "redis:7.0.5-alpine"
health:
cmd: ["redis-cli", "ping"]
timeout: "5s"
wait: "1s"
retries: 10
mysql:
image: "mysql:8.0.31"
env:
MYSQL_ROOT_PASSWORD: "root"
health:
cmd: ["mysqladmin", "-u", "root", "-proot", "-h", "127.0.0.1", "ping"]
timeout: "10s"
interval: "2s"
retries: 20
tasks: |
sloth instance --app tipee list -o json | jq -r '.[] | select(.features.tests) | .id' | tail -n 1
5 changes: 5 additions & 0 deletions client/job/tests/jobfile/valid_minimalist.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
version: "1"
name: "test"
image:
dockerfile: "a.dockerfile"
context: "."
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.17.0
github.com/stretchr/testify v1.8.4
golang.org/x/crypto v0.14.0
google.golang.org/grpc v1.59.0
google.golang.org/protobuf v1.31.0
Expand All @@ -21,6 +22,7 @@ require (

require (
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.5.0 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-connections v0.4.0 // indirect
Expand All @@ -44,6 +46,7 @@ require (
github.com/opencontainers/image-spec v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/sagikazarmark/locafero v0.3.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
Expand Down
Loading

0 comments on commit 6a6aaaa

Please sign in to comment.