Skip to content
Merged
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <base64-encoded-private-key>
```

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: <base64-encoded-private-key>
sshPrivateKeyPassword: <base64-encoded-passphrase>
known_hosts: <base64-encoded-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`.
Expand Down Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
81 changes: 73 additions & 8 deletions internal/git/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
}

Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)}
}
118 changes: 118 additions & 0 deletions internal/git/manager_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading