Skip to content

Commit

Permalink
feat: adds KCL release (#114)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmgilman authored Jan 16, 2025
1 parent 4b88990 commit aa2f9e4
Show file tree
Hide file tree
Showing 4 changed files with 341 additions and 1 deletion.
48 changes: 47 additions & 1 deletion cli/pkg/release/providers/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package providers
import (
"fmt"
"log/slog"
"regexp"
"strings"

"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws"
"github.com/input-output-hk/catalyst-forge/lib/project/project"
)

var ErrConfigNotFound = fmt.Errorf("release config field not found")

// createECRRepoIfNotExists creates an ECR repository if it does not exist.
func createECRRepoIfNotExists(client aws.ECRClient, p *project.Project, registry string, logger *slog.Logger) error {
name, err := aws.ExtractECRRepoName(registry)
Expand All @@ -30,9 +34,51 @@ func createECRRepoIfNotExists(client aws.ECRClient, p *project.Project, registry
return nil
}

// generateContainerName generates the container name for the project.
// If the name is not provided, the project name is used.
func generateContainerName(p *project.Project, name string, registry string) string {
var n string
if name == "" {
n = p.Name
} else {
n = name
}

if isGHCRRegistry(registry) {
return fmt.Sprintf("%s/%s", strings.TrimSuffix(registry, "/"), n)
} else {
var repo string
if strings.Contains(p.Blueprint.Global.Repo.Name, "/") {
repo = strings.Split(p.Blueprint.Global.Repo.Name, "/")[1]
} else {
repo = p.Blueprint.Global.Repo.Name
}

return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(registry, "/"), repo, n)
}
}

// isECRRegistry checks if the registry is an ECR registry.
func isECRRegistry(registry string) bool {
return regexp.MustCompile(`^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com`).MatchString(registry)
}

// isGHCRRegistry checks if the registry is a GHCR registry.
func isGHCRRegistry(registry string) bool {
return regexp.MustCompile(`^ghcr\.io/[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$`).MatchString(registry)
}

// parseConfig parses the configuration for the release.
func parseConfig(p *project.Project, release string, config any) error {
return p.Raw().DecodePath(fmt.Sprintf("project.release.%s.config", release), &config)
err := p.Raw().DecodePath(fmt.Sprintf("project.release.%s.config", release), &config)

if err != nil && strings.Contains(err.Error(), "not found") {
return ErrConfigNotFound
} else if err != nil {
return err
}

return nil
}

// getPlatforms returns the platforms for the target.
Expand Down
111 changes: 111 additions & 0 deletions cli/pkg/release/providers/kcl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package providers

import (
"fmt"
"log/slog"

"github.com/input-output-hk/catalyst-forge/cli/pkg/events"
"github.com/input-output-hk/catalyst-forge/cli/pkg/executor"
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws"
"github.com/input-output-hk/catalyst-forge/cli/pkg/run"
"github.com/input-output-hk/catalyst-forge/lib/project/project"
"github.com/input-output-hk/catalyst-forge/lib/project/schema"
)

const (
KCL_BINARY = "kcl"
)

type KCLReleaserConfig struct {
Container string `json:"container"`
}

type KCLReleaser struct {
config KCLReleaserConfig
ecr aws.ECRClient
force bool
handler events.EventHandler
kcl executor.WrappedExecuter
logger *slog.Logger
project project.Project
release schema.Release
releaseName string
}

func (r *KCLReleaser) Release() error {
if !r.handler.Firing(&r.project, r.project.GetReleaseEvents(r.releaseName)) && !r.force {
r.logger.Info("No release event is firing, skipping release")
return nil
}

registries := r.project.Blueprint.Global.CI.Providers.KCL.Registries
if len(registries) == 0 {
return fmt.Errorf("must specify at least one KCL registry")
}

for _, registry := range registries {
container := generateContainerName(&r.project, r.config.Container, registry)
path, err := r.project.GetRelativePath()
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}

if isECRRegistry(registry) {
r.logger.Info("Detected ECR registry, checking if repository exists", "repository", container)
if err := createECRRepoIfNotExists(r.ecr, &r.project, container, r.logger); err != nil {
return fmt.Errorf("failed to create ECR repository: %w", err)
}
}

r.logger.Info("Publishing module", "path", path, "container", container)
out, err := r.kcl.Execute("mod", "push", fmt.Sprintf("oci://%s", container))
if err != nil {
r.logger.Error("Failed to push module", "module", container, "error", err, "output", string(out))
return fmt.Errorf("failed to push module: %w", err)
}
}

return nil
}

// NewKCLReleaser creates a new KCL release provider.
func NewKCLReleaser(ctx run.RunContext,
project project.Project,
name string,
force bool,
) (*KCLReleaser, error) {
release, ok := project.Blueprint.Project.Release[name]
if !ok {
return nil, fmt.Errorf("unknown release: %s", name)
}

exec := executor.NewLocalExecutor(ctx.Logger, executor.WithWorkdir(project.Path))
if _, ok := exec.LookPath(KCL_BINARY); ok != nil {
return nil, fmt.Errorf("failed to find KCL binary: %w", ok)
}

var config KCLReleaserConfig
err := parseConfig(&project, name, &config)
if err != nil && err != ErrConfigNotFound {
return nil, fmt.Errorf("failed to parse release config: %w", err)
}

ecr, err := aws.NewECRClient(ctx.Logger)
if err != nil {
return nil, fmt.Errorf("failed to create ECR client: %w", err)
}

kcl := executor.NewLocalWrappedExecutor(exec, "kcl")
handler := events.NewDefaultEventHandler(ctx.Logger)
return &KCLReleaser{
config: config,
ecr: ecr,
force: force,
handler: &handler,
logger: ctx.Logger,
kcl: kcl,
project: project,
release: release,
releaseName: name,
}, nil
}
179 changes: 179 additions & 0 deletions cli/pkg/release/providers/kcl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package providers

