Skip to content

Commit aa2f9e4

Browse files
authored
feat: adds KCL release (#114)
1 parent 4b88990 commit aa2f9e4

File tree

4 files changed

+341
-1
lines changed

4 files changed

+341
-1
lines changed

cli/pkg/release/providers/common.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ package providers
33
import (
44
"fmt"
55
"log/slog"
6+
"regexp"
7+
"strings"
68

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

13+
var ErrConfigNotFound = fmt.Errorf("release config field not found")
14+
1115
// createECRRepoIfNotExists creates an ECR repository if it does not exist.
1216
func createECRRepoIfNotExists(client aws.ECRClient, p *project.Project, registry string, logger *slog.Logger) error {
1317
name, err := aws.ExtractECRRepoName(registry)
@@ -30,9 +34,51 @@ func createECRRepoIfNotExists(client aws.ECRClient, p *project.Project, registry
3034
return nil
3135
}
3236

37+
// generateContainerName generates the container name for the project.
38+
// If the name is not provided, the project name is used.
39+
func generateContainerName(p *project.Project, name string, registry string) string {
40+
var n string
41+
if name == "" {
42+
n = p.Name
43+
} else {
44+
n = name
45+
}
46+
47+
if isGHCRRegistry(registry) {
48+
return fmt.Sprintf("%s/%s", strings.TrimSuffix(registry, "/"), n)
49+
} else {
50+
var repo string
51+
if strings.Contains(p.Blueprint.Global.Repo.Name, "/") {
52+
repo = strings.Split(p.Blueprint.Global.Repo.Name, "/")[1]
53+
} else {
54+
repo = p.Blueprint.Global.Repo.Name
55+
}
56+
57+
return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(registry, "/"), repo, n)
58+
}
59+
}
60+
61+
// isECRRegistry checks if the registry is an ECR registry.
62+
func isECRRegistry(registry string) bool {
63+
return regexp.MustCompile(`^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com`).MatchString(registry)
64+
}
65+
66+
// isGHCRRegistry checks if the registry is a GHCR registry.
67+
func isGHCRRegistry(registry string) bool {
68+
return regexp.MustCompile(`^ghcr\.io/[a-zA-Z0-9](?:-?[a-zA-Z0-9])*$`).MatchString(registry)
69+
}
70+
3371
// parseConfig parses the configuration for the release.
3472
func parseConfig(p *project.Project, release string, config any) error {
35-
return p.Raw().DecodePath(fmt.Sprintf("project.release.%s.config", release), &config)
73+
err := p.Raw().DecodePath(fmt.Sprintf("project.release.%s.config", release), &config)
74+
75+
if err != nil && strings.Contains(err.Error(), "not found") {
76+
return ErrConfigNotFound
77+
} else if err != nil {
78+
return err
79+
}
80+
81+
return nil
3682
}
3783

3884
// getPlatforms returns the platforms for the target.

cli/pkg/release/providers/kcl.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package providers
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
7+
"github.com/input-output-hk/catalyst-forge/cli/pkg/events"
8+
"github.com/input-output-hk/catalyst-forge/cli/pkg/executor"
9+
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws"
10+
"github.com/input-output-hk/catalyst-forge/cli/pkg/run"
11+
"github.com/input-output-hk/catalyst-forge/lib/project/project"
12+
"github.com/input-output-hk/catalyst-forge/lib/project/schema"
13+
)
14+
15+
const (
16+
KCL_BINARY = "kcl"
17+
)
18+
19+
type KCLReleaserConfig struct {
20+
Container string `json:"container"`
21+
}
22+
23+
type KCLReleaser struct {
24+
config KCLReleaserConfig
25+
ecr aws.ECRClient
26+
force bool
27+
handler events.EventHandler
28+
kcl executor.WrappedExecuter
29+
logger *slog.Logger
30+
project project.Project
31+
release schema.Release
32+
releaseName string
33+
}
34+
35+
func (r *KCLReleaser) Release() error {
36+
if !r.handler.Firing(&r.project, r.project.GetReleaseEvents(r.releaseName)) && !r.force {
37+
r.logger.Info("No release event is firing, skipping release")
38+
return nil
39+
}
40+
41+
registries := r.project.Blueprint.Global.CI.Providers.KCL.Registries
42+
if len(registries) == 0 {
43+
return fmt.Errorf("must specify at least one KCL registry")
44+
}
45+
46+
for _, registry := range registries {
47+
container := generateContainerName(&r.project, r.config.Container, registry)
48+
path, err := r.project.GetRelativePath()
49+
if err != nil {
50+
return fmt.Errorf("failed to get relative path: %w", err)
51+
}
52+
53+
if isECRRegistry(registry) {
54+
r.logger.Info("Detected ECR registry, checking if repository exists", "repository", container)
55+
if err := createECRRepoIfNotExists(r.ecr, &r.project, container, r.logger); err != nil {
56+
return fmt.Errorf("failed to create ECR repository: %w", err)
57+
}
58+
}
59+
60+
r.logger.Info("Publishing module", "path", path, "container", container)
61+
out, err := r.kcl.Execute("mod", "push", fmt.Sprintf("oci://%s", container))
62+
if err != nil {
63+
r.logger.Error("Failed to push module", "module", container, "error", err, "output", string(out))
64+
return fmt.Errorf("failed to push module: %w", err)
65+
}
66+
}
67+
68+
return nil
69+
}
70+
71+
// NewKCLReleaser creates a new KCL release provider.
72+
func NewKCLReleaser(ctx run.RunContext,
73+
project project.Project,
74+
name string,
75+
force bool,
76+
) (*KCLReleaser, error) {
77+
release, ok := project.Blueprint.Project.Release[name]
78+
if !ok {
79+
return nil, fmt.Errorf("unknown release: %s", name)
80+
}
81+
82+
exec := executor.NewLocalExecutor(ctx.Logger, executor.WithWorkdir(project.Path))
83+
if _, ok := exec.LookPath(KCL_BINARY); ok != nil {
84+
return nil, fmt.Errorf("failed to find KCL binary: %w", ok)
85+
}
86+
87+
var config KCLReleaserConfig
88+
err := parseConfig(&project, name, &config)
89+
if err != nil && err != ErrConfigNotFound {
90+
return nil, fmt.Errorf("failed to parse release config: %w", err)
91+
}
92+
93+
ecr, err := aws.NewECRClient(ctx.Logger)
94+
if err != nil {
95+
return nil, fmt.Errorf("failed to create ECR client: %w", err)
96+
}
97+
98+
kcl := executor.NewLocalWrappedExecutor(exec, "kcl")
99+
handler := events.NewDefaultEventHandler(ctx.Logger)
100+
return &KCLReleaser{
101+
config: config,
102+
ecr: ecr,
103+
force: force,
104+
handler: &handler,
105+
logger: ctx.Logger,
106+
kcl: kcl,
107+
project: project,
108+
release: release,
109+
releaseName: name,
110+
}, nil
111+
}

cli/pkg/release/providers/kcl_test.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package providers
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/aws/aws-sdk-go-v2/service/ecr"
9+
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws"
10+
"github.com/input-output-hk/catalyst-forge/cli/pkg/providers/aws/mocks"
11+
"github.com/input-output-hk/catalyst-forge/lib/project/project"
12+
"github.com/input-output-hk/catalyst-forge/lib/project/schema"
13+
"github.com/input-output-hk/catalyst-forge/lib/tools/testutils"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestKCLReleaserRelease(t *testing.T) {
19+
type testResults struct {
20+
calls []string
21+
err error
22+
repoName string
23+
}
24+
25+
newProject := func(
26+
name string,
27+
registries []string,
28+
) project.Project {
29+
return project.Project{
30+
Name: name,
31+
Blueprint: schema.Blueprint{
32+
Global: schema.Global{
33+
CI: schema.GlobalCI{
34+
Providers: schema.Providers{
35+
KCL: schema.ProviderKCL{
36+
Registries: registries,
37+
},
38+
},
39+
},
40+
Repo: schema.GlobalRepo{
41+
Name: "repo",
42+
},
43+
},
44+
},
45+
}
46+
}
47+
48+
tests := []struct {
49+
name string
50+
project project.Project
51+
release schema.Release
52+
config KCLReleaserConfig
53+
firing bool
54+
force bool
55+
failOn string
56+
validate func(t *testing.T, r testResults)
57+
}{
58+
{
59+
name: "full",
60+
project: newProject("test", []string{"test.com"}),
61+
release: schema.Release{},
62+
config: KCLReleaserConfig{
63+
Container: "name",
64+
},
65+
firing: true,
66+
force: false,
67+
failOn: "",
68+
validate: func(t *testing.T, r testResults) {
69+
require.NoError(t, r.err)
70+
assert.Contains(t, r.calls, "mod push oci://test.com/repo/name")
71+
},
72+
},
73+
{
74+
name: "ECR",
75+
project: newProject("test", []string{"123456789012.dkr.ecr.us-west-2.amazonaws.com"}),
76+
release: schema.Release{},
77+
config: KCLReleaserConfig{
78+
Container: "name",
79+
},
80+
firing: true,
81+
force: false,
82+
failOn: "",
83+
validate: func(t *testing.T, r testResults) {
84+
require.NoError(t, r.err)
85+
assert.Contains(t, r.calls, "mod push oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/repo/name")
86+
assert.Equal(t, "repo/name", r.repoName)
87+
},
88+
},
89+
{
90+
name: "no container",
91+
project: newProject("test", []string{"test.com"}),
92+
release: schema.Release{},
93+
config: KCLReleaserConfig{},
94+
firing: true,
95+
force: false,
96+
failOn: "",
97+
validate: func(t *testing.T, r testResults) {
98+
require.NoError(t, r.err)
99+
assert.Contains(t, r.calls, "mod push oci://test.com/repo/test")
100+
},
101+
},
102+
{
103+
name: "not firing",
104+
project: newProject("test", []string{"test.com"}),
105+
firing: false,
106+
force: false,
107+
failOn: "",
108+
validate: func(t *testing.T, r testResults) {
109+
require.NoError(t, r.err)
110+
assert.Len(t, r.calls, 0)
111+
},
112+
},
113+
{
114+
name: "forced",
115+
project: newProject("test", []string{"test.com"}),
116+
release: schema.Release{},
117+
config: KCLReleaserConfig{
118+
Container: "test",
119+
},
120+
firing: false,
121+
force: true,
122+
failOn: "",
123+
validate: func(t *testing.T, r testResults) {
124+
require.NoError(t, r.err)
125+
assert.Contains(t, r.calls, "mod push oci://test.com/repo/test")
126+
},
127+
},
128+
{
129+
name: "push fails",
130+
project: newProject("test", []string{"test.com"}),
131+
release: schema.Release{},
132+
config: KCLReleaserConfig{
133+
Container: "test",
134+
},
135+
firing: true,
136+
force: false,
137+
failOn: "mod push",
138+
validate: func(t *testing.T, r testResults) {
139+
require.Error(t, r.err)
140+
},
141+
},
142+
}
143+
144+
for _, tt := range tests {
145+
t.Run(tt.name, func(t *testing.T) {
146+
var repoName string
147+
mock := mocks.AWSECRClientMock{
148+
CreateRepositoryFunc: func(ctx context.Context, params *ecr.CreateRepositoryInput, optFns ...func(*ecr.Options)) (*ecr.CreateRepositoryOutput, error) {
149+
repoName = *params.RepositoryName
150+
return &ecr.CreateRepositoryOutput{}, nil
151+
},
152+
DescribeRepositoriesFunc: func(ctx context.Context, params *ecr.DescribeRepositoriesInput, optFns ...func(*ecr.Options)) (*ecr.DescribeRepositoriesOutput, error) {
153+
return nil, fmt.Errorf("RepositoryNotFoundException")
154+
},
155+
}
156+
ecr := aws.NewCustomECRClient(&mock, testutils.NewNoopLogger())
157+
158+
var calls []string
159+
kcl := KCLReleaser{
160+
config: tt.config,
161+
ecr: ecr,
162+
force: tt.force,
163+
handler: newReleaseEventHandlerMock(tt.firing),
164+
kcl: newWrappedExecuterMock(&calls, tt.failOn),
165+
logger: testutils.NewNoopLogger(),
166+
project: tt.project,
167+
release: tt.release,
168+
}
169+
170+
err := kcl.Release()
171+
172+
tt.validate(t, testResults{
173+
calls: calls,
174+
err: err,
175+
repoName: repoName,
176+
})
177+
})
178+
}
179+
}

cli/pkg/release/releaser.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const (
1414
ReleaserTypeCue ReleaserType = "cue"
1515
ReleaserTypeDocker ReleaserType = "docker"
1616
ReleaserTypeGithub ReleaserType = "github"
17+
ReleaserTypeKCL ReleaserType = "kcl"
1718
ReleaserTypeTimoni ReleaserType = "timoni"
1819
)
1920

@@ -54,6 +55,9 @@ func NewDefaultReleaserStore() *ReleaserStore {
5455
ReleaserTypeGithub: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
5556
return providers.NewGithubReleaser(ctx, project, name, force)
5657
},
58+
ReleaserTypeKCL: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
59+
return providers.NewKCLReleaser(ctx, project, name, force)
60+
},
5761
ReleaserTypeTimoni: func(ctx run.RunContext, project project.Project, name string, force bool) (Releaser, error) {
5862
return providers.NewTimoniReleaser(ctx, project, name, force)
5963
},

0 commit comments

Comments
 (0)