diff --git a/.github/workflows/ci-pr-title.yaml b/.github/workflows/ci-pr-title.yaml deleted file mode 100644 index 8be2636..0000000 --- a/.github/workflows/ci-pr-title.yaml +++ /dev/null @@ -1,83 +0,0 @@ -name: CI Check Title - -on: - pull_request: - types: [opened, edited, synchronize, reopened] - -jobs: - title-lint: - name: Validate PR title - runs-on: [default] - steps: - - name: CI Check Title - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - wip: true - # Configure which types are allowed (newline-delimited). - # Default: https://github.com/commitizen/conventional-commit-types - types: | - build - chore - fix - feat - merge - publish - release - refactor - research - style - test - docs - # Configure which scopes are allowed (newline-delimited). - # These are regex patterns auto-wrapped in `^ $`. - scopes: | - build - config - charts - ci - core - deps - docs - actions - template - tests - ui - utils - version - webhook - ISSUE-\d+ - # Configure that a scope must always be provided. - requireScope: true - # Configure which scopes are disallowed in PR titles (newline-delimited). - # For instance by setting the value below, `chore(release): ...` (lowercase) - # and `ci(e2e,release): ...` (unknown scope) will be rejected. - # These are regex patterns auto-wrapped in `^ $`. - disallowScopes: | - release - [A-Z]+ - # Configure additional validation for the subject based on a regex. - # This example ensures the subject doesn't start with an uppercase character. - subjectPattern: ^(?![A-Z]).+$ - # If `subjectPattern` is configured, you can use this property to override - # the default error message that is shown when the pattern doesn't match. - # The variables `subject` and `title` can be used within the message. - subjectPatternError: | - The subject "{subject}" found in the pull request title "{title}" - didn't match the configured pattern. Please ensure that the subject - doesn't start with an uppercase character. - # If the PR contains one of these newline-delimited labels, the - # validation is skipped. If you want to rerun the validation when - # labels change, you might want to use the `labeled` and `unlabeled` - # event triggers in your workflow. - ignoreLabels: | - bot - ignore-semantic-pull-request - # If you're using a format for the PR title that differs from the traditional Conventional - # Commits spec, you can use these options to customize the parsing of the type, scope and - # subject. The `headerPattern` should contain a regex where the capturing groups in parentheses - # correspond to the parts listed in `headerPatternCorrespondence`. - # See: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser#headerpattern - headerPattern: '^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$' - headerPatternCorrespondence: type, scope, subject diff --git a/.github/workflows/license.yaml b/.github/workflows/license.yaml deleted file mode 100644 index 09aa802..0000000 --- a/.github/workflows/license.yaml +++ /dev/null @@ -1,10 +0,0 @@ -name: Check & Fix License Header -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - build-license-eye: - permissions: - contents: write # Only used when `apply_header: true` else the permission is `read` see: https://github.com/cloudoperators/common/blob/8f15c13b6f4c1631c7e6f6dff5c3300452e9b5b6/.github/workflows/shared-license.yaml#L21-L22 - uses: cloudoperators/common/.github/workflows/shared-license.yaml@main \ No newline at end of file diff --git a/.github/workflows/reuse.yaml b/.github/workflows/reuse.yaml deleted file mode 100644 index d02b6ab..0000000 --- a/.github/workflows/reuse.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. -# -# SPDX-License-Identifier: CC0-1.0 - -name: REUSE Compliance Check - -on: [pull_request] - -jobs: - reuse: - uses: cloudoperators/common/.github/workflows/shared-reuse.yaml@main diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index adfdade..1862ace 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,5 +1,14 @@ name: "Unit tests" on: + push: + branches: [main] + paths: + - 'pkg/**' + - 'cmd/**' + - 'Dockerfile*' + - 'go.mod' + - 'go.sum' + - '.golangci.yaml' pull_request: paths: - 'pkg/**' @@ -11,7 +20,7 @@ on: jobs: lint: - runs-on: [ default ] + runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: @@ -26,7 +35,7 @@ jobs: run: make lint build: - runs-on: [ default ] + runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: @@ -38,4 +47,19 @@ jobs: go-version-file: 'go.mod' token: ${{ secrets.GITHUB_TOKEN }} - name: build - run: make build \ No newline at end of file + run: make build + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: 'go.mod' + token: ${{ secrets.GITHUB_TOKEN }} + - name: test + run: make test \ No newline at end of file diff --git a/Makefile b/Makefile index 64f4170..f1f9ab4 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,10 @@ fmt: goimports lint: golint $(GOLINT) run -v --timeout 5m +.PHONY: test +test: + go test ./... + .PHONY: check check: fmt lint diff --git a/cmd/check/main.go b/cmd/check/main.go index 3cccadb..ccac42d 100644 --- a/cmd/check/main.go +++ b/cmd/check/main.go @@ -23,7 +23,14 @@ func main() { fmt.Fprintf(os.Stderr, "invalid source configuration: %s\n", err) } - resp, err := resource.Check(context.Background(), req) + ctx := context.Background() + repo, err := resource.NewRepositoryForSource(ctx, req.Source) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create repository client: %s\n", err) + os.Exit(1) + } + + resp, err := resource.Check(ctx, req, repo) if err != nil { fmt.Fprintf(os.Stderr, "invalid source configuration: %s\n", err) } diff --git a/cmd/in/main.go b/cmd/in/main.go index 2234c13..589a365 100644 --- a/cmd/in/main.go +++ b/cmd/in/main.go @@ -27,7 +27,15 @@ func main() { if err := req.Validate(); err != nil { fmt.Fprintf(os.Stderr, "invalid source configuration: %s\n", err) } - response, err := resource.Get(context.Background(), req, outputDir) + + ctx := context.Background() + repo, err := resource.NewRepositoryForSource(ctx, req.Source) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create repository client: %s\n", err) + os.Exit(1) + } + + response, err := resource.Get(ctx, req, outputDir, repo) if err != nil { fmt.Fprintf(os.Stderr, "get failed: %s\n", err) os.Exit(1) diff --git a/pkg/resource/check.go b/pkg/resource/check.go index ff74301..75301f5 100644 --- a/pkg/resource/check.go +++ b/pkg/resource/check.go @@ -12,7 +12,6 @@ import ( "github.com/Masterminds/semver/v3" "github.com/pkg/errors" "oras.land/oras-go/v2/registry" - "oras.land/oras-go/v2/registry/remote" ) type ( @@ -28,12 +27,7 @@ func (cr *CheckRequest) Validate() error { return cr.Source.Validate() } -func Check(ctx context.Context, request CheckRequest) (*CheckResponse, error) { - repo, err := newRepositoryForSource(ctx, request.Source) - if err != nil { - return nil, errors.Wrap(err, "failed to create repository client") - } - +func Check(ctx context.Context, request CheckRequest, repo Repository) (*CheckResponse, error) { // Fetch repository tags allTags, err := registry.Tags(ctx, repo) if err != nil { @@ -68,7 +62,7 @@ func Check(ctx context.Context, request CheckRequest) (*CheckResponse, error) { return nil, fmt.Errorf("no latest tag found for source %s", request.Source.String()) } - resolvedVersions, err := resolveImageDigests(ctx, sortedSemvers, repo) + resolvedVersions, err := resolveImageDigests(ctx, sortedSemvers, repo, request.Source.String()) if err != nil { return nil, err } @@ -93,10 +87,10 @@ func sortBySemver(allTags []string) []semver.Version { return allVersions } -func resolveImageDigests(ctx context.Context, sortedSemvers []semver.Version, repo *remote.Repository) (CheckResponse, error) { +func resolveImageDigests(ctx context.Context, sortedSemvers []semver.Version, repo Repository, source string) (CheckResponse, error) { resolvedVersions := make(CheckResponse, len(sortedSemvers)) for i, version := range sortedSemvers { - digest, err := getDigestForTag(ctx, repo, version.Original()) + digest, err := getDigestForTag(ctx, repo, source, version.Original()) if err != nil { return nil, errors.Wrap(err, fmt.Sprintf("failed to fetch digest for latest tag %q (parsed as %s)", version.Original(), version.String())) } diff --git a/pkg/resource/check_test.go b/pkg/resource/check_test.go new file mode 100644 index 0000000..a2ee683 --- /dev/null +++ b/pkg/resource/check_test.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 + +package resource + +import ( + "context" + "fmt" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content/memory" +) + +// testRepository implements Repository using an in-memory store for content, +// with configurable tags and digest mappings. +type testRepository struct { + *memory.Store + tags []string + digests map[string]ocispec.Descriptor +} + +func (r *testRepository) Tags(ctx context.Context, last string, fn func(tags []string) error) error { + return fn(r.tags) +} + +func (r *testRepository) Resolve(_ context.Context, ref string) (ocispec.Descriptor, error) { + desc, ok := r.digests[ref] + if !ok { + return ocispec.Descriptor{}, fmt.Errorf("not found: %s", ref) + } + return desc, nil +} + +func newTestRepo(tags []string, source string) *testRepository { + digests := make(map[string]ocispec.Descriptor, len(tags)) + for _, tag := range tags { + fakeDigest := digest.FromString(tag) + digests[fmt.Sprintf("%s:%s", source, tag)] = ocispec.Descriptor{ + Digest: fakeDigest, + } + } + return &testRepository{ + Store: memory.New(), + tags: tags, + digests: digests, + } +} + +func TestCheckRequestValidate(t *testing.T) { + t.Run("checkRequest should fail validation when source is invalid", func(t *testing.T) { + req := CheckRequest{ + Source: Source{Repository: "myrepo", ChartName: "mychart"}, // missing registry + } + err := req.Validate() + if err == nil { + t.Error("expected validation error, got nil") + } + }) + + t.Run("checkRequest should pass validation when source is valid", func(t *testing.T) { + req := CheckRequest{ + Source: Source{Registry: "r.example.com", Repository: "myrepo", ChartName: "mychart"}, + } + if err := req.Validate(); err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) +} + +func TestCheck(t *testing.T) { + source := Source{ + Registry: "registry.example.com", + Repository: "charts", + ChartName: "mychart", + } + sourceStr := source.String() // "registry.example.com/charts/mychart" + + t.Run("check should return latest 10 revisions when no starting revision is specified", func(t *testing.T) { + tags := []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0", "1.5.0", "1.6.0", "1.7.0", "1.8.0", "1.9.0", "2.0.0"} + repo := newTestRepo(tags, sourceStr) + req := CheckRequest{Source: source} + + resp, err := Check(context.Background(), req, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should return latest 10 (1.1.0 through 2.0.0) + if len(*resp) != 10 { + t.Errorf("expected 10 versions, got %d", len(*resp)) + } + // First should be 1.1.0, last should be 2.0.0 + if (*resp)[0].Tag != "1.1.0" { + t.Errorf("expected first tag 1.1.0, got %q", (*resp)[0].Tag) + } + if (*resp)[len(*resp)-1].Tag != "2.0.0" { + t.Errorf("expected last tag 2.0.0, got %q", (*resp)[len(*resp)-1].Tag) + } + }) + + t.Run("check should return all revisions when fewer than 10 exist", func(t *testing.T) { + tags := []string{"1.0.0", "1.1.0", "1.2.0"} + repo := newTestRepo(tags, sourceStr) + req := CheckRequest{Source: source} + + resp, err := Check(context.Background(), req, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(*resp) != 3 { + t.Errorf("expected 3 versions, got %d", len(*resp)) + } + }) + + t.Run("check should return versions from cursor onwards when starting revision is specified", func(t *testing.T) { + tags := []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0.0"} + repo := newTestRepo(tags, sourceStr) + req := CheckRequest{ + Source: source, + Version: &Version{Tag: "1.2.0"}, + } + + resp, err := Check(context.Background(), req, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should return 1.2.0, 1.3.0, 2.0.0 + if len(*resp) != 3 { + t.Errorf("expected 3 versions, got %d", len(*resp)) + } + if (*resp)[0].Tag != "1.2.0" { + t.Errorf("expected first tag 1.2.0, got %q", (*resp)[0].Tag) + } + }) + + t.Run("check should return versions in semver order when tags are unordered", func(t *testing.T) { + // Provide tags out of order + tags := []string{"2.0.0", "1.0.0", "1.1.0"} + repo := newTestRepo(tags, sourceStr) + req := CheckRequest{Source: source} + + resp, err := Check(context.Background(), req, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"1.0.0", "1.1.0", "2.0.0"} + for i, v := range *resp { + if v.Tag != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], v.Tag) + } + } + }) + + t.Run("check should populate digest in response", func(t *testing.T) { + tags := []string{"1.0.0"} + repo := newTestRepo(tags, sourceStr) + req := CheckRequest{Source: source} + + resp, err := Check(context.Background(), req, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if (*resp)[0].Digest == "" { + t.Error("expected digest to be populated") + } + }) + + t.Run("check should return error when no tags exist", func(t *testing.T) { + repo := newTestRepo([]string{}, sourceStr) + req := CheckRequest{Source: source} + + _, err := Check(context.Background(), req, repo) + if err == nil { + t.Error("expected error for empty tag list, got nil") + } + }) +} diff --git a/pkg/resource/in.go b/pkg/resource/in.go index a0261f5..126c4d2 100644 --- a/pkg/resource/in.go +++ b/pkg/resource/in.go @@ -42,12 +42,7 @@ func (gr *GetRequest) Validate() error { return gr.Source.Validate() } -func Get(ctx context.Context, request GetRequest, outputDir string) (*GetResponse, error) { - repo, err := newRepositoryForSource(ctx, request.Source) - if err != nil { - return nil, err - } - +func Get(ctx context.Context, request GetRequest, outputDir string, repo Repository) (*GetResponse, error) { store := memory.New() desc, err := oras.Copy(ctx, repo, request.Version.Tag, store, request.Version.Tag, oras.DefaultCopyOptions) if err != nil { diff --git a/pkg/resource/in_test.go b/pkg/resource/in_test.go new file mode 100644 index 0000000..cd861b1 --- /dev/null +++ b/pkg/resource/in_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: Apache-2.0 + +package resource + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" +) + +func mustDigest(data []byte) digest.Digest { + return digest.FromBytes(data) +} + +func mustReader(data []byte) io.Reader { + return bytes.NewReader(data) +} + +// inTestRepository wraps memory.Store and adds a Tags method to satisfy +// the Repository interface. Resolve and Fetch delegate to the store directly. +type inTestRepository struct { + *memory.Store +} + +func (r *inTestRepository) Tags(_ context.Context, _ string, fn func(tags []string) error) error { + return fn([]string{}) +} + +func (r *inTestRepository) Resolve(ctx context.Context, ref string) (ocispec.Descriptor, error) { + return r.Store.Resolve(ctx, ref) +} + +// buildChartRepo creates a memory store with a packed OCI manifest containing +// a helm chart layer, tagged with the given version. +func buildChartRepo(t *testing.T, tag string) (*inTestRepository, ocispec.Descriptor) { + t.Helper() + ctx := context.Background() + store := memory.New() + + // Push a fake chart archive layer. + chartContent := []byte("fake-chart-content") + chartDesc := ocispec.Descriptor{ + MediaType: mediaTypeHelmChartContentArchive, + Digest: mustDigest(chartContent), + Size: int64(len(chartContent)), + } + if err := store.Push(ctx, chartDesc, mustReader(chartContent)); err != nil { + t.Fatalf("failed to push chart layer: %v", err) + } + + // Pack a manifest with the chart layer and an annotation. + manifestDesc, err := oras.PackManifest(ctx, store, oras.PackManifestVersion1_0, "", oras.PackManifestOptions{ + Layers: []ocispec.Descriptor{chartDesc}, + ManifestAnnotations: map[string]string{ + "org.example.chart": "test", + }, + }) + if err != nil { + t.Fatalf("failed to pack manifest: %v", err) + } + + // Tag the manifest with the version. + if err := store.Tag(ctx, manifestDesc, tag); err != nil { + t.Fatalf("failed to tag manifest: %v", err) + } + + return &inTestRepository{Store: store}, manifestDesc +} + +func TestGetRequestValidate(t *testing.T) { + t.Run("missing tag returns error", func(t *testing.T) { + req := GetRequest{ + Source: Source{Registry: "r.example.com", Repository: "repo", ChartName: "chart"}, + Version: Version{Tag: ""}, + } + err := req.Validate() + if err == nil { + t.Error("expected error for missing tag, got nil") + } + if err != nil && err.Error() != "tag is required" { + t.Errorf("expected 'tag is required', got %q", err.Error()) + } + }) + + t.Run("missing source registry returns error", func(t *testing.T) { + req := GetRequest{ + Source: Source{Repository: "repo", ChartName: "chart"}, + Version: Version{Tag: "1.0.0"}, + } + if err := req.Validate(); err == nil { + t.Error("expected error for missing registry, got nil") + } + }) + + t.Run("valid request passes", func(t *testing.T) { + req := GetRequest{ + Source: Source{Registry: "r.example.com", Repository: "repo", ChartName: "chart"}, + Version: Version{Tag: "1.0.0"}, + } + if err := req.Validate(); err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) +} + +func TestGet(t *testing.T) { + tag := "1.0.0" + source := Source{ + Registry: "registry.example.com", + Repository: "charts", + ChartName: "mychart", + } + + t.Run("writes files to output dir and returns response", func(t *testing.T) { + repo, manifestDesc := buildChartRepo(t, tag) + outputDir := t.TempDir() + + req := GetRequest{Source: source, Version: Version{Tag: tag}} + resp, err := Get(context.Background(), req, outputDir, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify response fields. + if resp.Tag != tag { + t.Errorf("expected tag %q, got %q", tag, resp.Tag) + } + if resp.Digest != manifestDesc.Digest.String() { + t.Errorf("expected digest %q, got %q", manifestDesc.Digest.String(), resp.Digest) + } + + // Verify the manifest JSON file was written. + manifestFile := filepath.Join(outputDir, "mychart-1.0.0.json") + if _, err := os.Stat(manifestFile); os.IsNotExist(err) { + t.Errorf("expected manifest file %s to exist", manifestFile) + } + + // Verify the chart archive (.tgz) was written. + chartFile := filepath.Join(outputDir, "mychart-1.0.0.tgz") + if _, err := os.Stat(chartFile); os.IsNotExist(err) { + t.Errorf("expected chart file %s to exist", chartFile) + } + + // Verify chart file content. + content, err := os.ReadFile(chartFile) + if err != nil { + t.Fatalf("failed to read chart file: %v", err) + } + if string(content) != "fake-chart-content" { + t.Errorf("unexpected chart content: %q", string(content)) + } + }) + + t.Run("metadata from manifest annotations is returned", func(t *testing.T) { + repo, _ := buildChartRepo(t, tag) + outputDir := t.TempDir() + + req := GetRequest{Source: source, Version: Version{Tag: tag}} + resp, err := Get(context.Background(), req, outputDir, repo) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + found := false + for _, m := range resp.Metadata { + if m.Name == "org.example.chart" && m.Value == "test" { + found = true + break + } + } + if !found { + t.Errorf("expected metadata 'org.example.chart=test', got: %v", resp.Metadata) + } + }) +} diff --git a/pkg/resource/repo.go b/pkg/resource/repo.go index e5b3902..2f5333a 100644 --- a/pkg/resource/repo.go +++ b/pkg/resource/repo.go @@ -7,13 +7,23 @@ import ( "context" "fmt" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" + oras "oras.land/oras-go/v2" + "oras.land/oras-go/v2/registry" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/retry" ) +// Repository is the interface that Check and Get need from the OCI registry. +type Repository interface { + oras.ReadOnlyTarget + registry.TagLister + Resolve(ctx context.Context, ref string) (ocispec.Descriptor, error) +} + var allowedMediaTypes = []string{ "application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json", @@ -22,7 +32,7 @@ var allowedMediaTypes = []string{ "*/*", } -func newRepositoryForSource(_ context.Context, s Source) (*remote.Repository, error) { +func NewRepositoryForSource(_ context.Context, s Source) (*remote.Repository, error) { repo, err := remote.NewRepository(s.String()) if err != nil { return nil, errors.Wrapf(err, "failed to create repository from source %s", s.String()) @@ -52,8 +62,8 @@ func newRepositoryForSource(_ context.Context, s Source) (*remote.Repository, er return repo, nil } -func getDigestForTag(ctx context.Context, repo *remote.Repository, tag string) (string, error) { - desc, err := repo.Resolve(ctx, fmt.Sprintf("%s:%s", repo.Reference.String(), tag)) +func getDigestForTag(ctx context.Context, repo Repository, source, tag string) (string, error) { + desc, err := repo.Resolve(ctx, fmt.Sprintf("%s:%s", source, tag)) if err != nil { return "", err } diff --git a/pkg/resource/types_test.go b/pkg/resource/types_test.go new file mode 100644 index 0000000..3839eeb --- /dev/null +++ b/pkg/resource/types_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package resource + +import ( + "testing" +) + +func TestSourceValidate(t *testing.T) { + tests := []struct { + name string + source Source + wantErr string + }{ + { + name: "missing registry", + source: Source{Repository: "myrepo", ChartName: "mychart"}, + wantErr: "registry cannot be empty", + }, + { + name: "missing repository", + source: Source{Registry: "registry.example.com", ChartName: "mychart"}, + wantErr: "repository cannot be empty", + }, + { + name: "missing chart_name", + source: Source{Registry: "registry.example.com", Repository: "myrepo"}, + wantErr: "chart_name cannot be empty", + }, + { + name: "all fields present", + source: Source{Registry: "registry.example.com", Repository: "myrepo", ChartName: "mychart"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.source.Validate() + if tt.wantErr == "" { + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error %q, got nil", tt.wantErr) + } else if err.Error() != tt.wantErr { + t.Errorf("expected error %q, got %q", tt.wantErr, err.Error()) + } + } + }) + } +} + +func TestSourceString(t *testing.T) { + s := Source{ + Registry: "registry.example.com", + Repository: "myrepo", + ChartName: "mychart", + } + want := "registry.example.com/myrepo/mychart" + got := s.String() + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} diff --git a/pkg/resource/version.go b/pkg/resource/version.go index d82489e..0a41aec 100644 --- a/pkg/resource/version.go +++ b/pkg/resource/version.go @@ -1,9 +1,10 @@ package resource import ( - "github.com/Masterminds/semver/v3" "regexp" "strconv" + + "github.com/Masterminds/semver/v3" ) // CompareGitDescribeVersions compares this version to another one. It returns -1, 0, or 1 if @@ -16,7 +17,7 @@ import ( // lower than the version without a prerelease. Compare always takes into account // prereleases. If you want to work with ranges using typical range syntaxes that // skip prereleases if the range is not looking for them use constraints. -func CompareGitDescribeVersions(v *semver.Version, o *semver.Version) int { +func CompareGitDescribeVersions(v, o *semver.Version) int { // Compare the major, minor, and patch version for differences. If a // difference is found return the comparison. if d := compareSegment(v.Major(), o.Major()); d != 0 { @@ -76,7 +77,7 @@ func comparePrerelease(v, o string) int { } // Iterate over each part of the prereleases to compare the differences. - for i := 0; i < l; i++ { + for i := range l { // Since the lentgh of the parts can be different we need to create // a placeholder. This is to avoid out of bounds issues. stemp := "" @@ -134,15 +135,16 @@ func comparePrePart(s, o string) int { si, n2 := strconv.ParseUint(s, 10, 64) // The case where both are strings compare the strings - if n1 != nil && n2 != nil { + switch { + case n1 != nil && n2 != nil: if s > o { return 1 } return -1 - } else if n1 != nil { + case n1 != nil: // o is a string and s is a number return -1 - } else if n2 != nil { + case n2 != nil: // s is a string and o is a number return 1 } diff --git a/pkg/resource/version_test.go b/pkg/resource/version_test.go new file mode 100644 index 0000000..b5ebe0b --- /dev/null +++ b/pkg/resource/version_test.go @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 + +package resource + +import ( + "testing" + + "github.com/Masterminds/semver/v3" +) + +func mustSemver(t *testing.T, v string) *semver.Version { + t.Helper() + sv, err := semver.NewVersion(v) + if err != nil { + t.Fatalf("invalid semver %q: %v", v, err) + } + return sv +} + +func TestCompareGitDescribeVersions(t *testing.T) { + tests := []struct { + name string + v string + o string + want int + }{ + { + name: "equal versions", + v: "1.2.3", + o: "1.2.3", + want: 0, + }, + { + name: "major ordering", + v: "2.0.0", + o: "1.0.0", + want: 1, + }, + { + name: "major ordering is numeric not lexicographic", + v: "10.0.0", + o: "2.0.0", + want: 1, + }, + { + name: "minor ordering", + v: "1.1.0", + o: "1.2.0", + want: -1, + }, + { + name: "minor ordering is numeric not lexicographic", + v: "1.10.0", + o: "1.2.0", + want: 1, + }, + { + name: "patch ordering", + v: "1.0.1", + o: "1.0.0", + want: 1, + }, + { + name: "patch ordering is numeric not lexicographic", + v: "1.0.10", + o: "1.0.2", + want: 1, + }, + { + name: "prerelease less than release", + v: "1.0.0-rc1", + o: "1.0.0", + want: -1, + }, + { + name: "release greater than prerelease", + v: "1.0.0", + o: "1.0.0-rc1", + want: 1, + }, + { + name: "git-describe numeric sort: 5 commits < 12 commits", + v: "1.0.0-5-gabcdef", + o: "1.0.0-12-g1234567", + want: -1, + }, + { + name: "git-describe numeric sort: 12 commits > 5 commits", + v: "1.0.0-12-g1234567", + o: "1.0.0-5-gabcdef", + want: 1, + }, + { + name: "equal prerelease", + v: "1.0.0-5-gabcdef", + o: "1.0.0-5-gabcdef", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := mustSemver(t, tt.v) + o := mustSemver(t, tt.o) + got := CompareGitDescribeVersions(v, o) + if got != tt.want { + t.Errorf("CompareGitDescribeVersions(%q, %q) = %d, want %d", tt.v, tt.o, got, tt.want) + } + }) + } +} + +func TestSortBySemver(t *testing.T) { + t.Run("sortBySemver should return empty slice when input is empty", func(t *testing.T) { + result := sortBySemver([]string{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %v", result) + } + }) + + t.Run("sortBySemver should sort numerically not lexicographically", func(t *testing.T) { + tags := []string{"1.10.0", "1.2.0", "1.0.0", "2.0.0", "1.1.0"} + result := sortBySemver(tags) + want := []string{"1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"} + if len(result) != len(want) { + t.Fatalf("expected %d versions, got %d: %v", len(want), len(result), result) + } + for i, v := range result { + if v.Original() != want[i] { + t.Errorf("index %d: expected %q, got %q", i, want[i], v.Original()) + } + } + }) + + t.Run("sortBySemver should preserve slice length when tags are not valid semver", func(t *testing.T) { + // sortBySemver allocates len(allTags) and only fills valid entries, + // so the tail of the slice contains zero-value semver (0.0.0). + tags := []string{"not-a-version", "also-not"} + result := sortBySemver(tags) + // Slice length equals input length; zero-value entries sort first. + if len(result) != len(tags) { + t.Errorf("expected slice length %d, got %d", len(tags), len(result)) + } + }) + + t.Run("sortBySemver should sort git-describe prereleases numerically", func(t *testing.T) { + tags := []string{"1.0.0-12-g1234567", "1.0.0-5-gabcdef"} + result := sortBySemver(tags) + if len(result) != 2 { + t.Fatalf("expected 2 versions, got %d", len(result)) + } + if result[0].Original() != "1.0.0-5-gabcdef" { + t.Errorf("expected first to be 1.0.0-5-gabcdef, got %q", result[0].Original()) + } + if result[1].Original() != "1.0.0-12-g1234567" { + t.Errorf("expected second to be 1.0.0-12-g1234567, got %q", result[1].Original()) + } + }) +}