diff --git a/README.md b/README.md index e255681..aab8409 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,66 @@ spec: name: git-credentials ``` +#### SSH Key Authentication + +For SSH-based repository access, create a secret with the SSH private key: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: git-ssh-credentials + namespace: default +data: + sshPrivateKey: +``` + +Optional fields: +- `sshPrivateKeyPassword`: Passphrase for encrypted private keys +- `known_hosts`: SSH known_hosts file content for host key verification. If omitted, host key checking is skipped. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: git-ssh-credentials + namespace: default +data: + sshPrivateKey: + sshPrivateKeyPassword: + known_hosts: +``` + +Reference it in the Function with an SSH repository URL: + +```yaml +apiVersion: functions.dev/v1alpha1 +kind: Function +metadata: + name: my-function + namespace: default +spec: + repository: + url: git@github.com:your-org/your-function.git + authSecretRef: + name: git-ssh-credentials +``` + +For public repositories accessible over SSH, no secret is needed: + +```yaml +apiVersion: functions.dev/v1alpha1 +kind: Function +metadata: + name: my-function + namespace: default +spec: + repository: + url: git@github.com:your-org/your-function.git +``` + +Both SCP-style URLs (`git@host:path`) and standard SSH URLs (`ssh://git@host/path`) are supported. + ### Check Function Status The Function CRD has the short name `func`, so you can use `kubectl get func` instead of `kubectl get function`. @@ -300,7 +360,7 @@ make lint | Field | Type | Required | Description | |-----------------------------|---------|----------|--------------------------------------------------------------------------------------------------| -| `repository.url` | string | Yes | URL of the Git repository containing the function | +| `repository.url` | string | Yes | URL of the Git repository. Supports HTTPS, HTTP, SSH (`ssh://`), and SCP-style (`git@host:path`) | | `repository.branch` | string | No | Branch of the repository | | `repository.path` | string | No | Path to the function inside the repository. Defaults to "." | | `repository.authSecretRef` | object | No | Reference to the auth secret for private repository authentication | diff --git a/go.mod b/go.mod index dd98a90..6dbc811 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/onsi/gomega v1.40.0 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.50.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.35.4 k8s.io/apimachinery v0.35.4 @@ -122,7 +123,6 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect diff --git a/internal/git/manager.go b/internal/git/manager.go index 9ad9645..d2576f0 100644 --- a/internal/git/manager.go +++ b/internal/git/manager.go @@ -3,16 +3,18 @@ package git import ( "context" "fmt" - neturl "net/url" "os" - "strings" + "path/filepath" "github.com/functions-dev/func-operator/internal/monitoring" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/client" + "github.com/go-git/go-git/v6/plumbing/transport" "github.com/go-git/go-git/v6/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v6/plumbing/transport/ssh" "github.com/prometheus/client_golang/prometheus" + gossh "golang.org/x/crypto/ssh" ) const ( @@ -27,6 +29,12 @@ func NewManager() (Manager, error) { if err := os.MkdirAll(cloneBaseDir, 0755); err != nil { return nil, fmt.Errorf("failed to create git clone base directory: %w", err) } + // go-git's SSH transport requires a known_hosts file for host key algorithm + // discovery, even when HostKeyCallback is already set. Without this file, + // SSH connections fail in containers that lack ~/.ssh/known_hosts. + if err := ensureKnownHostsExists(); err != nil { + return nil, fmt.Errorf("failed to ensure known_hosts exists: %w", err) + } return &managerImpl{}, nil } @@ -36,13 +44,12 @@ func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, subPath, ref timer := prometheus.NewTimer(monitoring.GitCloneDuration) defer timer.ObserveDuration() - url, err := neturl.Parse(repoUrl) + parsedURL, err := transport.ParseURL(repoUrl) if err != nil { return nil, fmt.Errorf("failed to parse repository URL: %w", err) } - pattern := fmt.Sprintf("%s-%s-%s", url.Host, strings.ReplaceAll(strings.TrimSuffix(url.Path, ".git"), "/", "-"), reference) - targetDir, err := os.MkdirTemp(cloneBaseDir, pattern) + targetDir, err := os.MkdirTemp(cloneBaseDir, "repo-*") if err != nil { return nil, fmt.Errorf("failed to create temporary directory: %w", err) } @@ -52,7 +59,7 @@ func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, subPath, ref ReferenceName: plumbing.ReferenceName(reference), SingleBranch: true, Depth: 1, - ClientOptions: m.getClientOptions(auth), + ClientOptions: m.getClientOptions(parsedURL.Scheme, auth), }) if err != nil { return nil, fmt.Errorf("failed to clone repo: %w", err) @@ -71,7 +78,14 @@ func (m *managerImpl) CloneRepository(ctx context.Context, repoUrl, subPath, ref }, nil } -func (m *managerImpl) getClientOptions(authSecret map[string][]byte) []client.Option { +func (m *managerImpl) getClientOptions(scheme string, authSecret map[string][]byte) []client.Option { + if scheme == "ssh" { + return m.getSSHClientOptions(authSecret) + } + return m.getHTTPClientOptions(authSecret) +} + +func (m *managerImpl) getHTTPClientOptions(authSecret map[string][]byte) []client.Option { if len(authSecret) == 0 { return nil } else if token, ok := authSecret["token"]; ok { @@ -91,6 +105,57 @@ func (m *managerImpl) getClientOptions(authSecret map[string][]byte) []client.Op } } return nil - } // add other auth methods when needed + } + return nil +} + +func ensureKnownHostsExists() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + sshDir := filepath.Join(home, ".ssh") + if err := os.MkdirAll(sshDir, 0700); err != nil { + return err + } + knownHostsPath := filepath.Join(sshDir, "known_hosts") + if _, err := os.Stat(knownHostsPath); os.IsNotExist(err) { + return os.WriteFile(knownHostsPath, nil, 0644) + } return nil } + +func (m *managerImpl) getSSHClientOptions(authSecret map[string][]byte) []client.Option { + privateKey, hasKey := authSecret["sshPrivateKey"] + if !hasKey { + return []client.Option{ + client.WithSSHAuth(&gitssh.Password{ + User: "git", + HostKeyCallbackHelper: gitssh.HostKeyCallbackHelper{HostKeyCallback: gossh.InsecureIgnoreHostKey()}, + }), + } + } + + password := string(authSecret["sshPrivateKeyPassword"]) + auth, err := gitssh.NewPublicKeys("git", privateKey, password) + if err != nil { + return nil + } + auth.HostKeyCallback = gossh.InsecureIgnoreHostKey() + + if knownHostsData, ok := authSecret["known_hosts"]; ok { + tmpFile, err := os.CreateTemp("", "known_hosts-*") + if err == nil { + defer os.Remove(tmpFile.Name()) + if _, err := tmpFile.Write(knownHostsData); err == nil { + _ = tmpFile.Close() + cb, err := gitssh.NewKnownHostsCallback(tmpFile.Name()) + if err == nil { + auth.HostKeyCallback = cb + } + } + } + } + + return []client.Option{client.WithSSHAuth(auth)} +} diff --git a/internal/git/manager_test.go b/internal/git/manager_test.go new file mode 100644 index 0000000..c9d7a58 --- /dev/null +++ b/internal/git/manager_test.go @@ -0,0 +1,118 @@ +package git + +import ( + "testing" + + "github.com/go-git/go-git/v6/plumbing/transport" +) + +const sshScheme = "ssh" + +const testEd25519PrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACDAtq/Kt1/J1J/YivGDJIO57fFW1v68f1eq1N1Vr77BLAAAALB+/pd5fv6X +eQAAAAtzc2gtZWQyNTUxOQAAACDAtq/Kt1/J1J/YivGDJIO57fFW1v68f1eq1N1Vr77BLA +AAAEDDodLIs7cKTLW+FFH5jgfGo2b2iae1w5lbsIXiu8UZKcC2r8q3X8nUn9iK8YMkg7nt +8VbW/rx/V6rU3VWvvsEsAAAAKmNzdGFibGVyQGNzdGFibGVyLXRoaW5rcGFkcDFnZW43Ln +JtdGRlLmNzYgECAw== +-----END OPENSSH PRIVATE KEY-----` + +func TestGetClientOptions_HTTPToken(t *testing.T) { + m := &managerImpl{} + secret := map[string][]byte{"token": []byte("my-token")} + opts := m.getClientOptions("https", secret) + if len(opts) != 1 { + t.Fatalf("expected 1 option, got %d", len(opts)) + } +} + +func TestGetClientOptions_HTTPUsernamePassword(t *testing.T) { + m := &managerImpl{} + secret := map[string][]byte{"username": []byte("user"), "password": []byte("pass")} + opts := m.getClientOptions("http", secret) + if len(opts) != 1 { + t.Fatalf("expected 1 option, got %d", len(opts)) + } +} + +func TestGetClientOptions_HTTPEmpty(t *testing.T) { + m := &managerImpl{} + opts := m.getClientOptions("https", nil) + if opts != nil { + t.Fatalf("expected nil options, got %v", opts) + } +} + +func TestGetClientOptions_SSHNoSecret(t *testing.T) { + m := &managerImpl{} + opts := m.getClientOptions(sshScheme, nil) + if len(opts) != 1 { + t.Fatalf("expected 1 option for insecure SSH, got %d", len(opts)) + } +} + +func TestGetClientOptions_SSHEmptySecret(t *testing.T) { + m := &managerImpl{} + opts := m.getClientOptions(sshScheme, map[string][]byte{}) + if len(opts) != 1 { + t.Fatalf("expected 1 option for insecure SSH, got %d", len(opts)) + } +} + +func TestGetClientOptions_SSHWithPrivateKey(t *testing.T) { + m := &managerImpl{} + secret := map[string][]byte{"sshPrivateKey": []byte(testEd25519PrivateKey)} + opts := m.getClientOptions(sshScheme, secret) + if len(opts) != 1 { + t.Fatalf("expected 1 option, got %d", len(opts)) + } +} + +func TestGetClientOptions_SSHWithInvalidKey(t *testing.T) { + m := &managerImpl{} + secret := map[string][]byte{"sshPrivateKey": []byte("not-a-valid-key")} + opts := m.getClientOptions(sshScheme, secret) + if opts != nil { + t.Fatalf("expected nil options for invalid key, got %v", opts) + } +} + +func TestParseURL_SCPStyle(t *testing.T) { + u, err := transport.ParseURL("git@github.com:owner/repo.git") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Scheme != sshScheme { + t.Fatalf("expected scheme ssh, got %s", u.Scheme) + } +} + +func TestParseURL_SSHScheme(t *testing.T) { + u, err := transport.ParseURL("ssh://git@github.com/owner/repo.git") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Scheme != sshScheme { + t.Fatalf("expected scheme ssh, got %s", u.Scheme) + } +} + +func TestParseURL_HTTPSScheme(t *testing.T) { + u, err := transport.ParseURL("https://github.com/owner/repo.git") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Scheme != "https" { + t.Fatalf("expected scheme https, got %s", u.Scheme) + } +} + +func TestParseURL_HTTPScheme(t *testing.T) { + u, err := transport.ParseURL("http://github.com/owner/repo.git") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Scheme != "http" { + t.Fatalf("expected scheme http, got %s", u.Scheme) + } +} diff --git a/test/e2e/func_deploy_test.go b/test/e2e/func_deploy_test.go index 8eafc14..1e84cec 100644 --- a/test/e2e/func_deploy_test.go +++ b/test/e2e/func_deploy_test.go @@ -106,6 +106,9 @@ func functionNotReadyWithAuthError(functionName, functionNamespace string) func( ContainSubstring("Authentication"), ContainSubstring("401"), ContainSubstring("Unauthorized"), + ContainSubstring("handshake failed"), + ContainSubstring("permission denied"), + ContainSubstring("ssh:"), )) return } @@ -114,6 +117,52 @@ func functionNotReadyWithAuthError(functionName, functionNamespace string) func( } } +// createSSHFunctionAndExpectReady creates a K8s Secret with the SSH private key, creates a Function +// CR pointing at the SSH repo URL with authSecretRef, and waits for it to become Ready. +// It returns the Function name so callers can store it for cleanup. +func createSSHFunctionAndExpectReady( + sshKeyPath, sshRepoURL, functionNamespace, namePrefix string, +) string { + privateKeyBytes, err := os.ReadFile(sshKeyPath) + Expect(err).NotTo(HaveOccurred()) + + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "git-ssh-auth-", + Namespace: functionNamespace, + }, + Data: map[string][]byte{ + "sshPrivateKey": privateKeyBytes, + }, + } + err = k8sClient.Create(ctx, secret) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(func() { + _ = k8sClient.Delete(ctx, secret) + }) + + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: namePrefix, + Namespace: functionNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: sshRepoURL, + AuthSecretRef: &v1.LocalObjectReference{ + Name: secret.Name, + }, + }, + }, + } + + err = k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + + Eventually(functionBecomesReady(function.Name, functionNamespace)).Should(Succeed()) + return function.Name +} + // functionNotDeployed check if the function is not ready as the function was not deployed yet func functionNotDeployed(functionName, functionNamespace string) func(g Gomega) { return expectFunctionConditionFalseWithReason( @@ -528,4 +577,175 @@ var _ = Describe("Operator", func() { }) }) }) + Context("with an SSH repository URL", func() { + var sshRepoURL string + var repoDir string + var sshKeyPath string + var functionName, functionNamespace string + + BeforeEach(func() { + username, password, _, cleanup, err := repoProvider.CreateRandomUser() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + repoName, repoURL, cleanup, err := repoProvider.CreateRandomRepo(username, false) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + // Generate SSH keypair and register with Gitea + keyDir, err := os.MkdirTemp("", "ssh-e2e-*") + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(os.RemoveAll, keyDir) + + sshKeyPath = filepath.Join(keyDir, "id_ed25519") + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", sshKeyPath, "-N", "", "-q") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + pubKeyBytes, err := os.ReadFile(sshKeyPath + ".pub") + Expect(err).NotTo(HaveOccurred()) + + err = repoProvider.CreateSSHKey(username, password, "e2e-key", string(pubKeyBytes)) + Expect(err).NotTo(HaveOccurred()) + + sshRepoURL, err = repoProvider.SSHRepoURL(username, repoName) + Expect(err).NotTo(HaveOccurred()) + + // Initialize repository with function code (via HTTP) + repoDir, err = utils.InitializeRepoWithFunction(repoURL, username, password, "go") + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(os.RemoveAll, repoDir) + + functionNamespace, err = utils.GetTestNamespace() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanupNamespaces, functionNamespace) + + // Deploy function using func CLI + out, err := utils.RunFuncDeploy(repoDir, utils.WithNamespace(functionNamespace)) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + utils.DeferCleanupOnSuccess(func() { + _, _ = utils.RunFunc("delete", "--path", repoDir, "--namespace", functionNamespace) + }) + + // Commit func.yaml changes + err = utils.CommitAndPush(repoDir, "Update func.yaml after deploy", "func.yaml") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + logFailedTestDetails(functionName, functionNamespace) + + if functionName != "" { + cmd := exec.Command("kubectl", "delete", "function", functionName, "-n", functionNamespace, "--ignore-not-found") + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + } + }) + + It("should mark the function as ready with SSH key auth", func() { + functionName = createSSHFunctionAndExpectReady( + sshKeyPath, sshRepoURL, functionNamespace, "my-ssh-function-") + }) + }) + Context("with a private SSH repository", func() { + var sshRepoURL string + var repoDir string + var username, password string + var sshKeyPath string + var functionName, functionNamespace string + + BeforeEach(func() { + var cleanup func() + var err error + var repoName string + var repoURL string + + username, password, _, cleanup, err = repoProvider.CreateRandomUser() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + repoName, repoURL, cleanup, err = repoProvider.CreateRandomRepo(username, true) + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanup) + + // Generate SSH keypair and register with Gitea + keyDir, err := os.MkdirTemp("", "ssh-e2e-*") + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(os.RemoveAll, keyDir) + + sshKeyPath = filepath.Join(keyDir, "id_ed25519") + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", sshKeyPath, "-N", "", "-q") + _, err = utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + pubKeyBytes, err := os.ReadFile(sshKeyPath + ".pub") + Expect(err).NotTo(HaveOccurred()) + + err = repoProvider.CreateSSHKey(username, password, "e2e-key", string(pubKeyBytes)) + Expect(err).NotTo(HaveOccurred()) + + sshRepoURL, err = repoProvider.SSHRepoURL(username, repoName) + Expect(err).NotTo(HaveOccurred()) + + // Initialize repository with function code (via HTTP) + repoDir, err = utils.InitializeRepoWithFunction(repoURL, username, password, "go") + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(os.RemoveAll, repoDir) + + functionNamespace, err = utils.GetTestNamespace() + Expect(err).NotTo(HaveOccurred()) + utils.DeferCleanupOnSuccess(cleanupNamespaces, functionNamespace) + + // Deploy function using func CLI + out, err := utils.RunFuncDeploy(repoDir, utils.WithNamespace(functionNamespace)) + Expect(err).NotTo(HaveOccurred()) + _, _ = fmt.Fprint(GinkgoWriter, out) + + utils.DeferCleanupOnSuccess(func() { + _, _ = utils.RunFunc("delete", "--path", repoDir, "--namespace", functionNamespace) + }) + + // Commit func.yaml changes + err = utils.CommitAndPush(repoDir, "Update func.yaml after deploy", "func.yaml") + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + logFailedTestDetails(functionName, functionNamespace) + + if functionName != "" { + cmd := exec.Command("kubectl", "delete", "function", functionName, "-n", functionNamespace, "--ignore-not-found") + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + } + }) + + It("should mark the function as ready when SSH key authSecretRef is provided", func() { + functionName = createSSHFunctionAndExpectReady( + sshKeyPath, sshRepoURL, functionNamespace, "my-ssh-private-function-") + }) + + It("should fail with authentication error when authSecretRef is not provided", func() { + function := &functionsdevv1alpha1.Function{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "my-ssh-private-function-noauth-", + Namespace: functionNamespace, + }, + Spec: functionsdevv1alpha1.FunctionSpec{ + Repository: functionsdevv1alpha1.FunctionSpecRepository{ + URL: sshRepoURL, + }, + }, + } + + err := k8sClient.Create(ctx, function) + Expect(err).NotTo(HaveOccurred()) + + functionName = function.Name + + Eventually(functionNotReadyWithAuthError(functionName, functionNamespace), 2*time.Minute).Should(Succeed()) + }) + }) }) diff --git a/test/utils/gitea.go b/test/utils/gitea.go index 797a908..6f4cc0e 100644 --- a/test/utils/gitea.go +++ b/test/utils/gitea.go @@ -46,14 +46,19 @@ type RepositoryProvider interface { // Authentication CreateAccessToken(username, password, tokenName string) (string, error) + + // SSH support + CreateSSHKey(username, password, title, publicKey string) error + SSHRepoURL(owner, repo string) (string, error) } // GiteaClient wraps the Gitea SDK client and provides helper methods type GiteaClient struct { - client *gitea.Client - baseURL string - adminUser string - adminPass string + client *gitea.Client + baseURL string + sshEndpoint string + adminUser string + adminPass string } // NewGiteaClient discovers Gitea endpoint from ConfigMap and creates client @@ -87,6 +92,11 @@ func NewGiteaClient() (*GiteaClient, error) { return nil, fmt.Errorf("gitea-endpoint configmap missing 'http' key") } + sshEndpoint, ok := cm.Data["ssh"] + if !ok { + return nil, fmt.Errorf("gitea-endpoint configmap missing 'ssh' key") + } + // Create Gitea SDK client giteaClient, err := gitea.NewClient(baseURL, gitea.SetBasicAuth(giteaAdminUser, giteaAdminPass)) if err != nil { @@ -94,10 +104,11 @@ func NewGiteaClient() (*GiteaClient, error) { } return &GiteaClient{ - client: giteaClient, - baseURL: baseURL, - adminUser: giteaAdminUser, - adminPass: giteaAdminPass, + client: giteaClient, + baseURL: baseURL, + sshEndpoint: sshEndpoint, + adminUser: giteaAdminUser, + adminPass: giteaAdminPass, }, nil } @@ -175,6 +186,30 @@ func (g *GiteaClient) CreateRandomRepo(owner string, private bool) (name, url st return name, url, cleanup, err } +// CreateSSHKey registers an SSH public key for a Gitea user +func (g *GiteaClient) CreateSSHKey(username, password, title, publicKey string) error { + userClient, err := gitea.NewClient(g.baseURL, gitea.SetBasicAuth(username, password)) + if err != nil { + return fmt.Errorf("failed to create user client: %w", err) + } + + _, _, err = userClient.CreatePublicKey(gitea.CreateKeyOption{ + Title: title, + Key: publicKey, + }) + if err != nil { + return fmt.Errorf("failed to create SSH key for %s: %w", username, err) + } + + return nil +} + +// SSHRepoURL returns the SSH URL for a repository. +// The SSH endpoint format from the ConfigMap is "host:port". +func (g *GiteaClient) SSHRepoURL(owner, repo string) (string, error) { + return fmt.Sprintf("ssh://git@%s/%s/%s.git", g.sshEndpoint, owner, repo), nil +} + // CreateAccessToken creates a personal access token for a user func (g *GiteaClient) CreateAccessToken(username, password, tokenName string) (string, error) { // Create a client authenticated as the user