diff --git a/cli/blueprint.cue b/cli/blueprint.cue index 2001553e..95370d14 100644 --- a/cli/blueprint.cue +++ b/cli/blueprint.cue @@ -21,10 +21,6 @@ project: { config: { name: string | *"dev" @forge(name="GIT_TAG") prefix: project.name - token: { - provider: "env" - path: "GITHUB_TOKEN" - } } } } diff --git a/cli/cmd/cmds/module/deploy.go b/cli/cmd/cmds/module/deploy.go index 7954103d..45d0f09a 100644 --- a/cli/cmd/cmds/module/deploy.go +++ b/cli/cmd/cmds/module/deploy.go @@ -3,9 +3,9 @@ package module import ( "fmt" - "github.com/input-output-hk/catalyst-forge/cli/pkg/deployment" "github.com/input-output-hk/catalyst-forge/cli/pkg/events" "github.com/input-output-hk/catalyst-forge/cli/pkg/run" + "github.com/input-output-hk/catalyst-forge/lib/project/deployment/deployer" ) type DeployCmd struct { @@ -26,18 +26,14 @@ func (c *DeployCmd) Run(ctx run.RunContext) error { dryrun = true } - deployer := deployment.NewGitopsDeployer(&project, &ctx.SecretStore, ctx.DeploymentGenerator, ctx.Logger, dryrun) - if err := deployer.Load(); err != nil { - return fmt.Errorf("could not load deployer: %w", err) - } - - if err := deployer.Deploy(); err != nil { - if err == deployment.ErrNoChanges { + d := deployer.NewDeployer(&project, ctx.ManifestGenerator, ctx.Logger, dryrun) + if err := d.Deploy(); err != nil { + if err == deployer.ErrNoChanges { ctx.Logger.Warn("no changes to deploy") return nil } - return fmt.Errorf("could not deploy project: %w", err) + return fmt.Errorf("failed deploying project: %w", err) } return nil diff --git a/cli/cmd/cmds/module/template.go b/cli/cmd/cmds/module/template.go index 7e651acf..ba4759d8 100644 --- a/cli/cmd/cmds/module/template.go +++ b/cli/cmd/cmds/module/template.go @@ -17,11 +17,9 @@ func (c *TemplateCmd) Run(ctx run.RunContext) error { return fmt.Errorf("could not load project: %w", err) } - registry := project.Blueprint.Global.Deployment.Registries.Modules - instance := project.Name modules := project.Blueprint.Project.Deployment.Modules - result, err := ctx.DeploymentGenerator.GenerateBundle(modules, instance, registry) + result, err := ctx.DeploymentGenerator.GenerateBundle(modules) if err != nil { return fmt.Errorf("failed to generate manifests: %w", err) } diff --git a/cli/cmd/main.go b/cli/cmd/main.go index f29aa420..59eb19e4 100644 --- a/cli/cmd/main.go +++ b/cli/cmd/main.go @@ -91,7 +91,8 @@ func Run() int { } logger := slog.New(handler) - loader := project.NewDefaultProjectLoader(logger) + store := secrets.NewDefaultSecretStore() + loader := project.NewDefaultProjectLoader(store, logger) gen := generator.NewGenerator(kcl.NewKCLManifestGenerator(logger), logger) runctx := run.RunContext{ CI: cli.GlobalArgs.CI, @@ -99,8 +100,9 @@ func Run() int { FSWalker: walker.NewDefaultFSWalker(logger), Local: cli.GlobalArgs.Local, Logger: logger, + ManifestGenerator: kcl.NewKCLManifestGenerator(logger), ProjectLoader: &loader, - SecretStore: secrets.NewDefaultSecretStore(), + SecretStore: store, Verbose: cli.GlobalArgs.Verbose, } ctx.Bind(runctx) diff --git a/cli/go.mod b/cli/go.mod index 03af6d38..1a511596 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -13,8 +13,6 @@ require ( github.com/charmbracelet/bubbletea v1.1.1 github.com/charmbracelet/lipgloss v0.13.0 github.com/charmbracelet/log v0.4.0 - github.com/go-git/go-billy/v5 v5.5.0 - github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-github/v66 v66.0.0 github.com/input-output-hk/catalyst-forge/lib/project v0.0.0 github.com/input-output-hk/catalyst-forge/lib/tools v0.0.0 @@ -91,6 +89,8 @@ require ( github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-git/v5 v5.12.0 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/cli/pkg/deployment/gitops.go b/cli/pkg/deployment/gitops.go deleted file mode 100644 index 58391c89..00000000 --- a/cli/pkg/deployment/gitops.go +++ /dev/null @@ -1,304 +0,0 @@ -package deployment - -import ( - "fmt" - "io" - "log/slog" - "os" - "path/filepath" - "time" - - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/go-git/go-git/v5/storage" - "github.com/go-git/go-git/v5/storage/memory" - "github.com/input-output-hk/catalyst-forge/lib/project/deployment/generator" - "github.com/input-output-hk/catalyst-forge/lib/project/project" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets" -) - -const GIT_NAME = "Catalyst Forge" -const GIT_EMAIL = "forge@projectcatalyst.io" -const GIT_MESSAGE = "chore: automatic deployment for %s" - -var ErrNoChanges = fmt.Errorf("no changes to commit") - -// gitRemoteInterface is an interface for interacting with a git remote. -type gitRemoteInterface interface { - Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) - Push(repo *git.Repository, o *git.PushOptions) error -} - -// gitRemote is a concrete implementation of gitRemoteInterface. -type gitRemote struct{} - -func (g gitRemote) Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) { - return git.Clone(s, worktree, o) -} - -func (g gitRemote) Push(repo *git.Repository, o *git.PushOptions) error { - return repo.Push(o) -} - -// GitopsDeployer is a deployer that deploys projects to a GitOps repository. -type GitopsDeployer struct { - dryrun bool - fs billy.Filesystem - gen generator.Generator - logger *slog.Logger - project *project.Project - remote gitRemoteInterface - repo *git.Repository - secretStore *secrets.SecretStore - token string - worktree *git.Worktree -} - -func (g *GitopsDeployer) Deploy() error { - if (g.repo == nil) || (g.worktree == nil) { - return fmt.Errorf("must load repository before calling Deploy") - } - - globalDeploy := g.project.Blueprint.Global.Deployment - envPath := filepath.Join(globalDeploy.Root, globalDeploy.Environment, "apps") - prjPath := filepath.Join(envPath, g.project.Name) - bundlePath := filepath.Join(prjPath, "bundle.cue") - - g.logger.Info("Checking if environment path exists", "path", envPath) - exists, err := fileExists(g.fs, envPath) - if err != nil { - return fmt.Errorf("could not check if path exists: %w", err) - } else if !exists { - return fmt.Errorf("environment path does not exist: %s", envPath) - } - - g.logger.Info("Checking if project path exists", "path", prjPath) - exists, err = fileExists(g.fs, prjPath) - if err != nil { - return fmt.Errorf("could not check if path exists: %w", err) - } else if !exists { - g.logger.Info("Creating project path", "path", prjPath) - err = g.fs.MkdirAll(prjPath, os.ModePerm) - if err != nil { - return fmt.Errorf("could not create project path: %w", err) - } - } - - registry := g.project.Blueprint.Global.Deployment.Registries.Modules - instance := g.project.Name - - g.logger.Info("Clearing project path", "path", prjPath) - files, err := g.fs.ReadDir(prjPath) - if err != nil { - return fmt.Errorf("could not read project path: %w", err) - } - - for _, f := range files { - path := filepath.Join(prjPath, f.Name()) - g.logger.Debug("Removing file", "path", path) - if err := g.fs.Remove(path); err != nil { - return fmt.Errorf("could not remove file: %w", err) - } - } - - g.logger.Info("Generating manifests") - result, err := g.gen.GenerateBundle(g.project.Blueprint.Project.Deployment.Modules, instance, registry) - if err != nil { - return fmt.Errorf("could not generate deployment manifests: %w", err) - } - - for n, r := range result { - mpath := filepath.Join(prjPath, fmt.Sprintf("%s.yaml", n)) - vpath := filepath.Join(prjPath, fmt.Sprintf("%s.mod.cue", n)) - - g.logger.Info("Writing manifest", "path", mpath) - if err := g.write(mpath, []byte(r.Manifests)); err != nil { - return fmt.Errorf("could not write manifest: %w", err) - } - - g.logger.Info("Writing values", "path", vpath) - if err := g.write(vpath, []byte(r.Module)); err != nil { - return fmt.Errorf("could not write values: %w", err) - } - } - - if !g.dryrun { - changes, err := g.hasChanges() - if err != nil { - return fmt.Errorf("could not check if worktree has changes: %w", err) - } else if !changes { - return ErrNoChanges - } - - g.logger.Info("Committing changes", "path", bundlePath) - if err := g.commit(); err != nil { - return fmt.Errorf("could not commit changes: %w", err) - } - - g.logger.Info("Pushing changes") - if err := g.push(); err != nil { - return fmt.Errorf("could not push changes: %w", err) - } - } else { - g.logger.Info("Dry-run: not committing or pushing changes") - g.logger.Info("Dumping manifests") - for _, r := range result { - fmt.Println(string(r.Manifests)) - } - } - - return nil -} - -// Load loads the repository for the project. -func (g *GitopsDeployer) Load() error { - var err error - url := g.project.Blueprint.Global.Deployment.Repo.Url - ref := g.project.Blueprint.Global.Deployment.Repo.Ref - - g.token, err = GetGitToken(g.project, g.secretStore, g.logger) - if err != nil { - return fmt.Errorf("could not get git provider token: %w", err) - } - - if err := g.clone(url, ref); err != nil { - return fmt.Errorf("could not clone repository: %w", err) - } - - g.worktree, err = g.repo.Worktree() - if err != nil { - return fmt.Errorf("could not get repository worktree: %w", err) - } - - return nil -} - -// addFile adds a file to the current worktree. -func (g *GitopsDeployer) addFile(path string) error { - _, err := g.worktree.Add(path) - if err != nil { - return err - } - - return nil -} - -// clone clones the repository at the given URL and ref. -func (g *GitopsDeployer) clone(url, ref string) error { - var err error - ref = fmt.Sprintf("refs/heads/%s", ref) - - g.logger.Debug("Cloning repository", "url", url, "ref", ref) - g.repo, err = g.remote.Clone(memory.NewStorage(), g.fs, &git.CloneOptions{ - URL: url, - Depth: 1, - ReferenceName: plumbing.ReferenceName(ref), - Auth: &http.BasicAuth{ - Username: "forge", // Note: this field is not used, but it cannot be empty. - Password: g.token, - }, - }) - if err != nil { - return err - } - - return nil -} - -// commit commits all changes in the current worktree. -func (g *GitopsDeployer) commit() error { - msg := fmt.Sprintf(GIT_MESSAGE, g.project.Name) - _, err := g.worktree.Commit(msg, &git.CommitOptions{ - Author: &object.Signature{ - Name: GIT_NAME, - Email: GIT_EMAIL, - When: time.Now(), - }, - }) - if err != nil { - return err - } - - return nil -} - -// hasChanges returns true if the current worktree has changes. -func (g *GitopsDeployer) hasChanges() (bool, error) { - status, err := g.worktree.Status() - if err != nil { - return false, err - } - - return !status.IsClean(), nil -} - -// push pushes the current worktree to the remote repository. -func (g *GitopsDeployer) push() error { - return g.remote.Push(g.repo, &git.PushOptions{ - Auth: &http.BasicAuth{ - Username: "forge", // Note: this field is not used, but it cannot be empty. - Password: g.token, - }, - }) -} - -// write writes the given contents to the given path in the filesystem. -// It also adds the file to the current worktree. -func (g *GitopsDeployer) write(path string, contents []byte) error { - vfile, err := g.fs.Create(path) - if err != nil { - return fmt.Errorf("could not create file: %w", err) - } - - _, err = vfile.Write([]byte(contents)) - if err != nil { - return fmt.Errorf("could not write to file: %w", err) - } - - if err := g.addFile(path); err != nil { - return fmt.Errorf("could not add file to worktree: %w", err) - } - - return nil -} - -// NewGitopsDeployer creates a new GitopsDeployer. -func NewGitopsDeployer( - project *project.Project, - store *secrets.SecretStore, - generator generator.Generator, - logger *slog.Logger, - dryrun bool, -) GitopsDeployer { - if logger == nil { - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - } - - return GitopsDeployer{ - dryrun: dryrun, - fs: memfs.New(), - gen: generator, - logger: logger, - project: project, - remote: gitRemote{}, - secretStore: store, - } -} - -// fileExists checks if a file exists in the given filesystem. -func fileExists(fs billy.Filesystem, path string) (bool, error) { - _, err := fs.Stat(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } else { - return false, fmt.Errorf("could not stat path: %w", err) - } - } - - return true, nil -} diff --git a/cli/pkg/deployment/gitops_test.go b/cli/pkg/deployment/gitops_test.go deleted file mode 100644 index c3e50593..00000000 --- a/cli/pkg/deployment/gitops_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package deployment - -import ( - "fmt" - "log/slog" - "testing" - - "cuelang.org/go/cue/cuecontext" - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/go-git/go-git/v5/storage" - "github.com/input-output-hk/catalyst-forge/lib/project/deployment/generator" - dmock "github.com/input-output-hk/catalyst-forge/lib/project/deployment/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/project/secrets" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets/mocks" - "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type mockGitRemote struct { - cloneErr error - cloneOpts *git.CloneOptions - pushErr error - pushOpts *git.PushOptions - repo *git.Repository -} - -func (m *mockGitRemote) Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) { - m.cloneOpts = o - if m.cloneErr != nil { - return nil, m.cloneErr - } - - return m.repo, nil -} - -func (m *mockGitRemote) Push(repo *git.Repository, o *git.PushOptions) error { - m.pushOpts = o - if m.pushErr != nil { - return m.pushErr - } - - return nil -} - -func TestDeploy(t *testing.T) { - defaultParams := projectParams{ - projectName: "test", - globalDeploy: schema.GlobalDeployment{ - Environment: "dev", - Registries: schema.GlobalDeploymentRegistries{ - Modules: "registry.myserver.com", - }, - Repo: schema.GlobalDeploymentRepo{ - Ref: "main", - Url: "https://github.com/foo/bar", - }, - Root: "deploy", - }, - globalProvider: schema.ProviderGit{ - Credentials: &schema.Secret{ - Provider: "mock", - Path: "test", - }, - }, - container: "mycontainer", - namespace: "default", - values: `foo: "bar"`, - version: "1.0.0", - } - - tests := []struct { - name string - mock mockGitRemote - project projectParams - yaml string - execFail bool - dryrun bool - setup func(*testing.T, *GitopsDeployer, *testutils.InMemRepo) - validate func(*testing.T, *GitopsDeployer, mockGitRemote, *testutils.InMemRepo) - expectErr bool - expectedErr string - }{ - { - name: "valid", - mock: mockGitRemote{}, - project: defaultParams, - yaml: "yaml", - execFail: false, - dryrun: false, - setup: func(t *testing.T, deployer *GitopsDeployer, repo *testutils.InMemRepo) { - deployer.token = "test" - repo.MkdirAll(t, "deploy/dev/apps") - }, - validate: func(t *testing.T, deployer *GitopsDeployer, mock mockGitRemote, repo *testutils.InMemRepo) { - assert.True(t, repo.Exists(t, "deploy/dev/apps/test/main.yaml"), "main.yaml does not exist") - assert.Equal(t, repo.ReadFile(t, "deploy/dev/apps/test/main.yaml"), []byte("yaml"), "main.yaml content is incorrect") - - head, err := repo.Repo.Head() - require.NoError(t, err, "failed to get head") - commit, err := repo.Repo.CommitObject(head.Hash()) - assert.NoError(t, err, "failed to get commit") - assert.Equal(t, commit.Message, "chore: automatic deployment for test") - - assert.Equal(t, mock.pushOpts.Auth.(*http.BasicAuth).Password, "test") - }, - expectErr: false, - expectedErr: "", - }, - { - name: "dry-run", - mock: mockGitRemote{}, - project: defaultParams, - yaml: "yaml", - execFail: false, - dryrun: true, - setup: func(t *testing.T, deployer *GitopsDeployer, repo *testutils.InMemRepo) { - deployer.token = "test" - repo.MkdirAll(t, "deploy/dev/apps") - }, - validate: func(t *testing.T, deployer *GitopsDeployer, mock mockGitRemote, repo *testutils.InMemRepo) { - assert.True(t, repo.Exists(t, "deploy/dev/apps/test/main.yaml"), "main.yaml does not exist") - assert.Equal(t, repo.ReadFile(t, "deploy/dev/apps/test/main.yaml"), []byte("yaml"), "main.yaml content is incorrect") - - _, err := repo.Repo.Head() - require.Error(t, err) // No commit should be made - }, - expectErr: false, - expectedErr: "", - }, - { - name: "no changes", - mock: mockGitRemote{}, - project: defaultParams, - yaml: "yaml", - execFail: false, - setup: func(t *testing.T, deployer *GitopsDeployer, repo *testutils.InMemRepo) { - mod := `{ - name: "mycontainer" - namespace: "default" - values: { - foo: "bar" - } - version: "1.0.0" -}` - repo.MkdirAll(t, "deploy/dev/apps/test") - repo.AddFile(t, "deploy/dev/apps/test/main.yaml", string("yaml")) - repo.AddFile(t, "deploy/dev/apps/test/main.mod.cue", mod) - repo.Commit(t, "initial commit") - }, - validate: func(t *testing.T, deployer *GitopsDeployer, mock mockGitRemote, repo *testutils.InMemRepo) {}, - expectErr: true, - expectedErr: ErrNoChanges.Error(), - }, - { - name: "extra files", - mock: mockGitRemote{}, - project: defaultParams, - yaml: "yaml", - execFail: false, - setup: func(t *testing.T, deployer *GitopsDeployer, repo *testutils.InMemRepo) { - mod := `{ - name: "mycontainer" - namespace: "default" - values: { - foo: "bar" - } - version: "1.0.0" -}` - repo.MkdirAll(t, "deploy/dev/apps/test") - repo.AddFile(t, "deploy/dev/apps/test/main.yaml", string("yaml")) - repo.AddFile(t, "deploy/dev/apps/test/main.mod.cue", mod) - repo.AddFile(t, "deploy/dev/apps/test/bad.yaml", string("bad")) - repo.Commit(t, "initial commit") - }, - validate: func(t *testing.T, deployer *GitopsDeployer, mock mockGitRemote, repo *testutils.InMemRepo) { - assert.False(t, repo.Exists(t, "deploy/dev/apps/test/bad.yaml"), "bad.yaml does not exist") - }, - expectErr: false, - expectedErr: "", - }, - { - name: "no environment folder", - mock: mockGitRemote{}, - project: defaultParams, - setup: func(t *testing.T, deployer *GitopsDeployer, repo *testutils.InMemRepo) {}, - validate: func(t *testing.T, deployer *GitopsDeployer, mock mockGitRemote, repo *testutils.InMemRepo) {}, - expectErr: true, - expectedErr: "environment path does not exist: deploy/dev/apps", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - repo := testutils.NewInMemRepo(t) - gen := generator.NewGenerator(&dmock.ManifestGeneratorMock{ - GenerateFunc: func(mod schema.DeploymentModule, instance, registry string) ([]byte, error) { - return []byte(tt.yaml), nil - }, - }, testutils.NewNoopLogger()) - deployer := GitopsDeployer{ - dryrun: tt.dryrun, - fs: repo.Fs, - gen: gen, - logger: testutils.NewNoopLogger(), - project: newTestProject(tt.project), - remote: &tt.mock, - repo: repo.Repo, - secretStore: nil, - worktree: repo.Worktree, - } - - tt.setup(t, &deployer, &repo) - - err := deployer.Deploy() - if testutils.AssertError(t, err, tt.expectErr, tt.expectedErr) { - return - } - - tt.validate(t, &deployer, tt.mock, &repo) - }) - } -} - -func TestLoad(t *testing.T) { - defaultParams := projectParams{ - projectName: "test", - globalDeploy: schema.GlobalDeployment{ - Environment: "dev", - Registries: schema.GlobalDeploymentRegistries{ - Modules: "registry.myserver.com", - }, - Repo: schema.GlobalDeploymentRepo{ - Ref: "main", - Url: "https://github.com/foo/bar", - }, - Root: "deploy", - }, - globalProvider: schema.ProviderGit{ - Credentials: &schema.Secret{ - Provider: "mock", - Path: "test", - }, - }, - container: "mycontainer", - namespace: "default", - values: `foo: "bar"`, - version: "1.0.0", - } - - tests := []struct { - name string - mock mockGitRemote - project projectParams - store *secrets.SecretStore - validate func(t *testing.T, mock mockGitRemote, project *project.Project, deployer *GitopsDeployer) - expectErr bool - expectedErr string - }{ - { - name: "valid", - mock: mockGitRemote{}, - project: defaultParams, - store: newMockSecretStore("test", `{"token":"test"}`), - validate: func(t *testing.T, mock mockGitRemote, project *project.Project, deployer *GitopsDeployer) { - assert.Equal(t, deployer.token, "test") - assert.Equal(t, mock.cloneOpts.URL, project.Blueprint.Global.Deployment.Repo.Url) - assert.Equal(t, string(mock.cloneOpts.ReferenceName), fmt.Sprintf("refs/heads/%s", project.Blueprint.Global.Deployment.Repo.Ref)) - assert.Equal(t, mock.cloneOpts.Auth.(*http.BasicAuth).Password, "test") - }, - expectErr: false, - expectedErr: "", - }, - { - name: "clone error", - mock: mockGitRemote{cloneErr: fmt.Errorf("clone error")}, - project: defaultParams, - store: newMockSecretStore("test", `{"token":"test"}`), - expectErr: true, - expectedErr: "could not clone repository: clone error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.mock.repo = testutils.NewInMemRepo(t).Repo - project := newTestProject(tt.project) - deployer := GitopsDeployer{ - logger: testutils.NewNoopLogger(), - project: project, - remote: &tt.mock, - secretStore: newMockSecretStore("test", `{"token":"test"}`), - } - - err := deployer.Load() - if testutils.AssertError(t, err, tt.expectErr, tt.expectedErr) { - return - } - - tt.validate(t, tt.mock, project, &deployer) - }) - } -} - -type projectParams struct { - projectName string - globalDeploy schema.GlobalDeployment - globalProvider schema.ProviderGit - container string - namespace string - values string - version string -} - -func newTestProject(p projectParams) *project.Project { - ctx := cuecontext.New() - return &project.Project{ - Name: p.projectName, - Blueprint: schema.Blueprint{ - Global: schema.Global{ - Deployment: p.globalDeploy, - CI: schema.GlobalCI{ - Providers: schema.Providers{ - Git: p.globalProvider, - }, - }, - }, - Project: schema.Project{ - Deployment: schema.Deployment{ - Modules: map[string]schema.DeploymentModule{ - "main": { - Name: p.container, - Namespace: p.namespace, - Values: ctx.CompileString(p.values), - Version: p.version, - }, - }, - }, - }, - }, - } -} - -func newMockSecretStore(secretPath string, value string) *secrets.SecretStore { - provider := &mocks.SecretProviderMock{ - GetFunc: func(path string) (string, error) { - if path == secretPath { - return value, nil - } else { - return "", fmt.Errorf("secret not found") - } - }, - } - store := secrets.NewSecretStore(map[secrets.Provider]func(*slog.Logger) (secrets.SecretProvider, error){ - secrets.Provider("mock"): func(logger *slog.Logger) (secrets.SecretProvider, error) { - return provider, nil - }, - }) - - return &store -} diff --git a/cli/pkg/deployment/secret.go b/cli/pkg/deployment/secret.go deleted file mode 100644 index 1c55506c..00000000 --- a/cli/pkg/deployment/secret.go +++ /dev/null @@ -1,36 +0,0 @@ -package deployment - -import ( - "encoding/json" - "fmt" - "log/slog" - - "github.com/input-output-hk/catalyst-forge/lib/project/project" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets" -) - -// GetGitToken loads the Git token from the project. -func GetGitToken(project *project.Project, store *secrets.SecretStore, logger *slog.Logger) (string, error) { - secret := project.Blueprint.Global.CI.Providers.Git.Credentials - if secret == nil { - return "", fmt.Errorf("project does not have a Git provider configured") - } - - strSecret, err := secrets.GetSecret(secret, store, logger) - if err != nil { - return "", fmt.Errorf("could not get secret: %w", err) - } - - creds := struct { - Token string `json:"token"` - }{} - if err := json.Unmarshal([]byte(strSecret), &creds); err != nil { - return "", fmt.Errorf("could not unmarshal secret: %w", err) - } - - if creds.Token == "" { - return "", fmt.Errorf("git provider token is empty") - } - - return creds.Token, nil -} diff --git a/cli/pkg/deployment/secret_test.go b/cli/pkg/deployment/secret_test.go deleted file mode 100644 index 65ecad09..00000000 --- a/cli/pkg/deployment/secret_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package deployment - -import ( - "log/slog" - "testing" - - "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/project/secrets" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets/mocks" - "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" - "github.com/stretchr/testify/assert" -) - -func TestGetGitToken(t *testing.T) { - tests := []struct { - name string - secretValue string - expected string - expectErr bool - expectedErr string - }{ - { - name: "valid secret", - secretValue: `{"token":"foo"}`, - expected: "foo", - expectErr: false, - expectedErr: "", - }, - { - name: "invalid secret", - secretValue: `{"foo":"bar"}`, - expected: "", - expectErr: true, - expectedErr: "git provider token is empty", - }, - { - name: "invalid JSON", - secretValue: `invalid`, - expected: "", - expectErr: true, - expectedErr: "could not unmarshal secret: invalid character 'i' looking for beginning of value", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - provider := &mocks.SecretProviderMock{ - GetFunc: func(path string) (string, error) { - return tt.secretValue, nil - }, - } - store := secrets.NewSecretStore(map[secrets.Provider]func(*slog.Logger) (secrets.SecretProvider, error){ - secrets.Provider("mock"): func(logger *slog.Logger) (secrets.SecretProvider, error) { - return provider, nil - }, - }) - - project := project.Project{ - Blueprint: schema.Blueprint{ - Global: schema.Global{ - CI: schema.GlobalCI{ - Providers: schema.Providers{ - Git: schema.ProviderGit{ - Credentials: &schema.Secret{ - Provider: "mock", - Path: "foo", - }, - }, - }, - }, - }, - }, - } - - token, err := GetGitToken(&project, &store, testutils.NewNoopLogger()) - if testutils.AssertError(t, err, tt.expectErr, tt.expectedErr) { - return - } - - assert.Equal(t, tt.expected, token) - }) - } -} diff --git a/cli/pkg/events/merge_test.go b/cli/pkg/events/merge_test.go index c376e0a5..bb5f6e03 100644 --- a/cli/pkg/events/merge_test.go +++ b/cli/pkg/events/merge_test.go @@ -44,11 +44,16 @@ func TestMergeEventFiring(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - repo := testutils.NewInMemRepo(t) - repo.AddFile(t, "file.txt", "content") - repo.Commit(t, "Initial commit") + repo := testutils.NewTestRepo(t) - repo.NewBranch(t, tt.branch) + err := repo.WriteFile("file.txt", []byte("content")) + require.NoError(t, err) + + _, err = repo.Commit("Initial commit") + require.NoError(t, err) + + err = repo.NewBranch(tt.branch) + require.NoError(t, err) project := project.Project{ Blueprint: schema.Blueprint{ @@ -58,7 +63,7 @@ func TestMergeEventFiring(t *testing.T) { }, }, }, - Repo: repo.Repo, + Repo: &repo, } event := MergeEvent{ diff --git a/cli/pkg/events/tag_test.go b/cli/pkg/events/tag_test.go index f9cf6512..48b20a1c 100644 --- a/cli/pkg/events/tag_test.go +++ b/cli/pkg/events/tag_test.go @@ -6,6 +6,7 @@ import ( "cuelang.org/go/cue" "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/project/secrets" "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" "github.com/stretchr/testify/assert" ) @@ -40,7 +41,6 @@ func TestTagEventFiring(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { project := project.NewProject( - testutils.NewNoopLogger(), nil, nil, nil, @@ -49,6 +49,8 @@ func TestTagEventFiring(t *testing.T) { "", schema.Blueprint{}, tt.tag, + testutils.NewNoopLogger(), + secrets.SecretStore{}, ) event := TagEvent{ diff --git a/cli/pkg/release/providers/github.go b/cli/pkg/release/providers/github.go index 74b9ebef..62ff30db 100644 --- a/cli/pkg/release/providers/github.go +++ b/cli/pkg/release/providers/github.go @@ -12,16 +12,15 @@ import ( "github.com/input-output-hk/catalyst-forge/cli/pkg/events" "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/providers" "github.com/input-output-hk/catalyst-forge/lib/project/schema" - "github.com/input-output-hk/catalyst-forge/lib/project/secrets" "github.com/input-output-hk/catalyst-forge/lib/tools/archive" "github.com/spf13/afero" ) type GithubReleaserConfig struct { - Prefix string `json:"prefix"` - Name string `json:"name"` - Token schema.Secret `json:"token"` + Prefix string `json:"prefix"` + Name string `json:"name"` } type GithubReleaser struct { @@ -199,18 +198,17 @@ func NewGithubReleaser( return nil, fmt.Errorf("failed to parse release config: %w", err) } - token, err := secrets.GetSecret(&config.Token, &ctx.SecretStore, ctx.Logger) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub token: %w", err) - } - fs := afero.NewOsFs() workdir, err := afero.TempDir(fs, "", "catalyst-forge-") if err != nil { return nil, fmt.Errorf("failed to create temporary directory: %w", err) } - client := github.NewClient(nil).WithAuthToken(token) + client, err := providers.NewGithubClient(&project, ctx.Logger) + if err != nil { + return nil, fmt.Errorf("failed to create github client: %w", err) + } + handler := events.NewDefaultEventHandler(ctx.Logger) runner := run.NewDefaultProjectRunner(ctx, &project) return &GithubReleaser{ diff --git a/cli/pkg/run/context.go b/cli/pkg/run/context.go index 47892dfa..ea4f9d4b 100644 --- a/cli/pkg/run/context.go +++ b/cli/pkg/run/context.go @@ -3,6 +3,7 @@ package run import ( "log/slog" + "github.com/input-output-hk/catalyst-forge/lib/project/deployment" "github.com/input-output-hk/catalyst-forge/lib/project/deployment/generator" "github.com/input-output-hk/catalyst-forge/lib/project/project" "github.com/input-output-hk/catalyst-forge/lib/project/secrets" @@ -26,6 +27,9 @@ type RunContext struct { // Logger is the logger to use for logging. Logger *slog.Logger + // ManifestGenerator is the manifest generator to use for generating manifests. + ManifestGenerator deployment.ManifestGenerator + // ProjectLoader is the project loader to use for loading projects. ProjectLoader project.ProjectLoader diff --git a/cli/tui/ci/app.go b/cli/tui/ci/app.go index 16d44d1a..5a687a17 100644 --- a/cli/tui/ci/app.go +++ b/cli/tui/ci/app.go @@ -123,7 +123,7 @@ func Run(scanPath string, logger = slog.New(slog.NewTextHandler(io.Discard, nil)) } - loader := project.NewDefaultProjectLoader(logger) + loader := project.NewDefaultProjectLoader(runctx.SecretStore, logger) if scanPath == "" { scanPath, err := findRoot(".", logger) diff --git a/lib/project/blueprint/defaults/defaults.go b/lib/project/blueprint/defaults/defaults.go index d08f5007..29e0c30f 100644 --- a/lib/project/blueprint/defaults/defaults.go +++ b/lib/project/blueprint/defaults/defaults.go @@ -12,6 +12,7 @@ type DefaultSetter interface { // GetDefaultSetters returns a list of all default setters. func GetDefaultSetters() []DefaultSetter { return []DefaultSetter{ + DeploymentModuleSetter{}, ReleaseTargetSetter{}, } } diff --git a/lib/project/blueprint/defaults/deployment.go b/lib/project/blueprint/defaults/deployment.go new file mode 100644 index 00000000..b8a09cea --- /dev/null +++ b/lib/project/blueprint/defaults/deployment.go @@ -0,0 +1,46 @@ +package defaults + +import ( + "fmt" + + "cuelang.org/go/cue" +) + +// DeploymentModuleSetter sets default values for deployment modules. +type DeploymentModuleSetter struct{} + +func (d DeploymentModuleSetter) SetDefault(v cue.Value) (cue.Value, error) { + projectName, _ := v.LookupPath(cue.ParsePath("project.name")).String() + registry, _ := v.LookupPath(cue.ParsePath("global.deployment.registries.modules")).String() + + modules := v.LookupPath(cue.ParsePath("project.deployment.modules")) + if !modules.Exists() || modules.Err() != nil { + return v, nil + } + + iter, err := modules.Fields() + if err != nil { + return v, fmt.Errorf("failed to iterate deployment modules: %w", err) + } + + for iter.Next() { + moduleName := iter.Selector().String() + module := iter.Value() + + if projectName != "" { + instance := module.LookupPath(cue.ParsePath("instance")) + if !instance.Exists() { + v = v.FillPath(cue.ParsePath(fmt.Sprintf("project.deployment.modules.%s.instance", moduleName)), projectName) + } + } + + if registry != "" { + r := module.LookupPath(cue.ParsePath("registry")) + if !r.Exists() { + v = v.FillPath(cue.ParsePath(fmt.Sprintf("project.deployment.modules.%s.registry", moduleName)), registry) + } + } + } + + return v, nil +} diff --git a/lib/project/deployment/deployer/deployer.go b/lib/project/deployment/deployer/deployer.go new file mode 100644 index 00000000..1013287b --- /dev/null +++ b/lib/project/deployment/deployer/deployer.go @@ -0,0 +1,198 @@ +package deployer + +import ( + "fmt" + "log/slog" + "path/filepath" + + "github.com/input-output-hk/catalyst-forge/lib/project/deployment" + "github.com/input-output-hk/catalyst-forge/lib/project/deployment/generator" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/providers" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo/remote" + "github.com/spf13/afero" +) + +const ( + GIT_NAME = "Catalyst Forge" + GIT_EMAIL = "forge@projectcatalyst.io" + GIT_MESSAGE = "chore: automatic deployment for %s" +) + +var ( + ErrNoChanges = fmt.Errorf("no changes to commit") +) + +// Deployer performs GitOps deployments for projects. +type Deployer struct { + dryrun bool + fs afero.Fs + gen generator.Generator + logger *slog.Logger + project *project.Project + remote remote.GitRemoteInteractor +} + +// DeployProject deploys the manifests for a project to the GitOps repository. +func (d *Deployer) Deploy() error { + r, err := d.clone() + if err != nil { + return err + } + + prjPath := d.buildProjectPath() + + d.logger.Info("Checking if project path exists", "path", prjPath) + if err := d.checkProjectPath(prjPath, &r); err != nil { + return fmt.Errorf("failed checking project path: %w", err) + } + + d.logger.Info("Clearing project path", "path", prjPath) + if err := d.clearProjectPath(prjPath, &r); err != nil { + return fmt.Errorf("could not clear project path: %w", err) + } + + d.logger.Info("Generating manifests") + result, err := d.gen.GenerateBundle(d.project.Blueprint.Project.Deployment.Modules) + if err != nil { + return fmt.Errorf("could not generate deployment manifests: %w", err) + } + + for name, result := range result { + manPath := filepath.Join(prjPath, fmt.Sprintf("%s.yaml", name)) + modPath := filepath.Join(prjPath, fmt.Sprintf("%s.mod.cue", name)) + + d.logger.Info("Writing manifest", "path", manPath) + if err := r.WriteFile(manPath, []byte(result.Manifests)); err != nil { + return fmt.Errorf("could not write manifest: %w", err) + } + if err := r.StageFile(manPath); err != nil { + return fmt.Errorf("could not add manifest to working tree: %w", err) + } + + d.logger.Info("Writing module", "path", modPath) + if err := r.WriteFile(modPath, []byte(result.Module)); err != nil { + return fmt.Errorf("could not write values: %w", err) + } + if err := r.StageFile(modPath); err != nil { + return fmt.Errorf("could not add values to working tree: %w", err) + } + } + + if !d.dryrun { + changes, err := r.HasChanges() + if err != nil { + return fmt.Errorf("could not check if worktree has changes: %w", err) + } else if !changes { + return ErrNoChanges + } + + d.logger.Info("Committing changes") + _, err = r.Commit(fmt.Sprintf(GIT_MESSAGE, d.project.Name)) + if err != nil { + return fmt.Errorf("could not commit changes: %w", err) + } + + d.logger.Info("Pushing changes") + if err := r.Push(); err != nil { + return fmt.Errorf("could not push changes: %w", err) + } + } else { + d.logger.Info("Dry-run: not committing or pushing changes") + d.logger.Info("Dumping manifests") + for _, r := range result { + fmt.Println(string(r.Manifests)) + } + } + + return nil +} + +// buildProjectPath builds the path to the project in the GitOps repository. +func (d *Deployer) buildProjectPath() string { + globalDeploy := d.project.Blueprint.Global.Deployment + envPath := filepath.Join(globalDeploy.Root, globalDeploy.Environment, "apps") + prjPath := filepath.Join(envPath, d.project.Name) + + return prjPath +} + +// checkProjectPath checks if the project path exists and creates it if it does not. +func (d *Deployer) checkProjectPath(path string, r *repo.GitRepo) error { + exists, err := r.Exists(path) + if err != nil { + return fmt.Errorf("could not check if project path exists: %w", err) + } else if !exists { + d.logger.Info("Creating project path", "path", path) + err = r.MkdirAll(path) + if err != nil { + return fmt.Errorf("could not create project path: %w", err) + } + } + + return nil +} + +// clearProjectPath clears the project path in the GitOps repository. +func (d *Deployer) clearProjectPath(path string, r *repo.GitRepo) error { + files, err := r.ReadDir(path) + if err != nil { + return fmt.Errorf("could not read project path: %w", err) + } + + for _, f := range files { + path := filepath.Join(path, f.Name()) + d.logger.Debug("Removing file", "path", path) + if err := r.RemoveFile(path); err != nil { + return fmt.Errorf("could not remove file: %w", err) + } + + if err := r.StageFile(path); err != nil { + return fmt.Errorf("could not add file deletion to working tree: %w", err) + } + } + + return nil +} + +// clone clones the GitOps repository. +func (d *Deployer) clone() (repo.GitRepo, error) { + url := d.project.Blueprint.Global.Deployment.Repo.Url + ref := d.project.Blueprint.Global.Deployment.Repo.Ref + opts := []repo.GitRepoOption{ + repo.WithAuthor(GIT_NAME, GIT_EMAIL), + repo.WithGitRemoteInteractor(d.remote), + repo.WithFS(d.fs), + } + + creds, err := providers.GetGitProviderCreds(d.project, d.logger) + if err != nil { + d.logger.Warn("could not get git provider credentials, not using any authentication", "error", err) + } else { + opts = append(opts, repo.WithAuth("forge", creds.Token)) + } + + d.logger.Info("Cloning repository", "url", url, "ref", ref) + r := repo.NewGitRepo(d.logger, opts...) + if err := r.Clone("/repo", url, ref); err != nil { + return repo.GitRepo{}, fmt.Errorf("could not clone repository: %w", err) + } + + return r, nil +} + +// NewDeployer creates a new Deployer. +func NewDeployer(project *project.Project, mg deployment.ManifestGenerator, logger *slog.Logger, dryrun bool) Deployer { + gen := generator.NewGenerator(mg, logger) + remote := remote.GoGitRemoteInteractor{} + + return Deployer{ + dryrun: dryrun, + gen: gen, + fs: afero.NewMemMapFs(), + logger: logger, + project: project, + remote: remote, + } +} diff --git a/lib/project/deployment/deployer/deployer_test.go b/lib/project/deployment/deployer/deployer_test.go new file mode 100644 index 00000000..2c23587b --- /dev/null +++ b/lib/project/deployment/deployer/deployer_test.go @@ -0,0 +1,273 @@ +package deployer + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/go-git/go-billy/v5" + gg "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage" + "github.com/input-output-hk/catalyst-forge/lib/project/deployment/generator" + dm "github.com/input-output-hk/catalyst-forge/lib/project/deployment/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/project/secrets" + sm "github.com/input-output-hk/catalyst-forge/lib/project/secrets/mocks" + rm "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo/remote/mocks" + "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeployerDeploy(t *testing.T) { + newProject := func(name string, module schema.DeploymentModule) project.Project { + return project.Project{ + Blueprint: schema.Blueprint{ + Project: schema.Project{ + Deployment: schema.Deployment{ + Modules: map[string]schema.DeploymentModule{ + "main": module, + }, + }, + }, + Global: schema.Global{ + Deployment: schema.GlobalDeployment{ + Environment: "test", + Repo: schema.GlobalDeploymentRepo{ + Ref: "main", + Url: "url", + }, + Root: "root", + }, + CI: schema.GlobalCI{ + Providers: schema.Providers{ + Git: schema.ProviderGit{ + Credentials: &schema.Secret{ + Provider: "local", + Path: "key", + }, + }, + }, + }, + }, + }, + Name: name, + } + } + + type testResult struct { + cloneOpts *gg.CloneOptions + deployer Deployer + err error + fs afero.Fs + repo *gg.Repository + } + + tests := []struct { + name string + project project.Project + files map[string]string + dryrun bool + validate func(t *testing.T, r testResult) + }{ + { + name: "success", + project: newProject( + "project", + schema.DeploymentModule{ + Instance: "instance", + Name: "module", + Namespace: "default", + Registry: "registry", + Values: map[string]string{"key": "value"}, + Version: "v1.0.0", + }, + ), + files: nil, + dryrun: false, + validate: func(t *testing.T, r testResult) { + require.NoError(t, r.err) + + e, err := afero.Exists(r.fs, "/repo/root/test/apps/project/main.yaml") + require.NoError(t, err) + assert.True(t, e) + + e, err = afero.Exists(r.fs, "/repo/root/test/apps/project/main.mod.cue") + require.NoError(t, err) + assert.True(t, e) + + c, err := afero.ReadFile(r.fs, "/repo/root/test/apps/project/main.yaml") + require.NoError(t, err) + assert.Equal(t, "manifest", string(c)) + + mod := `{ + instance: "instance" + name: "module" + namespace: "default" + registry: "registry" + values: { + key: "value" + } + version: "v1.0.0" +}` + c, err = afero.ReadFile(r.fs, "/repo/root/test/apps/project/main.mod.cue") + require.NoError(t, err) + assert.Equal(t, mod, string(c)) + + auth := r.cloneOpts.Auth.(*http.BasicAuth) + assert.Equal(t, "value", auth.Password) + assert.Equal(t, "url", r.cloneOpts.URL) + assert.Equal(t, "refs/heads/main", r.cloneOpts.ReferenceName.String()) + + head, err := r.repo.Head() + require.NoError(t, err) + + cm, err := r.repo.CommitObject(head.Hash()) + require.NoError(t, err) + assert.Equal(t, fmt.Sprintf(GIT_MESSAGE, "project"), cm.Message) + assert.Equal(t, GIT_NAME, cm.Author.Name) + assert.Equal(t, GIT_EMAIL, cm.Author.Email) + }, + }, + { + name: "dry run with extra files", + project: newProject( + "project", + schema.DeploymentModule{ + Instance: "instance", + Name: "module", + Namespace: "default", + Registry: "registry", + Values: map[string]string{"key": "value"}, + Version: "v1.0.0", + }, + ), + files: map[string]string{ + "/repo/root/test/apps/project/extra.yaml": "extra", + }, + dryrun: true, + validate: func(t *testing.T, r testResult) { + require.NoError(t, r.err) + + e, err := afero.Exists(r.fs, "/repo/root/test/apps/project/main.yaml") + require.NoError(t, err) + assert.True(t, e) + + e, err = afero.Exists(r.fs, "/repo/root/test/apps/project/main.mod.cue") + require.NoError(t, err) + assert.True(t, e) + + e, err = afero.Exists(r.fs, "/repo/root/test/apps/project/extra.yaml") + require.NoError(t, err) + assert.False(t, e) + + wt, err := r.repo.Worktree() + require.NoError(t, err) + st, err := wt.Status() + require.NoError(t, err) + fst := st.File("root/test/apps/project/extra.yaml") + assert.Equal(t, fst.Staging, gg.Deleted) + + fst = st.File("root/test/apps/project/main.yaml") + assert.Equal(t, fst.Staging, gg.Added) + + fst = st.File("root/test/apps/project/main.mod.cue") + assert.Equal(t, fst.Staging, gg.Added) + + head, err := r.repo.Head() + assert.NoError(t, err) + cm, err := r.repo.CommitObject(head.Hash()) + require.NoError(t, err) + assert.Equal(t, "initial commit", cm.Message) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + var opts *gg.CloneOptions + var repo *gg.Repository + fs := afero.NewMemMapFs() + + remote := &rm.GitRemoteInteractorMock{ + CloneFunc: func(s storage.Storer, worktree billy.Filesystem, o *gg.CloneOptions) (*gg.Repository, error) { + opts = o + repo, err = gg.Init(s, worktree) + require.NoError(t, err, "failed to init repo") + + wt, err := repo.Worktree() + require.NoError(t, err, "failed to get worktree") + + if tt.files != nil { + testutils.SetupFS(t, fs, tt.files) + for path := range tt.files { + _, err := wt.Add(strings.TrimPrefix(path, "/repo/")) + require.NoError(t, err, "failed to add file") + } + + _, err = wt.Commit("initial commit", &gg.CommitOptions{ + Author: &object.Signature{ + Name: GIT_NAME, + Email: GIT_EMAIL, + }, + }) + require.NoError(t, err, "failed to commit") + } + + return repo, nil + }, + PushFunc: func(repo *gg.Repository, o *gg.PushOptions) error { + return nil + }, + } + gen := generator.NewGenerator( + &dm.ManifestGeneratorMock{ + GenerateFunc: func(mod schema.DeploymentModule) ([]byte, error) { + return []byte("manifest"), nil + }, + }, + testutils.NewNoopLogger(), + ) + + provider := func(logger *slog.Logger) (secrets.SecretProvider, error) { + return &sm.SecretProviderMock{ + GetFunc: func(key string) (string, error) { + j, err := json.Marshal(map[string]string{"token": "value"}) + require.NoError(t, err) + return string(j), nil + }, + }, nil + } + tt.project.SecretStore = secrets.NewSecretStore( + map[secrets.Provider]func(*slog.Logger) (secrets.SecretProvider, error){ + secrets.ProviderLocal: provider, + }, + ) + + d := Deployer{ + dryrun: tt.dryrun, + fs: fs, + gen: gen, + logger: testutils.NewStdoutLogger(), + project: &tt.project, + remote: remote, + } + + err = d.Deploy() + tt.validate(t, testResult{ + cloneOpts: opts, + deployer: d, + err: err, + fs: fs, + repo: repo, + }) + }) + } +} diff --git a/lib/project/deployment/generator/generator.go b/lib/project/deployment/generator/generator.go index 4a67d22c..d8a39d23 100644 --- a/lib/project/deployment/generator/generator.go +++ b/lib/project/deployment/generator/generator.go @@ -22,11 +22,11 @@ type Generator struct { } // GenerateBundle generates manifests for a deployment bundle. -func (d *Generator) GenerateBundle(b schema.DeploymentModuleBundle, instance, registry string) (map[string]GeneratorResult, error) { +func (d *Generator) GenerateBundle(b schema.DeploymentModuleBundle) (map[string]GeneratorResult, error) { results := make(map[string]GeneratorResult) for name, module := range b { d.logger.Debug("Generating module", "name", name) - result, err := d.Generate(module, instance, registry) + result, err := d.Generate(module) if err != nil { return nil, fmt.Errorf("failed to generate module %s: %w", name, err) } @@ -38,8 +38,8 @@ func (d *Generator) GenerateBundle(b schema.DeploymentModuleBundle, instance, re } // Generate generates manifests for a deployment module. -func (d *Generator) Generate(m schema.DeploymentModule, instance, registry string) (GeneratorResult, error) { - manifests, err := d.mg.Generate(m, instance, registry) +func (d *Generator) Generate(m schema.DeploymentModule) (GeneratorResult, error) { + manifests, err := d.mg.Generate(m) if err != nil { return GeneratorResult{}, fmt.Errorf("failed to generate manifest for module: %w", err) } diff --git a/lib/project/deployment/generator/generator_test.go b/lib/project/deployment/generator/generator_test.go index 4e1b79bc..39031646 100644 --- a/lib/project/deployment/generator/generator_test.go +++ b/lib/project/deployment/generator/generator_test.go @@ -24,8 +24,10 @@ func TestGeneratorGenerate(t *testing.T) { { name: "full", module: schema.DeploymentModule{ + Instance: "instance", Name: "test", Namespace: "default", + Registry: "registry", Values: ctx.CompileString(`foo: "bar"`), Version: "1.0.0", }, @@ -36,8 +38,10 @@ func TestGeneratorGenerate(t *testing.T) { assert.Equal(t, "test", string(result.Manifests)) m := `{ + instance: "instance" name: "test" namespace: "default" + registry: "registry" values: { foo: "bar" } @@ -79,7 +83,7 @@ func TestGeneratorGenerate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mg := &mocks.ManifestGeneratorMock{ - GenerateFunc: func(mod schema.DeploymentModule, instance, registry string) ([]byte, error) { + GenerateFunc: func(mod schema.DeploymentModule) ([]byte, error) { if tt.err { return nil, fmt.Errorf("error") } @@ -92,7 +96,7 @@ func TestGeneratorGenerate(t *testing.T) { logger: testutils.NewNoopLogger(), } - result, err := gen.Generate(tt.module, "", "") + result, err := gen.Generate(tt.module) tt.validate(t, result, err) }) } diff --git a/lib/project/deployment/manifest_gen.go b/lib/project/deployment/manifest_gen.go index 78cb7a12..2504d38f 100644 --- a/lib/project/deployment/manifest_gen.go +++ b/lib/project/deployment/manifest_gen.go @@ -9,5 +9,5 @@ import ( // ManifestGenerator generates deployment manifests. type ManifestGenerator interface { // Generate generates a deployment manifest for the given module. - Generate(mod schema.DeploymentModule, instance, registry string) ([]byte, error) + Generate(mod schema.DeploymentModule) ([]byte, error) } diff --git a/lib/project/deployment/mocks/manifest.go b/lib/project/deployment/mocks/manifest.go index 56fdb8a1..a40d30ec 100644 --- a/lib/project/deployment/mocks/manifest.go +++ b/lib/project/deployment/mocks/manifest.go @@ -14,7 +14,7 @@ import ( // // // make and configure a mocked deployment.ManifestGenerator // mockedManifestGenerator := &ManifestGeneratorMock{ -// GenerateFunc: func(mod schema.DeploymentModule, instance string, registry string) ([]byte, error) { +// GenerateFunc: func(mod schema.DeploymentModule) ([]byte, error) { // panic("mock out the Generate method") // }, // } @@ -25,7 +25,7 @@ import ( // } type ManifestGeneratorMock struct { // GenerateFunc mocks the Generate method. - GenerateFunc func(mod schema.DeploymentModule, instance string, registry string) ([]byte, error) + GenerateFunc func(mod schema.DeploymentModule) ([]byte, error) // calls tracks calls to the methods. calls struct { @@ -33,33 +33,25 @@ type ManifestGeneratorMock struct { Generate []struct { // Mod is the mod argument value. Mod schema.DeploymentModule - // Instance is the instance argument value. - Instance string - // Registry is the registry argument value. - Registry string } } lockGenerate sync.RWMutex } // Generate calls GenerateFunc. -func (mock *ManifestGeneratorMock) Generate(mod schema.DeploymentModule, instance string, registry string) ([]byte, error) { +func (mock *ManifestGeneratorMock) Generate(mod schema.DeploymentModule) ([]byte, error) { if mock.GenerateFunc == nil { panic("ManifestGeneratorMock.GenerateFunc: method is nil but ManifestGenerator.Generate was just called") } callInfo := struct { - Mod schema.DeploymentModule - Instance string - Registry string + Mod schema.DeploymentModule }{ - Mod: mod, - Instance: instance, - Registry: registry, + Mod: mod, } mock.lockGenerate.Lock() mock.calls.Generate = append(mock.calls.Generate, callInfo) mock.lockGenerate.Unlock() - return mock.GenerateFunc(mod, instance, registry) + return mock.GenerateFunc(mod) } // GenerateCalls gets all the calls that were made to Generate. @@ -67,14 +59,10 @@ func (mock *ManifestGeneratorMock) Generate(mod schema.DeploymentModule, instanc // // len(mockedManifestGenerator.GenerateCalls()) func (mock *ManifestGeneratorMock) GenerateCalls() []struct { - Mod schema.DeploymentModule - Instance string - Registry string + Mod schema.DeploymentModule } { var calls []struct { - Mod schema.DeploymentModule - Instance string - Registry string + Mod schema.DeploymentModule } mock.lockGenerate.RLock() calls = mock.calls.Generate diff --git a/lib/project/deployment/providers/kcl/generator.go b/lib/project/deployment/providers/kcl/generator.go index 244b4ff0..8c49a46e 100644 --- a/lib/project/deployment/providers/kcl/generator.go +++ b/lib/project/deployment/providers/kcl/generator.go @@ -16,10 +16,10 @@ type KCLManifestGenerator struct { logger *slog.Logger } -func (g *KCLManifestGenerator) Generate(mod schema.DeploymentModule, instance, registry string) ([]byte, error) { - container := fmt.Sprintf("oci://%s/%s?tag=%s", strings.TrimSuffix(registry, "/"), mod.Name, mod.Version) +func (g *KCLManifestGenerator) Generate(mod schema.DeploymentModule) ([]byte, error) { + container := fmt.Sprintf("oci://%s/%s?tag=%s", strings.TrimSuffix(mod.Registry, "/"), mod.Name, mod.Version) conf := client.KCLModuleConfig{ - InstanceName: instance, + InstanceName: mod.Instance, Namespace: mod.Namespace, Values: mod.Values, } diff --git a/lib/project/deployment/providers/kcl/generator_test.go b/lib/project/deployment/providers/kcl/generator_test.go index 50f61caa..5901515f 100644 --- a/lib/project/deployment/providers/kcl/generator_test.go +++ b/lib/project/deployment/providers/kcl/generator_test.go @@ -23,8 +23,6 @@ func TestKCLManifestGeneratorGenerate(t *testing.T) { tests := []struct { name string module schema.DeploymentModule - instance string - registry string out string err bool validate func(t *testing.T, result testResult) @@ -32,15 +30,15 @@ func TestKCLManifestGeneratorGenerate(t *testing.T) { { name: "full", module: schema.DeploymentModule{ + Instance: "instance", Name: "module", Namespace: "default", + Registry: "registry", Values: "test", Version: "1.0.0", }, - instance: "instance", - registry: "registry", - out: "output", - err: false, + out: "output", + err: false, validate: func(t *testing.T, result testResult) { require.NoError(t, result.err) assert.Equal(t, client.KCLModuleConfig{ @@ -55,15 +53,15 @@ func TestKCLManifestGeneratorGenerate(t *testing.T) { { name: "error", module: schema.DeploymentModule{ + Instance: "instance", Name: "module", Namespace: "default", + Registry: "registry", Values: "test", Version: "1.0.0", }, - instance: "instance", - registry: "registry", - out: "output", - err: true, + out: "output", + err: true, validate: func(t *testing.T, result testResult) { assert.Error(t, result.err) }, @@ -92,7 +90,7 @@ func TestKCLManifestGeneratorGenerate(t *testing.T) { logger: testutils.NewNoopLogger(), } - out, err := g.Generate(tt.module, tt.instance, tt.registry) + out, err := g.Generate(tt.module) tt.validate(t, testResult{ conf: c, container: cont, diff --git a/lib/project/go.mod b/lib/project/go.mod index a3b4c25d..404983a0 100644 --- a/lib/project/go.mod +++ b/lib/project/go.mod @@ -6,12 +6,10 @@ require ( github.com/aws/aws-sdk-go v1.55.5 github.com/aws/aws-sdk-go-v2/config v1.27.40 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.33.4 - github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-github/v66 v66.0.0 github.com/input-output-hk/catalyst-forge/lib/tools v0.0.0 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.10.0 - gopkg.in/jfontan/go-billy-desfacer.v0 v0.0.0-20210209210102-b43512b1cad0 kcl-lang.io/kpm v0.11.0 ) @@ -74,6 +72,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.5.0 // indirect + github.com/go-git/go-git/v5 v5.12.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-yaml v1.15.10 // indirect @@ -145,6 +144,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/grpc v1.69.0 // indirect google.golang.org/protobuf v1.35.2 // indirect + gopkg.in/jfontan/go-billy-desfacer.v0 v0.0.0-20210209210102-b43512b1cad0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect diff --git a/lib/project/project/loader.go b/lib/project/project/loader.go index 87489fa7..033f3360 100644 --- a/lib/project/project/loader.go +++ b/lib/project/project/loader.go @@ -11,11 +11,11 @@ import ( "cuelang.org/go/cue/cuecontext" "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" "github.com/input-output-hk/catalyst-forge/lib/project/injector" - "github.com/input-output-hk/catalyst-forge/lib/project/providers" "github.com/input-output-hk/catalyst-forge/lib/project/schema" "github.com/input-output-hk/catalyst-forge/lib/project/secrets" "github.com/input-output-hk/catalyst-forge/lib/tools/earthfile" "github.com/input-output-hk/catalyst-forge/lib/tools/git" + r "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" "github.com/input-output-hk/catalyst-forge/lib/tools/walker" "github.com/spf13/afero" ) @@ -35,8 +35,8 @@ type DefaultProjectLoader struct { fs afero.Fs injectors []injector.BlueprintInjector logger *slog.Logger - repoLoader git.RepoLoader runtimes []RuntimeData + store secrets.SecretStore } func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { @@ -56,9 +56,8 @@ func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { } p.logger.Info("Loading repository", "path", gitRoot) - rl := git.NewCustomDefaultRepoLoader(p.fs) - repo, err := rl.Load(gitRoot) - if err != nil { + repo := r.NewGitRepo(p.logger, r.WithFS(p.fs)) + if err := repo.Open(gitRoot); err != nil { p.logger.Error("Failed to load repository", "error", err) return Project{}, fmt.Errorf("failed to load repository: %w", err) } @@ -100,8 +99,9 @@ func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { Earthfile: ef, Path: projectPath, RawBlueprint: rbp, - Repo: repo, + Repo: &repo, RepoRoot: gitRoot, + SecretStore: p.store, logger: p.logger, ctx: p.ctx, }, nil @@ -114,7 +114,7 @@ func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { p.logger.Info("Loading tag data") var tag *ProjectTag - gitTag, err := git.GetTag(repo) + gitTag, err := git.GetTag(&repo) if err != nil { p.logger.Warn("Failed to get git tag", "error", err) } else if gitTag != "" { @@ -135,8 +135,9 @@ func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { Name: name, Path: projectPath, RawBlueprint: rbp, - Repo: repo, + Repo: &repo, RepoRoot: gitRoot, + SecretStore: p.store, Tag: tag, ctx: p.ctx, logger: p.logger, @@ -176,15 +177,17 @@ func (p *DefaultProjectLoader) Load(projectPath string) (Project, error) { Name: name, Path: projectPath, RawBlueprint: rbp, - Repo: repo, + Repo: &repo, RepoRoot: gitRoot, logger: p.logger, + SecretStore: p.store, Tag: tag, }, nil } // NewDefaultProjectLoader creates a new DefaultProjectLoader. func NewDefaultProjectLoader( + store secrets.SecretStore, logger *slog.Logger, ) DefaultProjectLoader { if logger == nil { @@ -194,9 +197,6 @@ func NewDefaultProjectLoader( ctx := cuecontext.New() fs := afero.NewOsFs() bl := blueprint.NewDefaultBlueprintLoader(ctx, logger) - rl := git.NewDefaultRepoLoader() - store := secrets.NewDefaultSecretStore() - ghp := providers.NewGithubProvider(fs, logger, &store) return DefaultProjectLoader{ blueprintLoader: &bl, ctx: ctx, @@ -204,12 +204,12 @@ func NewDefaultProjectLoader( injectors: []injector.BlueprintInjector{ injector.NewBlueprintEnvInjector(ctx, logger), }, - logger: logger, - repoLoader: &rl, + logger: logger, runtimes: []RuntimeData{ NewDeploymentRuntime(logger), - NewGitRuntime(&ghp, logger), + NewGitRuntime(logger), }, + store: store, } } @@ -219,8 +219,8 @@ func NewCustomProjectLoader( fs afero.Fs, bl blueprint.BlueprintLoader, injectors []injector.BlueprintInjector, - rl git.RepoLoader, runtimes []RuntimeData, + store secrets.SecretStore, logger *slog.Logger, ) DefaultProjectLoader { if logger == nil { @@ -233,8 +233,8 @@ func NewCustomProjectLoader( fs: fs, injectors: injectors, logger: logger, - repoLoader: rl, runtimes: runtimes, + store: store, } } diff --git a/lib/project/project/loader_test.go b/lib/project/project/loader_test.go index 8896f12f..a3a161d8 100644 --- a/lib/project/project/loader_test.go +++ b/lib/project/project/loader_test.go @@ -2,26 +2,19 @@ package project import ( "os" - "path/filepath" "testing" "cuelang.org/go/cue/cuecontext" - gg "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/cache" - "github.com/go-git/go-git/v5/storage/filesystem" "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" "github.com/input-output-hk/catalyst-forge/lib/project/injector" - "github.com/input-output-hk/catalyst-forge/lib/project/providers" "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - df "gopkg.in/jfontan/go-billy-desfacer.v0" ) func TestDefaultProjectLoaderLoad(t *testing.T) { ctx := cuecontext.New() - githubProvider := providers.NewGithubProvider(nil, testutils.NewNoopLogger(), nil) earthfile := ` VERSION 0.8 @@ -50,7 +43,7 @@ project: name: "foo" files map[string]string tag string injectors []injector.BlueprintInjector - runtimes []RuntimeData + runtimes func(afero.Fs) []RuntimeData env map[string]string initGit bool validate func(*testing.T, Project, error) @@ -65,7 +58,7 @@ project: name: "foo" }, tag: "foo/v1.0.0", injectors: []injector.BlueprintInjector{}, - runtimes: []RuntimeData{}, + runtimes: func(fs afero.Fs) []RuntimeData { return nil }, env: map[string]string{}, initGit: true, validate: func(t *testing.T, p Project, err error) { @@ -104,7 +97,7 @@ project: { injectors: []injector.BlueprintInjector{ injector.NewBlueprintEnvInjector(ctx, testutils.NewNoopLogger()), }, - runtimes: []RuntimeData{}, + runtimes: func(fs afero.Fs) []RuntimeData { return nil }, env: map[string]string{ "FOO": "bar", }, @@ -137,11 +130,8 @@ project: { injectors: []injector.BlueprintInjector{ injector.NewBlueprintEnvInjector(ctx, testutils.NewNoopLogger()), }, - runtimes: []RuntimeData{ - NewGitRuntime( - &githubProvider, - testutils.NewNoopLogger(), - ), + runtimes: func(fs afero.Fs) []RuntimeData { + return []RuntimeData{NewCustomGitRuntime(fs, testutils.NewNoopLogger())} }, env: map[string]string{}, initGit: true, @@ -161,7 +151,7 @@ project: { "/project/blueprint.cue": bp, }, injectors: []injector.BlueprintInjector{}, - runtimes: []RuntimeData{}, + runtimes: func(f afero.Fs) []RuntimeData { return nil }, env: map[string]string{}, initGit: false, validate: func(t *testing.T, p Project, err error) { @@ -182,7 +172,7 @@ project: { "/project/blueprint.cue": bp, }, injectors: []injector.BlueprintInjector{}, - runtimes: []RuntimeData{}, + runtimes: func(f afero.Fs) []RuntimeData { return nil }, env: map[string]string{}, initGit: true, validate: func(t *testing.T, p Project, err error) { @@ -203,7 +193,7 @@ project: { "/project/blueprint.cue": "invalid", }, injectors: []injector.BlueprintInjector{}, - runtimes: []RuntimeData{}, + runtimes: func(f afero.Fs) []RuntimeData { return nil }, env: map[string]string{}, initGit: true, validate: func(t *testing.T, p Project, err error) { @@ -236,7 +226,7 @@ project: { `, }, injectors: []injector.BlueprintInjector{}, - runtimes: []RuntimeData{}, + runtimes: func(f afero.Fs) []RuntimeData { return nil }, env: map[string]string{}, initGit: true, validate: func(t *testing.T, p Project, err error) { @@ -267,29 +257,21 @@ project: { testutils.SetupFS(t, tt.fs, tt.files) if tt.initGit { - tt.fs.Mkdir(filepath.Join(tt.projectPath, ".git"), 0755) - workdir := df.New(afero.NewBasePathFs(tt.fs, tt.projectPath)) - gitdir := df.New(afero.NewBasePathFs(tt.fs, filepath.Join(tt.projectPath, ".git"))) - storage := filesystem.NewStorage(gitdir, cache.NewObjectLRUDefault()) - r, err := gg.Init(storage, workdir) + repo := testutils.NewTestRepoWithFS(t, tt.fs, tt.projectPath) + err := repo.StageFile("Earthfile") require.NoError(t, err) - wt, err := r.Worktree() + err = repo.StageFile("blueprint.cue") require.NoError(t, err) - repo := testutils.InMemRepo{ - Fs: workdir, - Repo: r, - Worktree: wt, - } - repo.AddExistingFile(t, "Earthfile") - repo.AddExistingFile(t, "blueprint.cue") - repo.Commit(t, "Initial commit") + _, err = repo.Commit("Initial commit") + require.NoError(t, err) - head, err := repo.Repo.Head() + head, err := repo.Head() require.NoError(t, err) if tt.tag != "" { - repo.Tag(t, head.Hash(), tt.tag, "Initial tag") + _, err := repo.NewTag(head.Hash(), tt.tag, "Initial tag") + require.NoError(t, err) } } @@ -300,7 +282,7 @@ project: { fs: tt.fs, injectors: tt.injectors, logger: logger, - runtimes: tt.runtimes, + runtimes: tt.runtimes(tt.fs), } p, err := loader.Load(tt.projectPath) diff --git a/lib/project/project/project.go b/lib/project/project/project.go index d2bb7745..8b865ca0 100644 --- a/lib/project/project/project.go +++ b/lib/project/project/project.go @@ -7,10 +7,11 @@ import ( "strings" "cuelang.org/go/cue" - gg "github.com/go-git/go-git/v5" "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "github.com/input-output-hk/catalyst-forge/lib/project/secrets" "github.com/input-output-hk/catalyst-forge/lib/tools/earthfile" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" ) // Project represents a project @@ -31,11 +32,14 @@ type Project struct { RawBlueprint blueprint.RawBlueprint // Repo is the project git repository. - Repo *gg.Repository + Repo *repo.GitRepo // RepoRoot is the path to the repository root. RepoRoot string + // SecretStore is the project secret store. + SecretStore secrets.SecretStore + // Tag is the project tag, if it exists in the current context. Tag *ProjectTag @@ -113,24 +117,27 @@ func (p *Project) Raw() blueprint.RawBlueprint { return p.RawBlueprint } +// NewProject creates a new project. func NewProject( - logger *slog.Logger, ctx *cue.Context, - repo *gg.Repository, + repo *repo.GitRepo, earthfile *earthfile.Earthfile, name, path, repoRoot string, blueprint schema.Blueprint, tag *ProjectTag, + logger *slog.Logger, + secretStore secrets.SecretStore, ) Project { return Project{ - Blueprint: blueprint, - Earthfile: earthfile, - Name: name, - Path: path, - Repo: repo, - RepoRoot: repoRoot, - Tag: tag, - ctx: ctx, - logger: logger, + Blueprint: blueprint, + Earthfile: earthfile, + Name: name, + Path: path, + Repo: repo, + RepoRoot: repoRoot, + SecretStore: secretStore, + Tag: tag, + ctx: ctx, + logger: logger, } } diff --git a/lib/project/project/runtime.go b/lib/project/project/runtime.go index 4414e888..6226e65e 100644 --- a/lib/project/project/runtime.go +++ b/lib/project/project/runtime.go @@ -5,10 +5,11 @@ import ( "log/slog" "cuelang.org/go/cue" - "github.com/go-git/go-git/v5" "github.com/google/go-github/v66/github" - "github.com/input-output-hk/catalyst-forge/lib/project/providers" "github.com/input-output-hk/catalyst-forge/lib/project/schema" + gh "github.com/input-output-hk/catalyst-forge/lib/tools/git/github" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" + "github.com/spf13/afero" ) // RuntimeData is an interface for runtime data loaders. @@ -64,8 +65,8 @@ func NewDeploymentRuntime(logger *slog.Logger) *DeploymentRuntime { // GitRuntime is a runtime data loader for git related data. type GitRuntime struct { - provider *providers.GithubProvider - logger *slog.Logger + fs afero.Fs + logger *slog.Logger } func (g *GitRuntime) Load(project *Project) map[string]cue.Value { @@ -92,11 +93,12 @@ func (g *GitRuntime) Load(project *Project) map[string]cue.Value { } // getCommitHash returns the commit hash of the HEAD commit. -func (g *GitRuntime) getCommitHash(repo *git.Repository) (string, error) { - if g.provider.HasEvent() { - if g.provider.GetEventType() == "pull_request" { +func (g *GitRuntime) getCommitHash(repo *repo.GitRepo) (string, error) { + env := gh.NewCustomGithubEnv(g.fs, g.logger) + if env.HasEvent() { + if env.GetEventType() == "pull_request" { g.logger.Debug("Found GitHub pull request event") - event, err := g.provider.GetEventPayload() + event, err := env.GetEventPayload() if err != nil { return "", fmt.Errorf("failed to get event payload: %w", err) } @@ -111,9 +113,9 @@ func (g *GitRuntime) getCommitHash(repo *git.Repository) (string, error) { } return *pr.PullRequest.Head.SHA, nil - } else if g.provider.GetEventType() == "push" { + } else if env.GetEventType() == "push" { g.logger.Debug("Found GitHub push event") - event, err := g.provider.GetEventPayload() + event, err := env.GetEventPayload() if err != nil { return "", fmt.Errorf("failed to get event payload: %w", err) } @@ -137,7 +139,7 @@ func (g *GitRuntime) getCommitHash(repo *git.Repository) (string, error) { return "", fmt.Errorf("failed to get HEAD: %w", err) } - obj, err := repo.CommitObject(ref.Hash()) + obj, err := repo.GetCommit(ref.Hash()) if err != nil { return "", fmt.Errorf("failed to get commit object: %w", err) } @@ -146,9 +148,17 @@ func (g *GitRuntime) getCommitHash(repo *git.Repository) (string, error) { } // NewGitRuntime creates a new GitRuntime. -func NewGitRuntime(githubProvider *providers.GithubProvider, logger *slog.Logger) *GitRuntime { +func NewGitRuntime(logger *slog.Logger) *GitRuntime { return &GitRuntime{ - logger: logger, - provider: githubProvider, + fs: afero.NewOsFs(), + logger: logger, + } +} + +// NewCustomGitRuntime creates a new GitRuntime with a custom filesystem. +func NewCustomGitRuntime(fs afero.Fs, logger *slog.Logger) *GitRuntime { + return &GitRuntime{ + fs: fs, + logger: logger, } } diff --git a/lib/project/project/runtime_test.go b/lib/project/project/runtime_test.go index ce7fde25..9217c3fc 100644 --- a/lib/project/project/runtime_test.go +++ b/lib/project/project/runtime_test.go @@ -7,10 +7,9 @@ import ( "cuelang.org/go/cue" "cuelang.org/go/cue/cuecontext" - "github.com/go-git/go-git/v5" "github.com/input-output-hk/catalyst-forge/lib/project/blueprint" - "github.com/input-output-hk/catalyst-forge/lib/project/providers" lc "github.com/input-output-hk/catalyst-forge/lib/tools/cue" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -78,7 +77,7 @@ func TestGitRuntimeLoad(t *testing.T) { tag *ProjectTag env map[string]string files map[string]string - validate func(*testing.T, *git.Repository, map[string]cue.Value) + validate func(*testing.T, repo.GitRepo, map[string]cue.Value) }{ { name: "with tag", @@ -87,7 +86,7 @@ func TestGitRuntimeLoad(t *testing.T) { Project: "project", Version: "v1.0.0", }, - validate: func(t *testing.T, repo *git.Repository, data map[string]cue.Value) { + validate: func(t *testing.T, repo repo.GitRepo, data map[string]cue.Value) { head, err := repo.Head() require.NoError(t, err) assert.Contains(t, data, "GIT_COMMIT_HASH") @@ -109,7 +108,7 @@ func TestGitRuntimeLoad(t *testing.T) { files: map[string]string{ "/event.json": string(prPayload), }, - validate: func(t *testing.T, repo *git.Repository, data map[string]cue.Value) { + validate: func(t *testing.T, repo repo.GitRepo, data map[string]cue.Value) { require.NoError(t, err) assert.Contains(t, data, "GIT_COMMIT_HASH") assert.Equal(t, "0000000000000000000000000000000000000000", getString(t, data["GIT_COMMIT_HASH"])) @@ -124,7 +123,7 @@ func TestGitRuntimeLoad(t *testing.T) { files: map[string]string{ "/event.json": string(pushPayload), }, - validate: func(t *testing.T, repo *git.Repository, data map[string]cue.Value) { + validate: func(t *testing.T, repo repo.GitRepo, data map[string]cue.Value) { require.NoError(t, err) assert.Contains(t, data, "GIT_COMMIT_HASH") assert.Equal(t, "0000000000000000000000000000000000000000", getString(t, data["GIT_COMMIT_HASH"])) @@ -136,11 +135,13 @@ func TestGitRuntimeLoad(t *testing.T) { t.Run(tt.name, func(t *testing.T) { logger := testutils.NewNoopLogger() - repo := testutils.NewInMemRepo(t) - repo.AddFile(t, "example.txt", "example content") - _ = repo.Commit(t, "Initial commit") + repo := testutils.NewTestRepo(t) + err := repo.WriteFile("example.txt", []byte("example content")) + require.NoError(t, err) + + _, err = repo.Commit("Initial commit") + require.NoError(t, err) - provider := providers.NewGithubProvider(nil, logger, nil) if len(tt.env) > 0 { for k, v := range tt.env { require.NoError(t, os.Setenv(k, v)) @@ -148,23 +149,22 @@ func TestGitRuntimeLoad(t *testing.T) { } } + fs := afero.NewMemMapFs() if len(tt.files) > 0 { - fs := afero.NewMemMapFs() testutils.SetupFS(t, fs, tt.files) - provider = providers.NewGithubProvider(fs, logger, nil) } project := &Project{ ctx: ctx, RawBlueprint: blueprint.NewRawBlueprint(ctx.CompileString("{}")), - Repo: repo.Repo, + Repo: &repo, Tag: tt.tag, logger: logger, } - runtime := NewGitRuntime(&provider, logger) + runtime := NewCustomGitRuntime(fs, logger) data := runtime.Load(project) - tt.validate(t, repo.Repo, data) + tt.validate(t, repo, data) }) } } diff --git a/lib/project/project/strategies/git.go b/lib/project/project/strategies/git.go deleted file mode 100644 index 9198ea0d..00000000 --- a/lib/project/project/strategies/git.go +++ /dev/null @@ -1,22 +0,0 @@ -package strategies - -import ( - "fmt" - - "github.com/go-git/go-git/v5" -) - -// GitCommit returns the commit hash of the HEAD commit. -func GitCommit(repo *git.Repository) (string, error) { - ref, err := repo.Head() - if err != nil { - return "", fmt.Errorf("failed to get HEAD: %w", err) - } - - obj, err := repo.CommitObject(ref.Hash()) - if err != nil { - return "", fmt.Errorf("failed to get commit object: %w", err) - } - - return obj.Hash.String(), nil -} diff --git a/lib/project/project/strategies/git_test.go b/lib/project/project/strategies/git_test.go deleted file mode 100644 index 820688cf..00000000 --- a/lib/project/project/strategies/git_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package strategies - -import ( - "testing" - - "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" - "github.com/stretchr/testify/assert" -) - -func TestGitCommit(t *testing.T) { - repo := testutils.NewInMemRepo(t) - repo.AddFile(t, "example.txt", "example content") - commit := repo.Commit(t, "Initial commit") - - commitHash, err := GitCommit(repo.Repo) - assert.NoError(t, err) - assert.Equal(t, commit.String(), commitHash) -} diff --git a/lib/project/providers/git.go b/lib/project/providers/git.go new file mode 100644 index 00000000..bea86154 --- /dev/null +++ b/lib/project/providers/git.go @@ -0,0 +1,51 @@ +package providers + +import ( + "fmt" + "log/slog" + + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/input-output-hk/catalyst-forge/lib/project/project" + "github.com/input-output-hk/catalyst-forge/lib/project/secrets" +) + +// GitProviderCreds is the struct that holds the credentials for the Git provider +type GitProviderCreds struct { + Token string +} + +// GetGitProviderCreds loads the Git provider credentials from the project. +func GetGitProviderCreds(p *project.Project, logger *slog.Logger) (GitProviderCreds, error) { + secret := p.Blueprint.Global.CI.Providers.Git.Credentials + if secret == nil { + return GitProviderCreds{}, fmt.Errorf("project does not have a Git provider configured") + } + + m, err := secrets.GetSecretMap(secret, &p.SecretStore, logger) + if err != nil { + return GitProviderCreds{}, fmt.Errorf("could not get secret: %w", err) + } + + creds, ok := m["token"] + if !ok { + return GitProviderCreds{}, fmt.Errorf("git provider token is missing in secret") + } + + return GitProviderCreds{Token: creds}, nil +} + +// LoadGitProviderCreds loads the Git provider credentials into the project's +// repository. +func LoadGitProviderCreds(p *project.Project, logger *slog.Logger) error { + creds, err := GetGitProviderCreds(p, logger) + if err != nil { + return fmt.Errorf("could not get git provider credentials: %w", err) + } + + p.Repo.SetAuth(&http.BasicAuth{ + Username: "forge", + Password: creds.Token, + }) + + return nil +} diff --git a/lib/project/providers/github.go b/lib/project/providers/github.go index e9aac61b..eaf469e1 100644 --- a/lib/project/providers/github.go +++ b/lib/project/providers/github.go @@ -2,117 +2,58 @@ package providers import ( "fmt" - "io" "log/slog" "os" "github.com/google/go-github/v66/github" - "github.com/input-output-hk/catalyst-forge/lib/project/schema" + "github.com/input-output-hk/catalyst-forge/lib/project/project" "github.com/input-output-hk/catalyst-forge/lib/project/secrets" - "github.com/spf13/afero" ) -var ( - ErrNoEventFound = fmt.Errorf("no GitHub event data found") -) - -type GithubProvider struct { - fs afero.Fs - logger *slog.Logger - store *secrets.SecretStore +// GithubProviderCreds is the struct that holds the credentials for the Github provider +type GithubProviderCreds struct { + Token string } -// GetEventType returns the GitHub event type. -func (g *GithubProvider) GetEventType() string { - return os.Getenv("GITHUB_EVENT_NAME") -} - -// GetEventPayload returns the GitHub event payload. -func (g *GithubProvider) GetEventPayload() (any, error) { - path, pathExists := os.LookupEnv("GITHUB_EVENT_PATH") - name, nameExists := os.LookupEnv("GITHUB_EVENT_NAME") - - if !pathExists || !nameExists { - return nil, ErrNoEventFound +// GetGithubProviderCreds loads the Github provider credentials from the project. +func GetGithubProviderCreds(p *project.Project, logger *slog.Logger) (GithubProviderCreds, error) { + secret := p.Blueprint.Global.CI.Providers.Github.Credentials + if secret == nil { + return GithubProviderCreds{}, fmt.Errorf("project does not have a Github provider configured") } - g.logger.Debug("Reading GitHub event data", "path", path, "name", name) - payload, err := afero.ReadFile(g.fs, path) + m, err := secrets.GetSecretMap(secret, &p.SecretStore, logger) if err != nil { - return nil, fmt.Errorf("failed to read GitHub event data: %w", err) + return GithubProviderCreds{}, fmt.Errorf("could not get secret: %w", err) } - event, err := github.ParseWebHook(name, payload) - if err != nil { - return nil, fmt.Errorf("failed to parse GitHub event data: %w", err) + creds, ok := m["token"] + if !ok { + return GithubProviderCreds{}, fmt.Errorf("github provider token is missing in secret") } - return event, nil -} - -// HasEvent returns whether a GitHub event payload exists. -func (g *GithubProvider) HasEvent() bool { - _, pathExists := os.LookupEnv("GITHUB_EVENT_PATH") - _, nameExists := os.LookupEnv("GITHUB_EVENT_NAME") - return pathExists && nameExists + return GithubProviderCreds{Token: creds}, nil } -// NewClient returns a new GitHub client. -func (g *GithubProvider) NewClient(secret *schema.Secret) *github.Client { +// NewGithubClient returns a new Github client. +// If a GITHUB_TOKEN environment variable is set, it will use that token. +// Otherwise, it will use the provider secret. +// If neither are set, it will create an anonymous client. +func NewGithubClient(p *project.Project, logger *slog.Logger) (*github.Client, error) { token, exists := os.LookupEnv("GITHUB_TOKEN") if exists { - return github.NewClient(nil).WithAuthToken(token) - } else if secret != nil { - if secret.Maps != nil { - secretMap, err := secrets.GetSecretMap(secret, g.store, g.logger) - if err != nil { - g.logger.Error("Failed to get Github secret", "error", err) - } else { - token, exists := secretMap["token"] - if exists { - return github.NewClient(nil).WithAuthToken(token) - } else { - g.logger.Error("Github secret map does not contain token") - } - } - } else { - secret, err := secrets.GetSecret(secret, g.store, g.logger) - if err != nil { - g.logger.Error("Failed to get Github secret", "error", err) - } else { - return github.NewClient(nil).WithAuthToken(secret) - } + logger.Info("Creating Github client with environment token") + return github.NewClient(nil).WithAuthToken(token), nil + } else if p.Blueprint.Global.CI.Providers.Github.Credentials != nil { + logger.Info("Creating Github client with provider secret") + creds, err := GetGithubProviderCreds(p, logger) + if err != nil { + logger.Error("Failed to get Github provider credentials", "error", err) } - } - g.logger.Warn("No Github token found, using unauthenticated client") - return github.NewClient(nil) -} - -// NewDefaultGithubProvider returns a new default GitHub provider. -func NewDefaultGithubProvider(logger *slog.Logger) GithubProvider { - if logger == nil { - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) - } - - fs := afero.NewOsFs() - store := secrets.NewDefaultSecretStore() - return GithubProvider{ - fs: fs, - logger: logger, - store: &store, - } -} - -// NewGithubProvider returns a new GitHub provider. -func NewGithubProvider(fs afero.Fs, logger *slog.Logger, store *secrets.SecretStore) GithubProvider { - if logger == nil { - logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + return github.NewClient(nil).WithAuthToken(creds.Token), nil } - return GithubProvider{ - fs: fs, - logger: logger, - store: store, - } + logger.Info("Creating new anonymous Github client") + return github.NewClient(nil), nil } diff --git a/lib/project/providers/github_test.go b/lib/project/providers/github_test.go index c894436e..d719a644 100644 --- a/lib/project/providers/github_test.go +++ b/lib/project/providers/github_test.go @@ -1,144 +1,144 @@ package providers -import ( - "os" - "testing" +// import ( +// "os" +// "testing" - "github.com/google/go-github/v66/github" - "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) +// "github.com/google/go-github/v66/github" +// "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" +// "github.com/spf13/afero" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) -func TestGithubProviderGetEventPayload(t *testing.T) { - payload, err := os.ReadFile("testdata/event.json") - require.NoError(t, err) +// func TestGithubProviderGetEventPayload(t *testing.T) { +// payload, err := os.ReadFile("testdata/event.json") +// require.NoError(t, err) - tests := []struct { - name string - env map[string]string - files map[string]string - validate func(*testing.T, any, error) - }{ - { - name: "full", - env: map[string]string{ - "GITHUB_EVENT_PATH": "/event.json", - "GITHUB_EVENT_NAME": "pull_request", - }, - files: map[string]string{ - "/event.json": string(payload), - }, - validate: func(t *testing.T, payload any, err error) { - require.NoError(t, err) - _, ok := payload.(*github.PullRequestEvent) - require.True(t, ok) - }, - }, - { - name: "missing path", - env: map[string]string{ - "GITHUB_EVENT_NAME": "pull_request", - }, - validate: func(t *testing.T, payload any, err error) { - require.ErrorIs(t, err, ErrNoEventFound) - }, - }, - { - name: "missing name", - env: map[string]string{ - "GITHUB_EVENT_PATH": "/event.json", - }, - files: map[string]string{ - "/event.json": string(payload), - }, - validate: func(t *testing.T, payload any, err error) { - require.ErrorIs(t, err, ErrNoEventFound) - }, - }, - { - name: "invalid payload", - env: map[string]string{ - "GITHUB_EVENT_PATH": "/event.json", - "GITHUB_EVENT_NAME": "pull_request", - }, - files: map[string]string{ - "/event.json": "invalid", - }, - validate: func(t *testing.T, payload any, err error) { - require.Error(t, err) - }, - }, - } +// tests := []struct { +// name string +// env map[string]string +// files map[string]string +// validate func(*testing.T, any, error) +// }{ +// { +// name: "full", +// env: map[string]string{ +// "GITHUB_EVENT_PATH": "/event.json", +// "GITHUB_EVENT_NAME": "pull_request", +// }, +// files: map[string]string{ +// "/event.json": string(payload), +// }, +// validate: func(t *testing.T, payload any, err error) { +// require.NoError(t, err) +// _, ok := payload.(*github.PullRequestEvent) +// require.True(t, ok) +// }, +// }, +// { +// name: "missing path", +// env: map[string]string{ +// "GITHUB_EVENT_NAME": "pull_request", +// }, +// validate: func(t *testing.T, payload any, err error) { +// require.ErrorIs(t, err, ErrNoEventFound) +// }, +// }, +// { +// name: "missing name", +// env: map[string]string{ +// "GITHUB_EVENT_PATH": "/event.json", +// }, +// files: map[string]string{ +// "/event.json": string(payload), +// }, +// validate: func(t *testing.T, payload any, err error) { +// require.ErrorIs(t, err, ErrNoEventFound) +// }, +// }, +// { +// name: "invalid payload", +// env: map[string]string{ +// "GITHUB_EVENT_PATH": "/event.json", +// "GITHUB_EVENT_NAME": "pull_request", +// }, +// files: map[string]string{ +// "/event.json": "invalid", +// }, +// validate: func(t *testing.T, payload any, err error) { +// require.Error(t, err) +// }, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - require.NoError(t, os.Setenv(k, v)) - defer os.Unsetenv(k) - } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// for k, v := range tt.env { +// require.NoError(t, os.Setenv(k, v)) +// defer os.Unsetenv(k) +// } - fs := afero.NewMemMapFs() - testutils.SetupFS(t, fs, tt.files) +// fs := afero.NewMemMapFs() +// testutils.SetupFS(t, fs, tt.files) - provider := GithubProvider{ - fs: fs, - logger: testutils.NewNoopLogger(), - } +// provider := GithubProvider{ +// fs: fs, +// logger: testutils.NewNoopLogger(), +// } - payload, err := provider.GetEventPayload() - tt.validate(t, payload, err) - }) - } -} +// payload, err := provider.GetEventPayload() +// tt.validate(t, payload, err) +// }) +// } +// } -func TestGithubProviderGetEventType(t *testing.T) { - provider := GithubProvider{} +// func TestGithubProviderGetEventType(t *testing.T) { +// provider := GithubProvider{} - require.NoError(t, os.Setenv("GITHUB_EVENT_NAME", "push")) - assert.Equal(t, "push", provider.GetEventType()) -} +// require.NoError(t, os.Setenv("GITHUB_EVENT_NAME", "push")) +// assert.Equal(t, "push", provider.GetEventType()) +// } -func TestGithubProviderHasEvent(t *testing.T) { - tests := []struct { - name string - env map[string]string - expect bool - }{ - { - name: "has event", - env: map[string]string{ - "GITHUB_EVENT_PATH": "/path/to/event", - "GITHUB_EVENT_NAME": "push", - }, - expect: true, - }, - { - name: "missing path", - env: map[string]string{ - "GITHUB_EVENT_NAME": "push", - }, - expect: false, - }, - { - name: "missing name", - env: map[string]string{ - "GITHUB_EVENT_PATH": "/path/to/event", - }, - expect: false, - }, - } +// func TestGithubProviderHasEvent(t *testing.T) { +// tests := []struct { +// name string +// env map[string]string +// expect bool +// }{ +// { +// name: "has event", +// env: map[string]string{ +// "GITHUB_EVENT_PATH": "/path/to/event", +// "GITHUB_EVENT_NAME": "push", +// }, +// expect: true, +// }, +// { +// name: "missing path", +// env: map[string]string{ +// "GITHUB_EVENT_NAME": "push", +// }, +// expect: false, +// }, +// { +// name: "missing name", +// env: map[string]string{ +// "GITHUB_EVENT_PATH": "/path/to/event", +// }, +// expect: false, +// }, +// } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - require.NoError(t, os.Setenv(k, v)) - defer os.Unsetenv(k) - } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// for k, v := range tt.env { +// require.NoError(t, os.Setenv(k, v)) +// defer os.Unsetenv(k) +// } - provider := GithubProvider{} - assert.Equal(t, tt.expect, provider.HasEvent()) - }) - } -} +// provider := GithubProvider{} +// assert.Equal(t, tt.expect, provider.HasEvent()) +// }) +// } +// } diff --git a/lib/project/schema/_embed/schema.cue b/lib/project/schema/_embed/schema.cue index 45ea3c77..ed89a447 100644 --- a/lib/project/schema/_embed/schema.cue +++ b/lib/project/schema/_embed/schema.cue @@ -147,7 +147,7 @@ package schema #ProviderGithub: { // Credentials contains the credentials to use for Github // +optional - credentials?: #Secret @go(Credentials) + credentials?: null | #Secret @go(Credentials,*Secret) // Registry contains the Github registry to use. // +optional @@ -237,6 +237,10 @@ version: "1.0" // Module contains the configuration for a deployment module. #DeploymentModule: { + // Instance contains the instance name to use for all generated resources. + // +optional + instance?: string @go(Instance) + // Name contains the name of the module to deploy. // +optional name?: string @go(Name) @@ -246,6 +250,10 @@ version: "1.0" string } @go(Namespace) + // Registry contains the registry to pull the module from. + // +optional + registry?: string @go(Registry) + // Values contains the values to pass to the deployment module. values: _ @go(Values,any) diff --git a/lib/project/schema/deployment.go b/lib/project/schema/deployment.go index 933f6c0a..a1c536a6 100644 --- a/lib/project/schema/deployment.go +++ b/lib/project/schema/deployment.go @@ -13,6 +13,10 @@ type Deployment struct { // Module contains the configuration for a deployment module. type DeploymentModule struct { + // Instance contains the instance name to use for all generated resources. + // +optional + Instance string `json:"instance"` + // Name contains the name of the module to deploy. // +optional Name string `json:"name"` @@ -20,6 +24,10 @@ type DeploymentModule struct { // Namespace contains the namespace to deploy the module to. Namespace string `json:"namespace"` + // Registry contains the registry to pull the module from. + // +optional + Registry string `json:"registry"` + // Values contains the values to pass to the deployment module. Values any `json:"values"` diff --git a/lib/project/schema/deployment_go_gen.cue b/lib/project/schema/deployment_go_gen.cue index 4b4c66cf..1e1eb4aa 100644 --- a/lib/project/schema/deployment_go_gen.cue +++ b/lib/project/schema/deployment_go_gen.cue @@ -17,6 +17,10 @@ package schema // Module contains the configuration for a deployment module. #DeploymentModule: { + // Instance contains the instance name to use for all generated resources. + // +optional + instance?: string @go(Instance) + // Name contains the name of the module to deploy. // +optional name?: string @go(Name) @@ -24,6 +28,10 @@ package schema // Namespace contains the namespace to deploy the module to. namespace: string @go(Namespace) + // Registry contains the registry to pull the module from. + // +optional + registry?: string @go(Registry) + // Values contains the values to pass to the deployment module. values: _ @go(Values,any) diff --git a/lib/project/schema/providers.go b/lib/project/schema/providers.go index af8fc749..76e91dab 100644 --- a/lib/project/schema/providers.go +++ b/lib/project/schema/providers.go @@ -112,7 +112,7 @@ type ProviderGit struct { type ProviderGithub struct { // Credentials contains the credentials to use for Github // +optional - Credentials Secret `json:"credentials"` + Credentials *Secret `json:"credentials"` // Registry contains the Github registry to use. // +optional diff --git a/lib/project/schema/providers_go_gen.cue b/lib/project/schema/providers_go_gen.cue index dff92b41..27e41a7f 100644 --- a/lib/project/schema/providers_go_gen.cue +++ b/lib/project/schema/providers_go_gen.cue @@ -116,7 +116,7 @@ package schema #ProviderGithub: { // Credentials contains the credentials to use for Github // +optional - credentials?: #Secret @go(Credentials) + credentials?: null | #Secret @go(Credentials,*Secret) // Registry contains the Github registry to use. // +optional diff --git a/lib/project/secrets/secrets.go b/lib/project/secrets/secrets.go index bde41be6..4815ea54 100644 --- a/lib/project/secrets/secrets.go +++ b/lib/project/secrets/secrets.go @@ -35,13 +35,18 @@ func GetSecretMap(s *schema.Secret, store *SecretStore, logger *slog.Logger) (ma return nil, fmt.Errorf("failed to unmarshal secret: %w", err) } - finalMap := make(map[string]string) - for k, v := range s.Maps { - if _, ok := secretMap[k]; !ok { - return nil, fmt.Errorf("secret key not found in secret values: %s", k) + var finalMap map[string]string + if s.Maps != nil { + finalMap := make(map[string]string) + for k, v := range s.Maps { + if _, ok := secretMap[k]; !ok { + return nil, fmt.Errorf("secret key not found in secret values: %s", k) + } + + finalMap[k] = secretMap[v] } - - finalMap[k] = secretMap[v] + } else { + finalMap = secretMap } return finalMap, nil diff --git a/lib/tools/git/branch.go b/lib/tools/git/branch.go index 536c2ce2..6967728a 100644 --- a/lib/tools/git/branch.go +++ b/lib/tools/git/branch.go @@ -2,34 +2,29 @@ package git import ( "fmt" - "os" - "strings" - gg "github.com/go-git/go-git/v5" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/github" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" ) var ( ErrBranchNotFound = fmt.Errorf("branch not found") ) -func GetBranch(repo *gg.Repository) (string, error) { - if InCI() { - ref, ok := os.LookupEnv("GITHUB_HEAD_REF") - if !ok || ref == "" { - if strings.HasPrefix(os.Getenv("GITHUB_REF"), "refs/heads/") { - return strings.TrimPrefix(os.Getenv("GITHUB_REF"), "refs/heads/"), nil - } +func GetBranch(repo *repo.GitRepo) (string, error) { + env := github.NewGithubEnv(nil) - // Revert to trying to get the branch from the local repository - } else { + if github.InCI() { + ref := env.GetBranch() + if ref != "" { return ref, nil } } - ref, err := repo.Head() + branch, err := repo.GetCurrentBranch() if err != nil { return "", err } - return ref.Name().Short(), nil + return branch, nil } diff --git a/lib/tools/git/github/env.go b/lib/tools/git/github/env.go new file mode 100644 index 00000000..a85ceabb --- /dev/null +++ b/lib/tools/git/github/env.go @@ -0,0 +1,113 @@ +package github + +import ( + "fmt" + "io" + "log/slog" + "os" + "strings" + + "github.com/google/go-github/v66/github" + "github.com/spf13/afero" +) + +var ( + ErrNoEventFound = fmt.Errorf("no GitHub event data found") + ErrTagNotFound = fmt.Errorf("tag not found") +) + +// GithubEnv provides GitHub environment information. +type GithubEnv struct { + fs afero.Fs + logger *slog.Logger +} + +func (g *GithubEnv) GetBranch() string { + ref, ok := os.LookupEnv("GITHUB_HEAD_REF") + if !ok || ref == "" { + if strings.HasPrefix(os.Getenv("GITHUB_REF"), "refs/heads/") { + return strings.TrimPrefix(os.Getenv("GITHUB_REF"), "refs/heads/") + } + } + + return ref +} + +// GetEventPayload returns the GitHub event payload. +func (g *GithubEnv) GetEventPayload() (any, error) { + path, pathExists := os.LookupEnv("GITHUB_EVENT_PATH") + name, nameExists := os.LookupEnv("GITHUB_EVENT_NAME") + + if !pathExists || !nameExists { + return nil, ErrNoEventFound + } + + g.logger.Debug("Reading GitHub event data", "path", path, "name", name) + payload, err := afero.ReadFile(g.fs, path) + if err != nil { + return nil, fmt.Errorf("failed to read GitHub event data: %w", err) + } + + event, err := github.ParseWebHook(name, payload) + if err != nil { + return nil, fmt.Errorf("failed to parse GitHub event data: %w", err) + } + + return event, nil +} + +// GetEventType returns the GitHub event type. +func (g *GithubEnv) GetEventType() string { + return os.Getenv("GITHUB_EVENT_NAME") +} + +// GetTag returns the tag from the CI environment if it exists. +// If the tag is not found, an empty string is returned. +func (g *GithubEnv) GetTag() string { + tag, exists := os.LookupEnv("GITHUB_REF") + if exists && strings.HasPrefix(tag, "refs/tags/") { + return strings.TrimPrefix(tag, "refs/tags/") + } + + return "" +} + +// HasEvent returns whether a GitHub event payload exists. +func (g *GithubEnv) HasEvent() bool { + _, pathExists := os.LookupEnv("GITHUB_EVENT_PATH") + _, nameExists := os.LookupEnv("GITHUB_EVENT_NAME") + return pathExists && nameExists +} + +// NewGithubEnv creates a new GithubEnv. +func NewGithubEnv(logger *slog.Logger) GithubEnv { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return GithubEnv{ + fs: afero.NewOsFs(), + logger: logger, + } +} + +// NewCustomGithubEnv creates a new GithubEnv with a custom filesystem. +func NewCustomGithubEnv(fs afero.Fs, logger *slog.Logger) GithubEnv { + if logger == nil { + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + + return GithubEnv{ + fs: fs, + logger: logger, + } +} + +// InCI returns whether the code is running in a CI environment. +func InCI() bool { + if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok { + return true + } + + return false +} diff --git a/lib/tools/git/github/env_test.go b/lib/tools/git/github/env_test.go new file mode 100644 index 00000000..e7f0ab68 --- /dev/null +++ b/lib/tools/git/github/env_test.go @@ -0,0 +1,222 @@ +package github + +import ( + "os" + "testing" + + "github.com/google/go-github/v66/github" + "github.com/input-output-hk/catalyst-forge/lib/tools/testutils" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGithubEnvGetBranch(t *testing.T) { + tests := []struct { + name string + env map[string]string + validate func(*testing.T, string) + }{ + { + name: "head ref", + env: map[string]string{ + "GITHUB_HEAD_REF": "feature/branch", + }, + validate: func(t *testing.T, branch string) { + assert.Equal(t, "feature/branch", branch) + }, + }, + { + name: "ref", + env: map[string]string{ + "GITHUB_REF": "refs/heads/feature/branch", + }, + validate: func(t *testing.T, branch string) { + assert.Equal(t, "feature/branch", branch) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + require.NoError(t, os.Setenv(k, v)) + defer os.Unsetenv(k) + } + + gh := GithubEnv{} + tt.validate(t, gh.GetBranch()) + }) + } +} + +func TestGithubEnvGetEventPayload(t *testing.T) { + payload, err := os.ReadFile("testdata/event.json") + require.NoError(t, err) + + tests := []struct { + name string + env map[string]string + files map[string]string + validate func(*testing.T, any, error) + }{ + { + name: "full", + env: map[string]string{ + "GITHUB_EVENT_PATH": "/event.json", + "GITHUB_EVENT_NAME": "pull_request", + }, + files: map[string]string{ + "/event.json": string(payload), + }, + validate: func(t *testing.T, payload any, err error) { + require.NoError(t, err) + _, ok := payload.(*github.PullRequestEvent) + require.True(t, ok) + }, + }, + { + name: "missing path", + env: map[string]string{ + "GITHUB_EVENT_NAME": "pull_request", + }, + validate: func(t *testing.T, payload any, err error) { + require.ErrorIs(t, err, ErrNoEventFound) + }, + }, + { + name: "missing name", + env: map[string]string{ + "GITHUB_EVENT_PATH": "/event.json", + }, + files: map[string]string{ + "/event.json": string(payload), + }, + validate: func(t *testing.T, payload any, err error) { + require.ErrorIs(t, err, ErrNoEventFound) + }, + }, + { + name: "invalid payload", + env: map[string]string{ + "GITHUB_EVENT_PATH": "/event.json", + "GITHUB_EVENT_NAME": "pull_request", + }, + files: map[string]string{ + "/event.json": "invalid", + }, + validate: func(t *testing.T, payload any, err error) { + require.Error(t, err) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + require.NoError(t, os.Setenv(k, v)) + defer os.Unsetenv(k) + } + + fs := afero.NewMemMapFs() + testutils.SetupFS(t, fs, tt.files) + + gh := GithubEnv{ + fs: fs, + logger: testutils.NewNoopLogger(), + } + + payload, err := gh.GetEventPayload() + tt.validate(t, payload, err) + }) + } +} + +func TestGithubEnvGetEventType(t *testing.T) { + gh := GithubEnv{} + + require.NoError(t, os.Setenv("GITHUB_EVENT_NAME", "push")) + assert.Equal(t, "push", gh.GetEventType()) +} + +func TestGithubEnvGetTag(t *testing.T) { + tests := []struct { + name string + env map[string]string + validate func(*testing.T, string) + }{ + { + name: "tag", + env: map[string]string{ + "GITHUB_REF": "refs/tags/v1.0.0", + }, + validate: func(t *testing.T, tag string) { + assert.Equal(t, "v1.0.0", tag) + }, + }, + { + name: "no tag", + env: map[string]string{ + "GITHUB_REF": "refs/heads/feature/branch", + }, + validate: func(t *testing.T, tag string) { + assert.Empty(t, tag) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + require.NoError(t, os.Setenv(k, v)) + defer os.Unsetenv(k) + } + + gh := GithubEnv{} + tt.validate(t, gh.GetTag()) + }) + } +} + +func TestGithubEnvHasEvent(t *testing.T) { + tests := []struct { + name string + env map[string]string + expect bool + }{ + { + name: "has event", + env: map[string]string{ + "GITHUB_EVENT_PATH": "/path/to/event", + "GITHUB_EVENT_NAME": "push", + }, + expect: true, + }, + { + name: "missing path", + env: map[string]string{ + "GITHUB_EVENT_NAME": "push", + }, + expect: false, + }, + { + name: "missing name", + env: map[string]string{ + "GITHUB_EVENT_PATH": "/path/to/event", + }, + expect: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + require.NoError(t, os.Setenv(k, v)) + defer os.Unsetenv(k) + } + + gh := GithubEnv{} + assert.Equal(t, tt.expect, gh.HasEvent()) + }) + } +} diff --git a/lib/project/providers/testdata/event.json b/lib/tools/git/github/testdata/event.json similarity index 100% rename from lib/project/providers/testdata/event.json rename to lib/tools/git/github/testdata/event.json diff --git a/lib/tools/git/github/util.go b/lib/tools/git/github/util.go deleted file mode 100644 index 8a097a94..00000000 --- a/lib/tools/git/github/util.go +++ /dev/null @@ -1,13 +0,0 @@ -package github - -import "os" - -// InGithubActions returns true if the process is running in a GitHub Actions -// environment. -func InGithubActions() bool { - if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok { - return true - } - - return false -} diff --git a/lib/tools/git/repo.go b/lib/tools/git/repo.go deleted file mode 100644 index 8ad05c1a..00000000 --- a/lib/tools/git/repo.go +++ /dev/null @@ -1,49 +0,0 @@ -package git - -import ( - "fmt" - "path/filepath" - - gg "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/cache" - "github.com/go-git/go-git/v5/storage/filesystem" - "github.com/spf13/afero" - df "gopkg.in/jfontan/go-billy-desfacer.v0" -) - -type RepoLoader interface { - Load(path string) (*gg.Repository, error) -} - -type DefaultRepoLoader struct { - fs afero.Fs -} - -func (r *DefaultRepoLoader) Load(path string) (*gg.Repository, error) { - return loadFromAfero(r.fs, path) -} - -func NewDefaultRepoLoader() DefaultRepoLoader { - return DefaultRepoLoader{ - fs: afero.NewOsFs(), - } -} - -func NewCustomDefaultRepoLoader(fs afero.Fs) DefaultRepoLoader { - return DefaultRepoLoader{ - fs: fs, - } -} - -func loadFromAfero(fs afero.Fs, path string) (*gg.Repository, error) { - workdir := afero.NewBasePathFs(fs, path) - gitdir := afero.NewBasePathFs(fs, filepath.Join(path, ".git")) - - storage := filesystem.NewStorage(df.New(gitdir), cache.NewObjectLRUDefault()) - repo, err := gg.Open(storage, df.New(workdir)) - if err != nil { - return nil, fmt.Errorf("failed to open repository: %w", err) - } - - return repo, nil -} diff --git a/lib/tools/git/repo/remote/gogit.go b/lib/tools/git/repo/remote/gogit.go new file mode 100644 index 00000000..38e8df87 --- /dev/null +++ b/lib/tools/git/repo/remote/gogit.go @@ -0,0 +1,18 @@ +package remote + +import ( + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/storage" +) + +// GoGitRemoteInteractor is a GitRemoteInteractor that uses go-git. +type GoGitRemoteInteractor struct{} + +func (g GoGitRemoteInteractor) Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) { + return git.Clone(s, worktree, o) +} + +func (g GoGitRemoteInteractor) Push(repo *git.Repository, o *git.PushOptions) error { + return repo.Push(o) +} diff --git a/lib/tools/git/repo/remote/mocks/remote.go b/lib/tools/git/repo/remote/mocks/remote.go new file mode 100644 index 00000000..0f046d30 --- /dev/null +++ b/lib/tools/git/repo/remote/mocks/remote.go @@ -0,0 +1,135 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package mocks + +import ( + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/storage" + "sync" +) + +// GitRemoteInteractorMock is a mock implementation of remote.GitRemoteInteractor. +// +// func TestSomethingThatUsesGitRemoteInteractor(t *testing.T) { +// +// // make and configure a mocked remote.GitRemoteInteractor +// mockedGitRemoteInteractor := &GitRemoteInteractorMock{ +// CloneFunc: func(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) { +// panic("mock out the Clone method") +// }, +// PushFunc: func(repo *git.Repository, o *git.PushOptions) error { +// panic("mock out the Push method") +// }, +// } +// +// // use mockedGitRemoteInteractor in code that requires remote.GitRemoteInteractor +// // and then make assertions. +// +// } +type GitRemoteInteractorMock struct { + // CloneFunc mocks the Clone method. + CloneFunc func(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) + + // PushFunc mocks the Push method. + PushFunc func(repo *git.Repository, o *git.PushOptions) error + + // calls tracks calls to the methods. + calls struct { + // Clone holds details about calls to the Clone method. + Clone []struct { + // S is the s argument value. + S storage.Storer + // Worktree is the worktree argument value. + Worktree billy.Filesystem + // O is the o argument value. + O *git.CloneOptions + } + // Push holds details about calls to the Push method. + Push []struct { + // Repo is the repo argument value. + Repo *git.Repository + // O is the o argument value. + O *git.PushOptions + } + } + lockClone sync.RWMutex + lockPush sync.RWMutex +} + +// Clone calls CloneFunc. +func (mock *GitRemoteInteractorMock) Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) { + if mock.CloneFunc == nil { + panic("GitRemoteInteractorMock.CloneFunc: method is nil but GitRemoteInteractor.Clone was just called") + } + callInfo := struct { + S storage.Storer + Worktree billy.Filesystem + O *git.CloneOptions + }{ + S: s, + Worktree: worktree, + O: o, + } + mock.lockClone.Lock() + mock.calls.Clone = append(mock.calls.Clone, callInfo) + mock.lockClone.Unlock() + return mock.CloneFunc(s, worktree, o) +} + +// CloneCalls gets all the calls that were made to Clone. +// Check the length with: +// +// len(mockedGitRemoteInteractor.CloneCalls()) +func (mock *GitRemoteInteractorMock) CloneCalls() []struct { + S storage.Storer + Worktree billy.Filesystem + O *git.CloneOptions +} { + var calls []struct { + S storage.Storer + Worktree billy.Filesystem + O *git.CloneOptions + } + mock.lockClone.RLock() + calls = mock.calls.Clone + mock.lockClone.RUnlock() + return calls +} + +// Push calls PushFunc. +func (mock *GitRemoteInteractorMock) Push(repo *git.Repository, o *git.PushOptions) error { + if mock.PushFunc == nil { + panic("GitRemoteInteractorMock.PushFunc: method is nil but GitRemoteInteractor.Push was just called") + } + callInfo := struct { + Repo *git.Repository + O *git.PushOptions + }{ + Repo: repo, + O: o, + } + mock.lockPush.Lock() + mock.calls.Push = append(mock.calls.Push, callInfo) + mock.lockPush.Unlock() + return mock.PushFunc(repo, o) +} + +// PushCalls gets all the calls that were made to Push. +// Check the length with: +// +// len(mockedGitRemoteInteractor.PushCalls()) +func (mock *GitRemoteInteractorMock) PushCalls() []struct { + Repo *git.Repository + O *git.PushOptions +} { + var calls []struct { + Repo *git.Repository + O *git.PushOptions + } + mock.lockPush.RLock() + calls = mock.calls.Push + mock.lockPush.RUnlock() + return calls +} diff --git a/lib/tools/git/repo/remote/remote.go b/lib/tools/git/repo/remote/remote.go new file mode 100644 index 00000000..efc01f50 --- /dev/null +++ b/lib/tools/git/repo/remote/remote.go @@ -0,0 +1,18 @@ +package remote + +import ( + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/storage" +) + +//go:generate go run github.com/matryer/moq@latest -skip-ensure -pkg mocks -out mocks/remote.go . GitRemoteInteractor + +// GitRemoteInteractor is an interface for interacting with a git remote repository. +type GitRemoteInteractor interface { + // Clone clones a repository. + Clone(s storage.Storer, worktree billy.Filesystem, o *git.CloneOptions) (*git.Repository, error) + + // Push pushes changes to a repository. + Push(repo *git.Repository, o *git.PushOptions) error +} diff --git a/lib/tools/git/repo/repo.go b/lib/tools/git/repo/repo.go new file mode 100644 index 00000000..48945a64 --- /dev/null +++ b/lib/tools/git/repo/repo.go @@ -0,0 +1,405 @@ +package repo + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + gg "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo/remote" + "github.com/spf13/afero" + df "gopkg.in/jfontan/go-billy-desfacer.v0" +) + +const ( + DEFAULT_AUTHOR = "Catalyst Forge" + DEFAULT_EMAIL = "forge@projectcatalyst.io" +) + +var ( + ErrTagNotFound = fmt.Errorf("tag not found") +) + +type GitRepoOption func(*GitRepo) + +// GitRepo is a high-level representation of a git repository. +type GitRepo struct { + auth *http.BasicAuth + basePath string + commitAuthor string + commitEmail string + fs afero.Fs + logger *slog.Logger + raw *gg.Repository + remote remote.GitRemoteInteractor + worktree *gg.Worktree +} + +// Clone loads a repository from a git remote. +func (g *GitRepo) Clone(path, url, branch string) error { + workdir := afero.NewBasePathFs(g.fs, path) + gitdir := afero.NewBasePathFs(g.fs, filepath.Join(path, ".git")) + ref := fmt.Sprintf("refs/heads/%s", branch) + + g.logger.Debug("Cloning repository", "url", url, "ref", ref) + storage := filesystem.NewStorage(df.New(gitdir), cache.NewObjectLRUDefault()) + repo, err := g.remote.Clone(storage, df.New(workdir), &gg.CloneOptions{ + URL: url, + Depth: 1, + ReferenceName: plumbing.ReferenceName(ref), + Auth: g.auth, + }) + + if err != nil { + return err + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + g.basePath = path + g.raw = repo + g.worktree = wt + + return nil +} + +// Commit creates a commit with the given message. +func (g *GitRepo) Commit(msg string) (plumbing.Hash, error) { + author, email := g.getAuthor() + hash, err := g.worktree.Commit(msg, &gg.CommitOptions{ + Author: &object.Signature{ + Name: author, + Email: email, + When: time.Now(), + }, + }) + + if err != nil { + return plumbing.ZeroHash, err + } + + return hash, nil +} + +// Exists checks if a file exists in the repository. +func (g *GitRepo) Exists(path string) (bool, error) { + _, err := g.fs.Stat(filepath.Join(g.basePath, path)) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } else if err != nil { + return false, fmt.Errorf("failed to check if file exists: %w", err) + } + + return true, nil +} + +// GetCommit returns the commit with the given hash. +func (g *GitRepo) GetCommit(hash plumbing.Hash) (*object.Commit, error) { + return g.raw.CommitObject(hash) +} + +// GetCurrentBranch returns the name of the current branch. +func (g *GitRepo) GetCurrentBranch() (string, error) { + head, err := g.raw.Head() + if err != nil { + return "", err + } + + return head.Name().Short(), nil +} + +// GetCurrentTag returns the tag of the current HEAD commit, if it exists. +func (g *GitRepo) GetCurrentTag() (string, error) { + tags, err := g.raw.Tags() + if err != nil { + return "", fmt.Errorf("failed to get tags: %w", err) + } + + ref, err := g.raw.Head() + if err != nil { + return "", err + } + + var tag string + err = tags.ForEach(func(t *plumbing.Reference) error { + // Only process annotated tags + tobj, err := g.raw.TagObject(t.Hash()) + if err != nil { + return nil + } + + if tobj.Target == ref.Hash() { + tag = tobj.Name + return nil + } + + return nil + }) + + if err != nil { + return "", fmt.Errorf("failed to iterate over tags: %w", err) + } + + if tag == "" { + return "", ErrTagNotFound + } + + return tag, nil +} + +// HasChanges returns true if the repository has changes. +func (g *GitRepo) HasChanges() (bool, error) { + status, err := g.worktree.Status() + if err != nil { + return false, err + } + + return !status.IsClean(), nil +} + +// Head returns the HEAD reference of the current branch. +func (g *GitRepo) Head() (*plumbing.Reference, error) { + return g.raw.Head() +} + +// Init initializes a new repository at the given path. +func (g *GitRepo) Init(path string) error { + var workdir afero.Fs + if path == "" { + workdir = g.fs + } else { + workdir = afero.NewBasePathFs(g.fs, path) + } + + gitdir := afero.NewBasePathFs(g.fs, filepath.Join(path, ".git")) + + storage := filesystem.NewStorage(df.New(gitdir), cache.NewObjectLRUDefault()) + repo, err := gg.Init(storage, df.New(workdir)) + if err != nil { + return fmt.Errorf("failed to init repository: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + g.basePath = path + g.raw = repo + g.worktree = wt + + return nil +} + +// MkdirAll creates a directory and all necessary parents. +func (g *GitRepo) MkdirAll(path string) error { + return g.fs.MkdirAll(filepath.Join(g.basePath, path), 0755) +} + +// NewBranch creates a new branch with the given name. +func (g *GitRepo) NewBranch(name string) error { + head, err := g.raw.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD reference: %w", err) + } + + return g.worktree.Checkout(&gg.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName(name), + Hash: head.Hash(), + Create: true, + }) +} + +// NewTag creates a new tag with the given name and message. +func (g *GitRepo) NewTag(commit plumbing.Hash, name, message string) (*plumbing.Reference, error) { + author, email := g.getAuthor() + tag, err := g.raw.CreateTag(name, commit, &gg.CreateTagOptions{ + Tagger: &object.Signature{ + Name: author, + Email: email, + When: time.Now(), + }, + Message: message, + }) + + if err != nil { + return nil, fmt.Errorf("failed to create tag: %w", err) + } + + return tag, nil +} + +// Open loads a repository from a local path. +func (g *GitRepo) Open(path string) error { + workdir := afero.NewBasePathFs(g.fs, path) + gitdir := afero.NewBasePathFs(g.fs, filepath.Join(path, ".git")) + + storage := filesystem.NewStorage(df.New(gitdir), cache.NewObjectLRUDefault()) + repo, err := gg.Open(storage, df.New(workdir)) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + g.basePath = path + g.raw = repo + g.worktree = wt + + return nil +} + +// ReadFile reads the contents of a file in the repository. +func (g *GitRepo) ReadFile(path string) ([]byte, error) { + return afero.ReadFile(g.fs, path) +} + +// Push pushes changes to the remote repository. +func (g *GitRepo) Push() error { + return g.remote.Push(g.raw, &gg.PushOptions{ + Auth: g.auth, + }) +} + +// Raw returns the underlying go-git repository. +func (g *GitRepo) Raw() *gg.Repository { + return g.raw +} + +// ReadDir reads the contents of a directory in the repository. +func (g *GitRepo) ReadDir(path string) ([]os.FileInfo, error) { + return afero.ReadDir(g.fs, filepath.Join(g.basePath, path)) +} + +// RemoveFile removes a file from the repository. +func (g *GitRepo) RemoveFile(path string) error { + return g.fs.Remove(filepath.Join(g.basePath, path)) +} + +// SetAuth sets the authentication for the interacting with a remote repository. +func (g *GitRepo) SetAuth(auth *http.BasicAuth) { + g.auth = auth +} + +// StageFile adds a file to the staging area. +func (g *GitRepo) StageFile(path string) error { + _, err := g.worktree.Add(path) + if err != nil { + return fmt.Errorf("failed to stage file: %w", err) + } + + return nil +} + +// UnstageFile removes a file from the staging area. +func (g *GitRepo) UnstageFile(path string) error { + _, err := g.worktree.Remove(path) + if err != nil { + return fmt.Errorf("failed to unstage file: %w", err) + } + + return nil +} + +// WriteFile writes the given contents to the given path in the repository. +// It also automatically adds the file to the staging area. +func (g *GitRepo) WriteFile(path string, contents []byte) error { + newPath := filepath.Join(g.basePath, path) + if err := afero.WriteFile(g.fs, newPath, contents, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + _, err := g.worktree.Add(path) + if err != nil { + return fmt.Errorf("failed to add file: %w", err) + } + + return nil +} + +// getAuthor returns the author and email for commits. +func (g *GitRepo) getAuthor() (string, string) { + author := g.commitAuthor + email := g.commitEmail + if author == "" { + author = DEFAULT_AUTHOR + } + + if email == "" { + email = DEFAULT_EMAIL + } + + return author, email +} + +// WithAuth sets the authentication for the interacting with a remote repository. +func WithAuth(username, password string) GitRepoOption { + return func(g *GitRepo) { + g.auth = &http.BasicAuth{ + Username: username, + Password: password, + } + } +} + +// WithAuthor sets the author for all commits. +func WithAuthor(name, email string) GitRepoOption { + return func(g *GitRepo) { + g.commitAuthor = name + g.commitEmail = email + } +} + +// WithGitRemoteInteractor sets the remote interactor for the repository. +func WithGitRemoteInteractor(remote remote.GitRemoteInteractor) GitRepoOption { + return func(g *GitRepo) { + g.remote = remote + } +} + +// WithFS sets the filesystem for the repository. +func WithFS(fs afero.Fs) GitRepoOption { + return func(g *GitRepo) { + g.fs = fs + } +} + +// WithMemFS sets the repository to use an in-memory filesystem. +func WithMemFS() GitRepoOption { + return func(g *GitRepo) { + g.fs = afero.NewMemMapFs() + } +} + +// NewGitRepo creates a new GitRepo instance. +func NewGitRepo(logger *slog.Logger, opts ...GitRepoOption) GitRepo { + r := GitRepo{ + logger: logger, + } + + for _, opt := range opts { + opt(&r) + } + + if r.fs == nil { + r.fs = afero.NewOsFs() + } else if r.remote == nil { + r.remote = remote.GoGitRemoteInteractor{} + } + + return r +} diff --git a/lib/tools/git/repo/repo_test.go b/lib/tools/git/repo/repo_test.go new file mode 100644 index 00000000..19a5c3e8 --- /dev/null +++ b/lib/tools/git/repo/repo_test.go @@ -0,0 +1,328 @@ +package repo + +import ( + "fmt" + "io" + "log/slog" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-billy/v5" + gg "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/cache" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage" + "github.com/go-git/go-git/v5/storage/filesystem" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo/remote/mocks" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + df "gopkg.in/jfontan/go-billy-desfacer.v0" +) + +const ( + repoPath = "/repo" +) + +func TestGitRepoClone(t *testing.T) { + repo := newRepo(t) + + var opts *gg.CloneOptions + r := GitRepo{ + fs: repo.fs, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + remote: &mocks.GitRemoteInteractorMock{ + CloneFunc: func(s storage.Storer, worktree billy.Filesystem, o *gg.CloneOptions) (*gg.Repository, error) { + opts = o + return repo.repo, nil + }, + }, + } + + err := r.Clone(repoPath, "test.com", "master") + require.NoError(t, err) + + assert.Equal(t, opts.URL, "test.com") + assert.Equal(t, opts.ReferenceName, plumbing.ReferenceName("refs/heads/master")) + + head, err := r.raw.Head() + require.NoError(t, err) + + commit, err := r.GetCommit(head.Hash()) + require.NoError(t, err) + assert.Equal(t, commit.Message, "test") +} + +func TestGitRepoCommit(t *testing.T) { + t.Run("succcess", func(t *testing.T) { + repo := newGitRepo(t) + err := afero.WriteFile(repo.fs, joinPath("file.txt"), []byte("test"), 0644) + require.NoError(t, err) + + err = repo.StageFile("file.txt") + require.NoError(t, err) + + hash, err := repo.Commit("test") + require.NoError(t, err) + + commit, err := repo.GetCommit(hash) + require.NoError(t, err) + assert.Equal(t, commit.Message, "test") + }) +} + +func TestGitRepoExists(t *testing.T) { + t.Run("exists", func(t *testing.T) { + repo := newGitRepo(t) + err := afero.WriteFile(repo.fs, joinPath("file.txt"), []byte("test"), 0644) + require.NoError(t, err) + + exists, err := repo.Exists("file.txt") + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("does not exist", func(t *testing.T) { + repo := newGitRepo(t) + + exists, err := repo.Exists("file.txt") + require.NoError(t, err) + assert.False(t, exists) + }) +} + +func TestGitRepoGetCurrentBranch(t *testing.T) { + repo := newGitRepo(t) + + branch, err := repo.GetCurrentBranch() + require.NoError(t, err) + assert.Equal(t, branch, "master") +} + +func TestGitRepoGetCurrentTag(t *testing.T) { + t.Run("tag exists", func(t *testing.T) { + repo := newGitRepo(t) + + head, err := repo.Head() + require.NoError(t, err) + repo.NewTag(head.Hash(), "test", "test") + + tag, err := repo.GetCurrentTag() + require.NoError(t, err) + assert.Equal(t, tag, "test") + }) + + t.Run("tag does not exist", func(t *testing.T) { + repo := newGitRepo(t) + + tag, err := repo.GetCurrentTag() + require.Error(t, err) + assert.Contains(t, err.Error(), "tag not found") + assert.Equal(t, tag, "") + }) +} + +func TestGitRepoHasChanges(t *testing.T) { + t.Run("has changes", func(t *testing.T) { + repo := newGitRepo(t) + + err := afero.WriteFile(repo.fs, joinPath("file.txt"), []byte("test"), 0644) + require.NoError(t, err) + + changes, err := repo.HasChanges() + require.NoError(t, err) + assert.True(t, changes) + }) + + t.Run("no changes", func(t *testing.T) { + repo := newGitRepo(t) + + changes, err := repo.HasChanges() + require.NoError(t, err) + assert.False(t, changes) + }) +} + +func TestGitRepoNewBranch(t *testing.T) { + repo := newGitRepo(t) + + err := repo.NewBranch("test") + require.NoError(t, err) + + head, err := repo.raw.Head() + require.NoError(t, err) + assert.Equal(t, head.Name().String(), "refs/heads/test") +} + +func TestGitRepoNewTag(t *testing.T) { + repo := newGitRepo(t) + + err := afero.WriteFile(repo.fs, joinPath("file.txt"), []byte("test"), 0644) + require.NoError(t, err) + + err = repo.StageFile("file.txt") + require.NoError(t, err) + + hash, err := repo.Commit("test") + require.NoError(t, err) + + tag, err := repo.NewTag(hash, "test", "test") + require.NoError(t, err) + + assert.Equal(t, tag.Name().String(), "refs/tags/test") +} + +func TestGitRepoOpen(t *testing.T) { + repo := newRepo(t) + + r := GitRepo{ + fs: repo.fs, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + err := r.Open(repoPath) + require.NoError(t, err) + + head, err := r.raw.Head() + require.NoError(t, err) + + commit, err := r.GetCommit(head.Hash()) + require.NoError(t, err) + assert.Equal(t, commit.Message, "test") +} + +func TestGitRepoWriteFile(t *testing.T) { + repo := newGitRepo(t) + + err := repo.WriteFile("file.txt", []byte("test")) + require.NoError(t, err) + + status, err := repo.worktree.Status() + require.NoError(t, err) + assert.Contains(t, status, "file.txt") +} + +func TestGitRepoPush(t *testing.T) { + t.Run("success", func(t *testing.T) { + repo := newGitRepo(t) + + var opts *gg.PushOptions + auth := &http.BasicAuth{ + Username: "forge", + Password: "test", + } + + repo.auth = auth + repo.remote = &mocks.GitRemoteInteractorMock{ + PushFunc: func(r *gg.Repository, o *gg.PushOptions) error { + opts = o + return nil + }, + } + + err := repo.Push() + assert.NoError(t, err) + assert.Equal(t, opts.Auth, auth) + }) + + t.Run("error", func(t *testing.T) { + repo := newGitRepo(t) + + repo.remote = &mocks.GitRemoteInteractorMock{ + PushFunc: func(r *gg.Repository, o *gg.PushOptions) error { + return fmt.Errorf("failed to push") + }, + } + + err := repo.Push() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to push") + }) +} + +func TestGitRepoStageFile(t *testing.T) { + t.Run("success", func(t *testing.T) { + repo := newGitRepo(t) + afero.WriteFile(repo.fs, joinPath("file.txt"), []byte("test"), 0644) + + _, err := repo.worktree.Add("file.txt") + require.NoError(t, err, "failed to add file") + + status, err := repo.worktree.Status() + require.NoError(t, err) + + assert.Contains(t, status, "file.txt") + }) + + t.Run("file missing", func(t *testing.T) { + repo := newGitRepo(t) + + err := repo.StageFile("file.txt") + require.Error(t, err) + assert.Contains(t, err.Error(), "entry not found") + }) +} + +func joinPath(path string) string { + return filepath.Join(repoPath, path) +} + +type testRepo struct { + repo *gg.Repository + fs afero.Fs + worktree *gg.Worktree +} + +func newRepo(t *testing.T) testRepo { + fs := afero.NewMemMapFs() + rfs := afero.NewBasePathFs(fs, repoPath) + wfs := df.New(rfs) + gitdir := afero.NewBasePathFs(rfs, ".git") + storage := filesystem.NewStorage(df.New(gitdir), cache.NewObjectLRUDefault()) + + repo, err := gg.Init(storage, wfs) + require.NoError(t, err, "failed to init repo") + + worktree, err := repo.Worktree() + require.NoError(t, err, "failed to get worktree") + + afero.WriteFile(fs, filepath.Join(repoPath, "test.txt"), []byte("test"), 0644) + + _, err = worktree.Add("test.txt") + require.NoError(t, err, "failed to add file") + + status, err := worktree.Status() + require.NoError(t, err) + assert.False(t, status.IsClean()) + assert.Contains(t, status, "test.txt") + + _, err = worktree.Commit("test", &gg.CommitOptions{ + Author: &object.Signature{ + Name: "test", + Email: "test@test.com", + When: time.Now(), + }, + }) + require.NoError(t, err, "failed to commit") + + return testRepo{ + fs: fs, + repo: repo, + worktree: worktree, + } +} + +func newGitRepo(t *testing.T) GitRepo { + repo := newRepo(t) + + return GitRepo{ + basePath: "/repo", + fs: repo.fs, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + raw: repo.repo, + worktree: repo.worktree, + } +} diff --git a/lib/tools/git/tag.go b/lib/tools/git/tag.go index f140954c..da2b5809 100644 --- a/lib/tools/git/tag.go +++ b/lib/tools/git/tag.go @@ -2,11 +2,9 @@ package git import ( "fmt" - "os" - "strings" - gg "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/github" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" ) var ( @@ -14,16 +12,18 @@ var ( ) // GetTag returns the tag of the current HEAD commit. -func GetTag(repo *gg.Repository) (string, error) { +func GetTag(r *repo.GitRepo) (string, error) { var tag string var err error - if InCI() { - tag, err = getCITag() - if err != nil { - return "", err + env := github.NewGithubEnv(nil) + + if github.InCI() { + tag = env.GetTag() + if tag == "" { + return "", ErrTagNotFound } } else { - tag, err = getLocalTag(repo) + tag, err = r.GetCurrentTag() if err != nil { return "", err } @@ -31,52 +31,3 @@ func GetTag(repo *gg.Repository) (string, error) { return tag, nil } - -// getCITag returns the tag from the CI environment if it exists. -func getCITag() (string, error) { - tag, exists := os.LookupEnv("GITHUB_REF") - if exists && strings.HasPrefix(tag, "refs/tags/") { - return strings.TrimPrefix(tag, "refs/tags/"), nil - } else { - return "", ErrTagNotFound - } -} - -// getLocalTag returns the tag of the current HEAD commit if it exists. -func getLocalTag(repo *gg.Repository) (string, error) { - tags, err := repo.Tags() - if err != nil { - return "", fmt.Errorf("failed to get tags: %w", err) - } - - ref, err := repo.Head() - if err != nil { - return "", err - } - - var tag string - err = tags.ForEach(func(t *plumbing.Reference) error { - // Only process annotated tags - tobj, err := repo.TagObject(t.Hash()) - if err != nil { - return nil - } - - if tobj.Target == ref.Hash() { - tag = tobj.Name - return nil - } - - return nil - }) - - if err != nil { - return "", fmt.Errorf("failed to iterate over tags: %w", err) - } - - if tag == "" { - return "", ErrTagNotFound - } - - return tag, nil -} diff --git a/lib/tools/git/util.go b/lib/tools/git/util.go index 69d86975..2b0aa746 100644 --- a/lib/tools/git/util.go +++ b/lib/tools/git/util.go @@ -3,7 +3,6 @@ package git import ( "errors" "io" - "os" "path/filepath" "github.com/input-output-hk/catalyst-forge/lib/tools/walker" @@ -43,12 +42,3 @@ func FindGitRoot(startPath string, rw walker.ReverseWalker) (string, error) { return gitRoot, nil } - -// InCI returns true if the code is running in a CI environment. -func InCI() bool { - if _, ok := os.LookupEnv("GITHUB_ACTIONS"); ok { - return true - } - - return false -} diff --git a/lib/tools/go.mod b/lib/tools/go.mod index 88660079..3de882de 100644 --- a/lib/tools/go.mod +++ b/lib/tools/go.mod @@ -7,6 +7,7 @@ require ( github.com/earthly/earthly/ast v0.0.2-0.20240228223838-42e8ca204e8a github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/google/go-github/v66 v66.0.0 github.com/spf13/afero v1.11.0 github.com/stretchr/testify v1.9.0 gopkg.in/jfontan/go-billy-desfacer.v0 v0.0.0-20210209210102-b43512b1cad0 @@ -24,6 +25,7 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect diff --git a/lib/tools/go.sum b/lib/tools/go.sum index 917d9e78..ea71ba09 100644 --- a/lib/tools/go.sum +++ b/lib/tools/go.sum @@ -50,8 +50,13 @@ github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7 github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= +github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -179,6 +184,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/lib/tools/testutils/git.go b/lib/tools/testutils/git.go index e695b978..3bfe78dd 100644 --- a/lib/tools/testutils/git.go +++ b/lib/tools/testutils/git.go @@ -1,147 +1,26 @@ package testutils import ( - "io" - "os" "testing" - "time" - "github.com/go-git/go-billy/v5" - "github.com/go-git/go-billy/v5/memfs" - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "github.com/go-git/go-git/v5/storage/memory" - "github.com/stretchr/testify/assert" + "github.com/input-output-hk/catalyst-forge/lib/tools/git/repo" + "github.com/spf13/afero" "github.com/stretchr/testify/require" ) -// InMemRepo represents an in-memory git repository. -type InMemRepo struct { - Fs billy.Filesystem - Repo *git.Repository - Worktree *git.Worktree -} - -// AddFile creates a file in the repository and adds it to the worktree. -func (r *InMemRepo) AddFile(t *testing.T, path, content string) { - file, err := r.Fs.Create(path) - require.NoError(t, err, "failed to create file") - - _, err = file.Write([]byte(content)) - require.NoError(t, err, "failed to write to file") - - err = file.Close() - require.NoError(t, err, "failed to close file") - - _, err = r.Worktree.Add(path) - require.NoError(t, err, "failed to add file") -} - -// AddExistingFile adds an existing file to the worktree. -func (r *InMemRepo) AddExistingFile(t *testing.T, path string) { - _, err := r.Worktree.Add(path) - require.NoError(t, err, "failed to add file") -} - -// Commit creates a commit in the repository. -func (r *InMemRepo) Commit(t *testing.T, message string) plumbing.Hash { - commit, err := r.Worktree.Commit(message, &git.CommitOptions{ - Author: &object.Signature{ - Name: "Test", - Email: "test@test.com", - When: time.Now(), - }, - }) - require.NoError(t, err, "failed to commit") - - return commit -} - -// CreateFile creates a file in the repository. -func (r *InMemRepo) CreateFile(t *testing.T, path, content string) { - file, err := r.Fs.Create(path) - require.NoError(t, err, "failed to create file") - - _, err = file.Write([]byte(content)) - require.NoError(t, err, "failed to write to file") - - err = file.Close() - require.NoError(t, err, "failed to close file") -} - -// Exists checks if a file exists in the repository. -func (r *InMemRepo) Exists(t *testing.T, path string) bool { - _, err := r.Fs.Stat(path) - if err == os.ErrNotExist { - return false - } else if err != nil { - t.Fatalf("failed to check if file exists: %v", err) - } +// NewTestRepo creates a new test git repository with a memory filesystem. +func NewTestRepo(t *testing.T, opts ...repo.GitRepoOption) repo.GitRepo { + opts = append(opts, repo.WithMemFS()) + repo := repo.NewGitRepo(NewNoopLogger(), opts...) + require.NoError(t, repo.Init(""), "failed to init repo") - return true + return repo } -func (r *InMemRepo) Head(t *testing.T) *plumbing.Reference { - headRef, err := r.Repo.Head() - require.NoError(t, err, "failed to get HEAD reference") - - return headRef -} - -// MkdirAll creates a directory in the repository. -func (r *InMemRepo) MkdirAll(t *testing.T, path string) { - require.NoError(t, r.Fs.MkdirAll(path, 0755), "failed to create directory") -} - -// NewBranch creates a new branch in the repository and checks it out. -func (r *InMemRepo) NewBranch(t *testing.T, name string) { - head := r.Head(t) - require.NoError(t, r.Worktree.Checkout(&git.CheckoutOptions{ - Branch: plumbing.NewBranchReferenceName(name), - Hash: head.Hash(), - Create: true, - })) -} - -func (r *InMemRepo) ReadFile(t *testing.T, path string) []byte { - file, err := r.Fs.Open(path) - require.NoError(t, err, "failed to open file") - - contents, err := io.ReadAll(file) - require.NoError(t, err, "failed to read file") - require.NoError(t, file.Close(), "failed to close file") - - return contents -} - -// Tag creates a tag in the repository. -func (r *InMemRepo) Tag(t *testing.T, commit plumbing.Hash, name, message string) *plumbing.Reference { - tag, err := r.Repo.CreateTag(name, commit, &git.CreateTagOptions{ - Tagger: &object.Signature{ - Name: "Test", - Email: "test@test.com", - When: time.Now(), - }, - Message: message, - }) - require.NoError(t, err, "failed to create tag") - - return tag -} - -// NewInMemRepo creates a new in-memory git repository. -func NewInMemRepo(t *testing.T) InMemRepo { - fs := memfs.New() - repo, err := git.Init(memory.NewStorage(), fs) - assert.NoError(t, err, "failed to init repo") - - worktree, err := repo.Worktree() - require.NoError(t, err, "failed to get worktree") +// NewTestRepoWithFS creates a new test git repository with the given filesystem. +func NewTestRepoWithFS(t *testing.T, fs afero.Fs, path string) repo.GitRepo { + repo := repo.NewGitRepo(NewNoopLogger(), repo.WithFS(fs)) + require.NoError(t, repo.Init(path), "failed to init repo") - return InMemRepo{ - Fs: fs, - Repo: repo, - Worktree: worktree, - } + return repo }