import (
"context"
"fmt"
"testing"

"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws"
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws/mocks"
"github.com/input-output-hk/catalyst-forge/lib/project/project"
"github.com/input-output-hk/catalyst-forge/lib/project/schema"
"github.com/input-output-hk/catalyst-forge/lib/tools/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestKCLReleaserRelease(t *testing.T) {
type testResults struct {
calls []string
err error
repoName string
}

newProject := func(
name string,
registries []string,
) project.Project {
return project.Project{
Name: name,
Blueprint: schema.Blueprint{
Global: schema.Global{
CI: schema.GlobalCI{
Providers: schema.Providers{
KCL: schema.ProviderKCL{
Registries: registries,
},
},
},
Repo: schema.GlobalRepo{
Name: "repo",
},
},
},
}
}

tests := []struct {
name string
project project.Project
release schema.Release
config KCLReleaserConfig
firing bool
force bool
failOn string
validate func(t *testing.T, r testResults)
}{
{
name: "full",
project: newProject("test", []string{"test.com"}),
release: schema.Release{},
config: KCLReleaserConfig{
Container: "name",
},
firing: true,
force: false,
failOn: "",
validate: func(t *testing.T, r testResults) {
require.NoError(t, r.err)
assert.Contains(t, r.calls, "mod push oci://test.com/repo/name")
},
},
{
name: "ECR",
project: newProject("test", []string{"123456789012.dkr.ecr.us-west-2.amazonaws.com"}),
release: schema.Release{},
config: KCLReleaserConfig{
Container: "name",
},
firing: true,
force: false,
failOn: "",
validate: func(t *testing.T, r testResults) {
require.NoError(t, r.err)
assert.Contains(t, r.calls, "mod push oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/repo/name")
assert.Equal(t, "repo/name", r.repoName)
},
},
{
name: "no container",
project: newProject("test", []string{"test.com"}),
release: schema.Release{},
config: KCLReleaserConfig{},
firing: true,
force: false,
failOn: "",
validate: func(t *testing.T, r testResults) {
require.NoError(t, r.err)
assert.Contains(t, r.calls, "mod push oci://test.com/repo/test")
},
},
{
name: "not firing",
project: newProject("test", []string{"test.com"}),
firing: false,
force: false,
failOn: "",
validate: func(t *testing.T, r testResults) {
require.NoError(t, r.err)
assert.Len(t, r.calls, 0)
},
},
{
name: "forced",
project: newProject("test", []string{"test.com"}),
release: schema.Release{},
config: KCLReleaserConfig{
Container: "test",
},
firing: false,
force: true,
failOn: "",
validate: func(t *testing.T, r testResults) {
require.NoError(t, r.err)
assert.Contains(t, r.calls, "mod push oci://test.com/repo/test")
},
},
{
name: "push fails",
project: newProject("test", []string{"test.com"}),
release: schema.Release{},
config: KCLReleaserConfig{
Container: "test",
},
firing: true,
force: false,
failOn: "mod push",
validate: func(t *testing.T, r testResults) {
require.Error(t, r.err)
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var repoName string
mock := mocks.AWSECRClientMock{
CreateRepositoryFunc: func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) {
repoName = *params.RepositoryName
return &ecr.CreateRepositoryOutput{}, nil
},
DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) {
return nil, fmt.Errorf("RepositoryNotFoundException")
},
}
ecr := aws.NewCustomECRClient(&mock, testutils.NewNoopLogger())

var calls []string
kcl := KCLReleaser{
config: tt.config,
ecr: ecr,
force: tt.force,
handler: newReleaseEventHandlerMock(tt.firing),
kcl: newWrappedExecuterMock(&calls, tt.failOn),
logger: testutils.NewNoopLogger(),
project: tt.project,
release: tt.release,
}

err := kcl.Release()

tt.validate(t, testResults{
calls: calls,
err: err,
repoName: repoName,
})
})
}
}
4 changes: 4 additions & 0 deletions cli/pkg/release/releaser.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
ReleaserTypeCue ReleaserType = "cue"
ReleaserTypeDocker ReleaserType = "docker"
ReleaserTypeGithub ReleaserType = "github"
ReleaserTypeKCL ReleaserType = "kcl"
ReleaserTypeTimoni ReleaserType = "timoni"
)

Expand Down Expand Up @@ -54,6 +55,9 @@ func NewDefaultReleaserStore() *ReleaserStore {
ReleaserTypeGithub: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
return providers.NewGithubReleaser(ctx, project, name, force)
},
ReleaserTypeKCL: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
return providers.NewKCLReleaser(ctx, project, name, force)
},
ReleaserTypeTimoni: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
return providers.NewTimoniReleaser(ctx, project, name, force)
},
Expand Down

0 comments on commit aa2f9e4

Please sign in to comment.