diff --git a/.goreleaser.yml b/.goreleaser.yml index 7a219b8..ba9ca56 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -11,7 +11,7 @@ builds: binary: ansible-requirements-lint ldflags: - -s -w - - -X main.version={{.Version}} + - -X main.version={{ .Version }} goos: - darwin - linux @@ -39,7 +39,7 @@ checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ .Tag }}-next" + name_template: "{{ .Version }}-next" changelog: sort: asc diff --git a/Makefile b/Makefile index 9bd02ca..39448a0 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ VERSION := $(shell git describe $(git rev-list --tags --max-count=1)) GITCOMMIT := $(shell git rev-parse --short HEAD) GITUNTRACKEDCHANGES := $(shell git status --porcelain --untracked-files=no) ifeq ($(VERSION),) - VERSION := v0.0.0 + VERSION := 0.0.0 endif ifneq ($(GITUNTRACKEDCHANGES),) VERSION := $(GITCOMMIT)-dirty diff --git a/README.md b/README.md index 2620ad1..f6770bb 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Given the following `requirements.yml` file in your current working directory ```bash $ cat requirements.yml +--- # Prometheus - name: atosatto.prometheus @@ -45,9 +46,33 @@ $ cat requirements.yml `ansible-requirements-lint` can be used to detect updates to the list of requirements with ```bash -$ bin/ansible-requirements-lint requirements.yml -WARN: atosatto.prometheus: role not at the latest version, upgrade to v1.1.0. -WARN: atosatto.grafana: role not at the latest version, upgrade to v1.1.0. +$ ansible-requirements-lint requirements.yml +WARN: atosatto.prometheus: role not at the latest version, upgrade from v1.0.1 to v1.1.0. +WARN: atosatto.grafana: role not at the latest version, upgrade from v1.0.0 to v1.1.0. +``` + + +In addition to requirements files, `ansible-requirements-lint` can also parse role dependencies +declared in the `meta/main.yml` file in your role directory + +```bash +$ cat meta/main.yml +--- + +dependencies: +- role: atosatto.prometheus + version: v1.0.0 + prometheus_release_tag: "v2.16.0" + +- name: atosatto.alertmanager + version: v1.0.0 +``` + +Running `ansible-requirements-lint` will produce the following results + +```bash +$ ansible-requirements-lint meta/main.yml +WARN: atosatto.prometheus: role not at the latest version, upgrade from v1.0.0 to v1.1.0. ``` ## License diff --git a/cmd/ansible-requirements-lint/main.go b/cmd/ansible-requirements-lint/main.go index e9ab13d..fa3f718 100644 --- a/cmd/ansible-requirements-lint/main.go +++ b/cmd/ansible-requirements-lint/main.go @@ -42,7 +42,7 @@ func main() { // print the version if *printVersion { - errAndExit(fmt.Sprintf("ansible-galaxy-lint %s", version)) + errAndExit(fmt.Sprintf("ansible-galaxy-lint v%s", version)) } if flag.NArg() != 1 || *printHelp { diff --git a/linter/updates.go b/linter/updates.go index 03c7482..7b284c3 100644 --- a/linter/updates.go +++ b/linter/updates.go @@ -153,7 +153,7 @@ func (u *UpdatesLinter) RunWithContext(ctx context.Context, roles <-chan require results <- Result{ Role: r, Level: LevelInfo, - Msg: fmt.Sprintf("%s: the role is already at the latest version", roleName), + Msg: fmt.Sprintf("%s: %s is the latest version for the role, no update needed.", roleName, r.Version), Metadata: Update{FromVersion: latest, ToVersion: latest, IsUpdate: false}, } continue @@ -177,7 +177,7 @@ func (u *UpdatesLinter) RunWithContext(ctx context.Context, roles <-chan require results <- Result{ Role: r, Level: LevelWarning, - Msg: fmt.Sprintf("%s: role not at the latest version, upgrade to %s", roleName, latest), + Msg: fmt.Sprintf("%s: role not at the latest version, upgrade from %s to %s", roleName, r.Version, latest), Metadata: Update{FromVersion: r.Version, ToVersion: latest, IsUpdate: true}, } } diff --git a/linter/updates_test.go b/linter/updates_test.go index aabcef5..94fcf85 100644 --- a/linter/updates_test.go +++ b/linter/updates_test.go @@ -16,7 +16,7 @@ type mockGitProvider struct{} func (g mockGitProvider) VersionsForRole(ctx context.Context, r requirements.Role) ([]string, error) { switch { - case r.Source == "https://github.com/test/galaxy-lint": + case r.Source == "https://github.com/test/ansible-requirements-lint": return []string{"v1.0.0", "v1.1.0"}, nil default: return nil, errMockRoleNotFound @@ -27,7 +27,7 @@ type mockAnsibleGalaxyProvider struct{} func (g mockAnsibleGalaxyProvider) VersionsForRole(ctx context.Context, r requirements.Role) ([]string, error) { switch { - case r.Source == "test.requirements-lint": + case r.Source == "test.ansible-requirements-lint": return []string{"v1.0.0", "v1.1.0"}, nil default: return nil, errMockRoleNotFound @@ -51,7 +51,7 @@ func TestUpdatesLinter(t *testing.T) { }{ "galaxy:update": { role: requirements.Role{ - Source: "test.requirements-lint", + Source: "test.ansible-requirements-lint", Version: "v1.0.0", }, update: Update{ @@ -63,7 +63,7 @@ func TestUpdatesLinter(t *testing.T) { }, "galaxy:latest": { role: requirements.Role{ - Source: "test.requirements-lint", + Source: "test.ansible-requirements-lint", Version: "v1.1.0", }, update: Update{ @@ -75,7 +75,7 @@ func TestUpdatesLinter(t *testing.T) { }, "galaxy:noVersion": { role: requirements.Role{ - Source: "test.requirements-lint", + Source: "test.ansible-requirements-lint", }, update: Update{ ToVersion: "v1.1.0", @@ -85,7 +85,7 @@ func TestUpdatesLinter(t *testing.T) { }, "galaxy:notFound": { role: requirements.Role{ - Source: "test.galaxy-lint-notfound", + Source: "test.ansible-requirements-lint-notfound", Version: "v1.0.0", }, level: LevelError, @@ -94,7 +94,7 @@ func TestUpdatesLinter(t *testing.T) { // no scm (defaulting to git) "noscm:update": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Version: "v1.0.0", }, update: Update{ @@ -106,7 +106,7 @@ func TestUpdatesLinter(t *testing.T) { }, "noscm:latest": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Version: "v1.1.0", }, update: Update{ @@ -118,7 +118,7 @@ func TestUpdatesLinter(t *testing.T) { }, "noscm:notFound": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint-notfound", + Source: "https://github.com/test/ansible-requirements-lint-notfound", Version: "v1.1.0", }, level: LevelError, @@ -126,14 +126,14 @@ func TestUpdatesLinter(t *testing.T) { }, "noscm:tarball": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint.tar.gz", + Source: "https://github.com/test/ansible-requirements-lint.tar.gz", Version: "v1.0.0", }, level: LevelInfo, }, "git:update": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Scm: "git", Version: "v1.0.0", }, @@ -146,7 +146,7 @@ func TestUpdatesLinter(t *testing.T) { }, "git:latest": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Scm: "git", Version: "v1.0.0", }, @@ -159,7 +159,7 @@ func TestUpdatesLinter(t *testing.T) { }, "git:branch": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Scm: "git", Version: "master", }, @@ -172,7 +172,7 @@ func TestUpdatesLinter(t *testing.T) { }, "git:notfound": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint-notfound", + Source: "https://github.com/test/ansible-requirements-lint-notfound", Scm: "git", Version: "v1.0.0", }, @@ -181,7 +181,7 @@ func TestUpdatesLinter(t *testing.T) { }, "uknownscm": { role: requirements.Role{ - Source: "https://github.com/test/galaxy-lint", + Source: "https://github.com/test/ansible-requirements-lint", Scm: "hg", Version: "v1.0.0", }, diff --git a/requirements/parser.go b/requirements/parser.go index b0a2338..2ad19c9 100644 --- a/requirements/parser.go +++ b/requirements/parser.go @@ -1,7 +1,6 @@ package requirements import ( - "fmt" "io/ioutil" "gopkg.in/yaml.v3" @@ -32,7 +31,9 @@ func Unmarshal(data []byte) (*Requirements, error) { } var node = root.Content[0] - var childrens = node.Content + var childrens = make([]*yaml.Node, len(node.Content)) + copy(childrens, node.Content) + switch { case node.Kind == yaml.SequenceNode: // we have a sequence of roles @@ -43,20 +44,25 @@ func Unmarshal(data []byte) (*Requirements, error) { requirements.Roles = roles case node.Kind == yaml.MappingNode: // we have a dictionary with either a roles or a collections list - - // In case of maps,we should have at least a key and a value, - // if not we break the loop - if len(childrens)/2 <= 0 { - break - } - for { + if len(childrens) < 2 { + break + } + // Note that // - node.Content[0] will be the key of the dictionary // - node.Content[1] will contain the list of roles or collections var k, v = childrens[0], childrens[1] + childrens = childrens[2:] + switch { case k.Kind == yaml.ScalarNode && k.Value == "roles": + // we are parsing a requirements file using the new + // dictionary based syntax introduced with Collections + fallthrough + case k.Kind == yaml.ScalarNode && k.Value == "dependencies": + // we are parsing roles dependencies contained in the meta/main.yml + // manifest of an Ansible role roles, err := parseRolesFromNodesList(v.Content) if err != nil { return nil, err @@ -64,23 +70,15 @@ func Unmarshal(data []byte) (*Requirements, error) { requirements.Roles = roles case k.Kind == yaml.ScalarNode && k.Value == "collections": // collections are not supported yet - case k.Kind == yaml.ScalarNode && k.Value == "collections": - return nil, fmt.Errorf("unknown dictionary key at line %d: %s", k.Line, k.Value) + case k.Kind == yaml.ScalarNode: + return nil, NewUnexpectedMappingNodeValueError(k.Line, k.Value) default: - return nil, fmt.Errorf("unexpected node type at line %d: %v", k.Line, k.Kind) - } - - // if there are no more nodes to parse - if len(childrens) <= 2 { - break + return nil, NewUnexpectedNodeKindError(k.Line, k.Kind) } - - // parse the next key/value pair - childrens = childrens[2:] } default: // mh, something wrong is happening here - return nil, fmt.Errorf("unexpected node type at line %d: %v", node.Line, node.Kind) + return nil, NewUnexpectedNodeKindError(node.Line, node.Kind) } return &requirements, nil @@ -90,12 +88,48 @@ func parseRolesFromNodesList(nodes []*yaml.Node) ([]*Role, error) { var res []*Role for _, n := range nodes { - var role Role - err := n.Decode(&role) - if err != nil { - return nil, fmt.Errorf("decoding line %d as role: %w", n.Line, err) + var role = Role{node: n} + + switch { + case n.Kind == yaml.ScalarNode: + // entries in which the name of the role + // is provided directly (e.g. meta/main.yml dependencies) + role.Name = n.Value + case n.Kind == yaml.MappingNode: + // this is a standard dict-based role definition + var childrens = make([]*yaml.Node, len(n.Content)) + copy(childrens, n.Content) + + for { + if len(childrens) < 2 { + break + } + + var k, v = childrens[0], childrens[1] + childrens = childrens[2:] + + switch { + case k.Kind == yaml.ScalarNode && k.Value == "src": + role.Source = v.Value + case k.Kind == yaml.ScalarNode && k.Value == "scm": + role.Scm = v.Value + case k.Kind == yaml.ScalarNode && k.Value == "version": + role.Version = v.Value + case k.Kind == yaml.ScalarNode && (k.Value == "name" || k.Value == "role"): + role.Name = v.Value + case k.Kind == yaml.ScalarNode && k.Value == "include": + role.Include = v.Value + case k.Kind == yaml.ScalarNode: + // when parsing dependencies in the meta/main.yml format + // we might encounter some variables names, let's ignore them + default: + return nil, NewUnexpectedNodeKindError(k.Line, k.Kind) + } + } + default: + return nil, NewUnexpectedNodeKindError(n.Line, n.Kind) } - role.node = n + res = append(res, &role) } diff --git a/requirements/parser_errors.go b/requirements/parser_errors.go new file mode 100644 index 0000000..9993525 --- /dev/null +++ b/requirements/parser_errors.go @@ -0,0 +1,47 @@ +package requirements + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// UnexpectedNodeKindError is returned +// when the parser encounters an unexpeted yaml.Node kind +// while parsing roles or requirements. +type UnexpectedNodeKindError struct { + line int + kind yaml.Kind +} + +// NewUnexpectedNodeKindError creates a new UnexpectedNodeKindError +func NewUnexpectedNodeKindError(line int, kind yaml.Kind) *UnexpectedNodeKindError { + return &UnexpectedNodeKindError{ + line: line, + kind: kind, + } +} + +func (e *UnexpectedNodeKindError) Error() string { + return fmt.Sprintf("unexpected yaml node kind at line %d: %d", e.line, e.kind) +} + +// UnexpectedMappingNodeValueError is returned +// when parsing a MappingNode, the parser +// encounters an unexpected dictionary key. +type UnexpectedMappingNodeValueError struct { + line int + value string +} + +// NewUnexpectedMappingNodeValueError creates a new UnexpectedMappingNodeValueError +func NewUnexpectedMappingNodeValueError(line int, value string) *UnexpectedMappingNodeValueError { + return &UnexpectedMappingNodeValueError{ + line: line, + value: value, + } +} + +func (e *UnexpectedMappingNodeValueError) Error() string { + return fmt.Sprintf("unexpected yaml dictionary key or value at line %d: %s", e.line, e.value) +} diff --git a/requirements/parser_test.go b/requirements/parser_test.go index 91a5321..907fb01 100644 --- a/requirements/parser_test.go +++ b/requirements/parser_test.go @@ -1,12 +1,168 @@ package requirements -import "testing" +import ( + "testing" +) -func TestParseRequirementsWithInlineRoles(t *testing.T) { +func rolesEqual(a, b Role) bool { + switch { + case a.Name != b.Name: + return false + case a.Source != b.Source: + return false + case a.Scm != b.Scm: + return false + case a.Version != b.Version: + return false + case a.Include != b.Include: + return false + default: + return true + } } -func TestParseRequirementsWithInlineRolesAndIncludes(t *testing.T) { +func parseAndCompare(t *testing.T, requirements string, expected Requirements) { + parsed, err := Unmarshal([]byte(requirements)) + if err != nil { + t.Errorf("expected no error, obtained %+v", err) + return + } + + if len(expected.Roles) != len(parsed.Roles) { + t.Errorf("expecting %d roles, parsed %d roles", len(expected.Roles), len(parsed.Roles)) + } + + for i, r := range parsed.Roles { + expR := expected.Roles[i] + if !rolesEqual(*expR, *r) { + t.Errorf("expecting role %+v, parsed %+v", expR, r) + } + } +} + +// TestParseInlineRequirementsFile tests parsing of roles +// requirements from Ansible requirements files using the +// legacy inline syntax. +func TestParseInlineRequirementsFile(t *testing.T) { + var requirements = ` +--- +# Test ansible-requirements-lint +- name: test.ansible-requirements-lint-name + version: v1.0.0 + +- src: test.ansible-requirements-lint-src + version: v1.0.0 + +- src: test.ansible-requirements-lint-scm + version: v1.0.0 + scm: git +` + + var expected = Requirements{ + Roles: []*Role{ + &Role{ + Name: "test.ansible-requirements-lint-name", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-src", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-scm", + Version: "v1.0.0", + Scm: "git", + }, + }, + } + + parseAndCompare(t, requirements, expected) +} + +// TestParseRolesAndCollectionsRequirementsFile tests parsing of roles +// requirements from Ansible requirements files using the new +// dictionary based syntax introduced to add support to Ansible collections. +func TestParseRolesAndCollectionsRequirementsFile(t *testing.T) { + var requirements = ` +--- + roles: + - name: test.ansible-requirements-lint-name + version: v1.0.0 + + - src: test.ansible-requirements-lint-src + version: v1.0.0 + + - src: test.ansible-requirements-lint-scm + version: v1.0.0 + scm: git +` + var expected = Requirements{ + Roles: []*Role{ + &Role{ + Name: "test.ansible-requirements-lint-name", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-src", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-scm", + Version: "v1.0.0", + Scm: "git", + }, + }, + } + + parseAndCompare(t, requirements, expected) } -func TestParseRequirementsWithRolesAndCollection(t *testing.T) { +// TestParseMetaRequirementsFile tests parsing of roles +// requirements from Ansible roles meta definitions. +func TestParseMetaRequirementsFile(t *testing.T) { + var requirements = ` +--- + dependencies: + - test.ansible-requirements-lint-inline + + - role: test.ansible-requirements-lint-role + version: v1.0.0 + ansible_requirements_lint_role_variable: "ansible_requirements_lint" + + - name: test.ansible-requirements-lint-name + version: v1.0.0 + + - src: test.ansible-requirements-lint-src + version: v1.0.0 + + - src: test.ansible-requirements-lint-scm + version: v1.0.0 + scm: git +` + var expected = Requirements{ + Roles: []*Role{ + &Role{ + Name: "test.ansible-requirements-lint-inline", + }, + &Role{ + Name: "test.ansible-requirements-lint-role", + Version: "v1.0.0", + }, + &Role{ + Name: "test.ansible-requirements-lint-name", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-src", + Version: "v1.0.0", + }, + &Role{ + Source: "test.ansible-requirements-lint-scm", + Version: "v1.0.0", + Scm: "git", + }, + }, + } + + parseAndCompare(t, requirements, expected) } diff --git a/requirements/requirements.go b/requirements/requirements.go index 088a332..56e4b6b 100644 --- a/requirements/requirements.go +++ b/requirements/requirements.go @@ -9,7 +9,7 @@ type Requirements struct { // Roles is the list of roles defined // in the Requirements file. - Roles []*Role `yaml:",inline"` + Roles []*Role // Children is the list of requirements // files included by the Requirement file. @@ -25,10 +25,10 @@ type Requirements struct { type Role struct { node *yaml.Node - Source string `yaml:"src"` - Scm string `yaml:"scm"` - Version string `yaml:"version"` - Name string `yaml:"name"` + Source string + Scm string + Version string + Name string - Include string `yaml:"include"` + Include string }