diff --git a/AGENTS.md b/AGENTS.md index 7aa02cd..871e789 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,9 @@ Read these files to understand the project setup, conventions, and development w - `README.md` - user-facing usage, API reference table, installation - `CONTRIBUTING.md` - development setup, workflow, coding conventions +- `docs/architecture.md` - system architecture, reconciliation flow, component interactions +- `docs/release.md` - release process, branching model, versioning - `docs/development/` - developer guides (e.g. Gitea integration, test patterns) +- `docs/plans/` - design documents capturing rationale and tradeoffs for past decisions After implementing a feature or making significant changes, check whether these docs need updating. The API reference table in README.md must stay in sync with `api/v1alpha1/function_types.go`. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f125a9..30756e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,12 @@ If you find a bug or have a feature request, please [open an issue](https://gith All CI checks must pass before a pull request can be merged. +## Further Reading + +- [Architecture Overview](docs/architecture.md) — system components, reconciliation flow, CRD lifecycle +- [Release Process](docs/release.md) — branching model, versioning, automated tag management +- [Gitea Integration](docs/development/gitea-integration.md) — e2e test infrastructure details + ## Development ### Prerequisites diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..afd40f5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,114 @@ +# Architecture Overview + +## What This Operator Does + +func-operator is a Kubernetes operator that monitors serverless functions deployed with the [Knative `func` CLI](https://github.com/knative/func). Its primary job is **middleware lifecycle management**: it detects when a function's middleware is outdated and automatically rebuilds the function using the latest version. + +The operator does **not** handle initial deployment. Functions must first be deployed with `func deploy`. The operator then takes over ongoing maintenance. + +## Components + +- **FunctionReconciler** (`internal/controller/`) — The central controller. Watches `Function` custom resources and reconciles them. Also watches the `func-operator-controller-config` ConfigMap to re-reconcile functions when the operator-wide `autoUpdateMiddleware` default changes. Runs up to 10 concurrent reconciliations. + +- **FuncCliManager** (`internal/funccli/`) — Wraps the Knative `func` CLI binary. Periodically checks GitHub for new releases and downloads them (with SHA256 checksum verification and atomic install). Runs `func deploy`, `func describe`, and `func version` as subprocesses. The download logic (`DownloadAndInstall`) is shared with e2e test utilities via `internal/funccli/download.go`. + +- **GitManager** (`internal/git/`) — Clones function source repositories with authentication support: HTTP/HTTPS (token or basic auth) and SSH (private key with optional passphrase and known_hosts). Uses go-git for pure-Go shallow cloning (single-branch, depth 1). + +- **StatusTracker** (`internal/controller/status_tracker.go`) — Buffers status changes during reconciliation and persists them in a single API call at the end via `Flush()`. Supports mid-reconcile flushes for long-running operations (e.g., before a deployment starts) so users see progress. + +## CRD: Function + +Defined in `api/v1alpha1/function_types.go`. A `Function` resource represents a deployed serverless function that the operator should monitor. + +**Spec** (user-provided): +- `repository.url` — Git repository containing the function source +- `repository.branch` — Branch to track (optional, defaults to repo default) +- `repository.path` — Subdirectory within the repo (for monorepos) +- `repository.authSecretRef` — Secret for private repo authentication +- `registry.authSecretRef` — Secret for container registry authentication +- `autoUpdateMiddleware` — Override operator default (optional) + +**Status** (operator-managed): +- `git` — Resolved branch, observed commit, last check time +- `deployment` — Current image, build time, deployer, runtime +- `middleware` — Current/available versions, auto-update config, rebuild state +- `service` — URL and readiness of the underlying Knative Service +- `conditions` — Standard Kubernetes conditions (see below) +- `history` — Last 20 reconciliation events + +## Reconciliation Flow + +```mermaid +flowchart TD + start["Reconcile()"] --> get["Get Function CR"] + get -->|Not found| ignore["Exit — already deleted"] + get -->|Found| tracker["Create StatusTracker"] + tracker --> prepare + + subgraph prepare ["prepareSource()"] + fetchsecret["Fetch auth secret
(if configured)"] + fetchsecret --> clone["Clone git repository"] + clone --> readmeta["Read func.yaml metadata"] + readmeta --> sourceready["Mark SourceReady"] + end + + prepare --> describe["func describe
(check if deployed)"] + describe -->|Not deployed| notdeployed["Mark NotDeployed
Return"] + describe -->|Deployed| checkMW["Check middleware version"] + + checkMW -->|Up to date| uptodate["Update status
Mark MiddlewareUpToDate"] + checkMW -->|Outdated| autocheck{"Auto-update
enabled?"} + + autocheck -->|No| skip["Mark as intentionally
not updated"] + autocheck -->|Yes| flush["Flush status
(mid-reconcile)"] + flush --> deploy["func deploy --remote
(rebuild via Tekton)"] + deploy --> updatedesc["func describe
(get new state)"] + updatedesc --> updated["Update status
Mark MiddlewareUpToDate"] + + uptodate --> cleanup["Remove func annotations"] + skip --> cleanup + updated --> cleanup + cleanup --> flushfinal["StatusTracker.Flush()
Calculate Ready, persist"] +``` + +### Conditions + +The operator maintains five conditions on each Function: + +| Condition | Meaning | +|-----------|---------| +| `Ready` | Overall health (calculated from all other conditions) | +| `SourceReady` | Git clone and metadata read succeeded | +| `Deployed` | Function exists in the cluster | +| `MiddlewareUpToDate` | Function uses the latest middleware version | +| `ServiceReady` | Underlying Knative Service is ready | + +`Ready` is automatically calculated: it is `True` only when all other conditions are `True`. + +## Deployment and Build + +The operator uses **Tekton Pipelines** for building function images. During `func deploy --remote`, the func CLI creates a Tekton PipelineRun in the function's namespace. The operator sets up the necessary RBAC (Role + RoleBinding) for the pipeline to run. + +Supported builders: `pack` (Cloud Native Buildpacks) and `s2i` (Source-to-Image). + +Supported deployers: `knative` (Knative Serving) and `raw` (plain Kubernetes Deployment), with experimental `keda` support (HTTP-based autoscaling). + +## Configuration + +The operator reads its default config from a ConfigMap named `func-operator-controller-config` in the operator's namespace: + +| Key | Description | Default | +|-----|-------------|---------| +| `autoUpdateMiddleware` | Whether to auto-rebuild functions with outdated middleware | `true` | + +Per-function overrides are possible via `spec.autoUpdateMiddleware` on the Function CR. + +## E2E Test Infrastructure + +E2E tests run in a KinD cluster with: +- **Gitea** (in-cluster Git server) for complete test isolation +- **Local container registry** with self-signed TLS +- **Tekton Pipelines** for build execution +- **Knative Serving** (or raw/keda deployer depending on test matrix) + +See [Gitea Integration](development/gitea-integration.md) for details on the test infrastructure. \ No newline at end of file diff --git a/docs/plans/2026-03-13-gitea-e2e-integration-design.md b/docs/plans/2026-03-13-gitea-e2e-integration-design.md new file mode 100644 index 0000000..d4027ec --- /dev/null +++ b/docs/plans/2026-03-13-gitea-e2e-integration-design.md @@ -0,0 +1,375 @@ +# Gitea E2E Integration Design + +**Date:** 2026-03-13 +**Status:** Approved + +## Overview + +This design integrates Gitea into our e2e test infrastructure to eliminate dependency on GitHub for test function repositories. Tests will use an in-cluster Gitea instance for all git operations, providing complete isolation and enabling testing of private repositories and various authentication methods. + +## Goals + +- Remove GitHub dependency from e2e tests +- Enable per-test isolation with dedicated git repositories +- Support testing private repositories with token authentication +- Provide infrastructure for future SSH authentication testing +- Keep test code focused on operator logic, not git infrastructure + +## Architecture + +The integration consists of three layers: + +### 1. Infrastructure Layer +Cluster setup with Gitea installation and configuration + +### 2. Utilities Layer +Go client and helpers for managing Gitea resources + +### 3. Test Integration Layer +Updates to existing e2e tests to use Gitea instead of GitHub + +## Infrastructure Layer + +### Gitea Installation (`hack/create-kind-cluster.sh`) + +Update the existing but disabled `install_gitea()` function: + +```bash +function install_gitea() { + header_text "Installing Gitea" + + helm repo add gitea-charts https://dl.gitea.com/charts/ + helm install gitea gitea-charts/gitea --namespace gitea --create-namespace \ + --set service.http.type=NodePort \ + --set service.http.nodePort=30000 \ + --set service.ssh.type=NodePort \ + --set service.ssh.nodePort=30022 \ + --set gitea.admin.username=giteaadmin \ + --set gitea.admin.password=giteapass \ + --set gitea.admin.email=admin@gitea.local \ + --set persistence.enabled=false + + header_text "Waiting for Gitea to become ready" + kubectl wait deployment --all --timeout=-1s --for=condition=Available --namespace gitea + + # Get Gitea endpoint for tests + GITEA_NODE_IP=$(docker inspect kind-control-plane --format '{{.NetworkSettings.Networks.kind.IPAddress}}') + + # Create ConfigMap with Gitea endpoint info + kubectl apply -f - <:30000` +- **Why this works:** + - Kind node IP is reachable from host (via Docker networking) + - Same IP is reachable from pods inside cluster (node IPs) + - No port-forwarding or DNS tricks needed + - Single consistent address for tests and operator + +## Utilities Layer + +### Dependencies + +Add to `go.mod`: +``` +code.gitea.io/sdk/gitea v0.x.x +``` + +### GiteaClient Structure (`test/utils/gitea.go`) + +```go +package utils + +import ( + "fmt" + "os/exec" + + "code.gitea.io/sdk/gitea" + "k8s.io/apimachinery/pkg/util/rand" +) + +type GiteaClient struct { + client *gitea.Client + baseURL string // http://172.18.0.2:30000 + adminUser string + adminPass string +} + +// NewGiteaClient discovers Gitea endpoint from ConfigMap and creates client +func NewGiteaClient() (*GiteaClient, error) { + // Read gitea-endpoint ConfigMap from kube-public namespace + // Extract baseURL from data.http + // Create Gitea SDK client with admin credentials + // Return initialized GiteaClient +} + +// User management +func (g *GiteaClient) CreateUser(username, password, email string) error + +func (g *GiteaClient) CreateRandomUser() (username, password, email string, err error) { + username = "user-" + rand.String(8) + password = "pass-" + rand.String(8) + email = username + "@test.local" + + err = g.CreateUser(username, password, email) + return username, password, email, err +} + +func (g *GiteaClient) DeleteUser(username string) error + +// Repository management +func (g *GiteaClient) CreateRepo(owner, name string, private bool) (string, error) { + // Returns repository URL: http://172.18.0.2:30000/owner/name.git +} + +func (g *GiteaClient) CreateRandomRepo(owner string, private bool) (name, url string, err error) { + name = "repo-" + rand.String(8) + url, err = g.CreateRepo(owner, name, private) + return name, url, err +} + +func (g *GiteaClient) DeleteRepo(owner, name string) error + +// Token authentication +func (g *GiteaClient) CreateAccessToken(username, password, tokenName string) (string, error) +``` + +### Helper Functions (`test/e2e/gitea_helpers.go`) + +```go +package e2e + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/functions-dev/func-operator/test/utils" + "k8s.io/apimachinery/pkg/util/rand" +) + +// buildAuthURL embeds credentials into git URL +func buildAuthURL(repoURL, username, password string) string { + return strings.Replace(repoURL, "http://", + fmt.Sprintf("http://%s:%s@", username, password), 1) +} + +// InitializeRepoWithFunction clones an empty Gitea repo, initializes a function, and pushes it +func InitializeRepoWithFunction(repoURL, username, password, language string) (repoDir string, err error) { + repoDir = fmt.Sprintf("%s/func-test-%s", os.TempDir(), rand.String(10)) + + // Build authenticated URL + authURL := buildAuthURL(repoURL, username, password) + + // Clone empty repo + cmd := exec.Command("git", "clone", authURL, repoDir) + if _, err = utils.Run(cmd); err != nil { + return "", err + } + + // Initialize function + cmd = exec.Command("func", "init", "-l", language) + cmd.Dir = repoDir + if _, err = utils.Run(cmd); err != nil { + return "", err + } + + // Commit and push + exec.Command("git", "-C", repoDir, "add", ".").Run() + exec.Command("git", "-C", repoDir, "commit", "-m", "Initial function").Run() + exec.Command("git", "-C", repoDir, "push").Run() + + return repoDir, nil +} + +// CommitAndPushFuncYaml commits and pushes func.yaml changes after deployment +func CommitAndPushFuncYaml(repoDir string) error { + exec.Command("git", "-C", repoDir, "add", "func.yaml").Run() + exec.Command("git", "-C", repoDir, "commit", "-m", "Update func.yaml after deploy").Run() + return exec.Command("git", "-C", repoDir, "push").Run() +} +``` + +## Test Integration Layer + +### Suite Setup (`test/e2e/e2e_suite_test.go`) + +Add package-level variable: +```go +var ( + k8sClient client.Client + ctx context.Context + + registry string + registryInsecure bool + + giteaClient *utils.GiteaClient // NEW +) +``` + +Update BeforeSuite: +```go +var _ = BeforeSuite(func() { + ctx = context.Background() + + // ... existing k8sClient setup ... + + // ... existing registry setup ... + + // Initialize Gitea client + var err error + giteaClient, err = utils.NewGiteaClient() + Expect(err).NotTo(HaveOccurred()) + Expect(giteaClient).NotTo(BeNil()) +}) +``` + +### Test Pattern + +Standard pattern for tests using Gitea: + +```go +var _ = Describe("Test Suite", func() { + var ( + username string + password string + repoName string + repoURL string + repoDir string + ) + + BeforeEach(func() { + var err error + + // Create random user + username, password, _, err = giteaClient.CreateRandomUser() + Expect(err).NotTo(HaveOccurred()) + + // Create random repo + repoName, repoURL, err = giteaClient.CreateRandomRepo(username, false) + Expect(err).NotTo(HaveOccurred()) + + // Initialize with function code + repoDir, err = InitializeRepoWithFunction(repoURL, username, password, "go") + Expect(err).NotTo(HaveOccurred()) + + // Deploy function (example) + cmd := exec.Command("func", "deploy", + "--path", repoDir, + "--registry", registry, + "--registry-insecure", strconv.FormatBool(registryInsecure)) + out, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + // Commit func.yaml changes + CommitAndPushFuncYaml(repoDir) + }) + + AfterEach(func() { + // Cleanup + os.RemoveAll(repoDir) + giteaClient.DeleteRepo(username, repoName) + giteaClient.DeleteUser(username) + }) + + It("should do something", func() { + // Test uses repoURL in Function spec + function := &functionsdevv1alpha1.Function{ + Spec: functionsdevv1alpha1.FunctionSpec{ + Source: functionsdevv1alpha1.FunctionSpecSource{ + RepositoryURL: repoURL, + }, + // ... + }, + } + // ... + }) +}) +``` + +### Updates to Existing Tests + +**`test/e2e/func_deploy_test.go`:** +- Replace `git clone https://github.com/creydr/func-go-hello-world` with Gitea workflow +- Replace hardcoded `RepositoryURL: "https://github.com/..."` with `repoURL` +- Add cleanup for Gitea resources in AfterEach + +**`test/e2e/bundle_test.go`:** +- Update `CreateFunctionAndWaitForReady()` to accept `repoURL` parameter +- Update `createNamespaceAndDeployFunction()` to use Gitea workflow +- Replace GitHub URLs throughout + +## Implementation Steps + +1. **Update cluster setup script** + - Enable `install_gitea()` function + - Add call to main flow + - Test cluster creation end-to-end + +2. **Create utilities layer** + - Add Gitea SDK dependency + - Implement `test/utils/gitea.go` + - Implement `test/e2e/gitea_helpers.go` + +3. **Update test suite** + - Add giteaClient initialization in BeforeSuite + - Test basic connectivity and operations + +4. **Migrate tests** + - Update `func_deploy_test.go` + - Update `bundle_test.go` + - Run e2e tests to verify + +5. **Documentation** + - Update README with Gitea information + - Document how to access Gitea UI during development + +## Benefits + +1. **No External Dependencies:** Tests run without internet or GitHub access +2. **Complete Isolation:** Each test gets fresh user and repository +3. **Faster Tests:** No network latency to external services +4. **Reproducible:** Identical Gitea state on every test run +5. **Extended Testing:** Ready for private repos, SSH, multiple auth methods +6. **Self-Contained:** All test infrastructure in the cluster + +## Future Enhancements + +Once basic integration is complete, we can add: + +1. **SSH Key Authentication** + - `GenerateSSHKeyPair()` method + - `AddSSHKey()` method + - SSH URL helper functions + +2. **Advanced Git Scenarios** + - Multiple branches + - Tags and releases + - Submodules + - Webhooks + +3. **Performance Testing** + - Test with large repositories + - Concurrent git operations + +4. **Failure Scenarios** + - Invalid credentials + - Repository permissions + - Network failures \ No newline at end of file diff --git a/docs/plans/2026-04-01-middleware-update-e2e-test-design.md b/docs/plans/2026-04-01-middleware-update-e2e-test-design.md new file mode 100644 index 0000000..8c548c0 --- /dev/null +++ b/docs/plans/2026-04-01-middleware-update-e2e-test-design.md @@ -0,0 +1,103 @@ +# E2E Test for Middleware Update Design + +**Date:** 2026-04-01 + +## Overview + +Add e2e test to verify that the operator updates middleware when it detects a function was deployed with an old version. The operator automatically detects version mismatch by comparing against the currently installed func CLI version on the operator pod. + +## Implementation Plan + +### 1. Create func CLI Wrapper Functions + +**File:** `test/utils/func.go` + +**Two functions:** + +1. **RunFunc(args ...string) (string, error)** + - Executes func CLI with current/latest version + - Wraps `exec.Command("func", args...)` + - Uses existing `Run()` helper for consistent output handling + - Returns combined stdout/stderr and error + +2. **RunFuncWithVersion(version string, args ...string) (string, error)** + - Executes func CLI with a specific version + - Downloads and caches the specified version if not already cached + - Same return signature for consistency + +**Version caching:** +- Cache location: `/bin/func-cli/` +- Directory structure: `bin/func-cli/v1.20.0/func`, etc. +- Each version in its own subdirectory +- Already covered by `bin/` gitignore pattern + +**Download logic:** +1. Get project directory using `GetProjectDir()` +2. Check if `/bin/func-cli//func` exists +3. If exists → use it +4. If not exists: + - Create `bin/func-cli//` directory + - Download binary directly from: `https://github.com/knative/func/releases/download//func__` + - Write to `bin/func-cli//func` + - Make executable (`chmod +x`) +5. Execute with provided args + +**Platform detection:** Use `runtime.GOOS` and `runtime.GOARCH` + +**Error handling:** +- Clear errors for missing versions: "failed to download func v1.20.0: HTTP 404" +- Clear errors for download/write failures +- Preserve func CLI error output + +### 2. Refactor Existing Tests (Separate Commit) + +Replace all `exec.Command("func", ...)` calls with `utils.RunFunc(...)`: +- `func_deploy_test.go` line 66-71 (deploy command) +- `func_deploy_test.go` line 77 (delete command) +- `git.go` line 42 in `InitializeRepoWithFunction` (init command) + +### 3. Add New Middleware Update Test (Separate Commit) + +**File:** `test/e2e/func_middleware_update_test.go` + +**Test flow:** + +1. **Setup:** + - Create repository provider resources (user, repo) + - Initialize repo with function code using `InitializeRepoWithFunction` + - Create test namespace + +2. **Deploy with old func CLI:** + - Use `RunFuncWithVersion("v1.20.0", "deploy", "--namespace", ns, "--path", repoDir, ...)` + - Creates initial deployment with old middleware + - Commit func.yaml changes to git + +3. **Create Function CR:** + - Create Function resource pointing to repository + - Triggers operator reconciliation + +4. **Verify update:** + - Eventually verify Function CR becomes Ready + - This confirms operator successfully updated the middleware + +**Old version:** Use `v1.20.0` as the hardcoded "old" version + +## Dependencies + +**New imports needed:** +- `io` +- `net/http` +- `runtime` (for OS/arch detection) + +**No external dependencies required.** + +## Testing + +- Download logic tested automatically on first run of middleware update test +- Subsequent runs use cached binary +- Manual cleanup for testing: `rm -rf bin/func-cli/` + +## Commit Strategy + +1. **First commit:** Create wrapper functions and refactor existing tests to use RunFunc +2. **Second commit:** Add new middleware update e2e test diff --git a/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md b/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md new file mode 100644 index 0000000..9a514e8 --- /dev/null +++ b/docs/plans/2026-04-09-private-git-repo-e2e-test-design.md @@ -0,0 +1,171 @@ +# Private Git Repository Authentication E2E Test Design + +## Overview + +Add end-to-end tests to verify that the func-operator correctly handles private Git repositories using authentication credentials specified via `.spec.repository.authSecretRef`. + +## Background + +The func-operator supports private Git repositories by allowing users to specify a reference to an authentication secret in the Function CR. The controller reads this secret and uses the credentials to clone the repository. The implementation in `internal/git/manager.go` supports two authentication methods: + +1. **Token-based**: Secret contains `token` key +2. **Username/Password**: Secret contains `username` and `password` keys + +Currently, there are no e2e tests verifying this functionality works end-to-end. + +## Test Structure + +All tests will be added to `test/e2e/func_deploy_test.go` to keep deployment-related tests consolidated. + +### Test Contexts + +Four new test contexts will be added: + +1. **"with a private repository using token authentication - success"** + - Verifies Function becomes Ready when authSecretRef is provided + +2. **"with a private repository using token authentication - failure"** + - Verifies Function fails with authentication error when authSecretRef is missing + +3. **"with a private repository using username/password authentication - success"** + - Verifies Function becomes Ready when authSecretRef is provided + +4. **"with a private repository using username/password authentication - failure"** + - Verifies Function fails with authentication error when authSecretRef is missing + +## Implementation Details + +### Test Flow (Token Authentication - Success) + +**BeforeEach:** +1. Create random Gitea user using `repoProvider.CreateRandomUser()` +2. Create private repository using `repoProvider.CreateRandomRepo(username, true)` +3. Create access token using `repoProvider.CreateAccessToken(username, password, "e2e-token")` +4. Initialize repository with function code using `utils.InitializeRepoWithFunction()` +5. Deploy function using func CLI (authenticates with username/password for git operations) +6. Commit func.yaml changes +7. Create test namespace + +**Test Body:** +1. Create Kubernetes Secret with token data using k8sClient: + ```go + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "git-auth-", + Namespace: functionNamespace, + }, + Data: map[string][]byte{ + "token": []byte(token), + }, + } + ``` +2. Create Function CR with `spec.repository.authSecretRef.name` pointing to the secret +3. Verify Function becomes Ready: + ```go + funcBecomeReady := func(g Gomega) { + fn := &functionsdevv1alpha1.Function{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: functionName, Namespace: functionNamespace}, fn) + g.Expect(err).NotTo(HaveOccurred()) + + for _, cond := range fn.Status.Conditions { + if cond.Type == functionsdevv1alpha1.TypeReady { + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + return + } + } + g.Expect(false).To(BeTrue(), "Ready condition not found") + } + Eventually(funcBecomeReady, 6*time.Minute).Should(Succeed()) + ``` + +**Cleanup:** +- Delete Function CR +- Delete Secret +- Cleanup namespace, repo, user (via DeferCleanup) + +### Test Flow (Token Authentication - Failure) + +**BeforeEach:** Same as success case + +**Test Body:** +1. Do NOT create authentication secret +2. Create Function CR WITHOUT `spec.repository.authSecretRef` +3. Verify Function does NOT become Ready and has authentication error: + ```go + funcFailsWithAuthError := func(g Gomega) { + fn := &functionsdevv1alpha1.Function{} + err := k8sClient.Get(ctx, types.NamespacedName{Name: functionName, Namespace: functionNamespace}, fn) + g.Expect(err).NotTo(HaveOccurred()) + + // Check it's NOT Ready + for _, cond := range fn.Status.Conditions { + if cond.Type == functionsdevv1alpha1.TypeReady { + g.Expect(cond.Status).NotTo(Equal(metav1.ConditionTrue)) + } + // Check for SourceReady condition with auth error + if cond.Type == functionsdevv1alpha1.TypeSourceReady { + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Message).To(Or( + ContainSubstring("authentication"), + ContainSubstring("Authentication"), + ContainSubstring("401"), + ContainSubstring("Unauthorized"), + )) + return + } + } + g.Expect(false).To(BeTrue(), "SourceReady condition not found") + } + Eventually(funcFailsWithAuthError, 2*time.Minute).Should(Succeed()) + ``` + +**Cleanup:** Same as success case + +### Test Flow (Username/Password Authentication) + +Same as token authentication, but: +- No token creation +- Secret contains username and password: + ```go + secret := &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "git-auth-", + Namespace: functionNamespace, + }, + Data: map[string][]byte{ + "username": []byte(username), + "password": []byte(password), + }, + } + ``` + +## Verification Criteria + +### Positive Tests +- Function CR is created successfully +- Function status reaches Ready condition with status=True +- No error conditions present +- Timeout: 6 minutes (matching existing deployed function tests) + +### Negative Tests +- Function CR is created successfully +- Function status does NOT reach Ready condition +- SourceReady condition exists with status=False +- SourceReady condition message contains authentication-related error keywords +- Timeout: 2 minutes (should fail faster) + +## Testing Infrastructure Requirements + +All required infrastructure already exists: +- Gitea client with user/repo/token management in `test/utils/gitea.go` +- Repository initialization helpers in `test/utils/git.go` +- k8sClient available in test suite +- Test namespace management + +## Future Enhancements + +Potential follow-up work (not in scope for this implementation): +- Refactor duplicated `funcBecomeReady` closures into reusable test helpers +- Test SSH-based authentication (if/when implemented) +- Test secret updates/rotation +- Test invalid credentials handling \ No newline at end of file diff --git a/docs/plans/2026-04-10-status-tracker-design.md b/docs/plans/2026-04-10-status-tracker-design.md new file mode 100644 index 0000000..d00db07 --- /dev/null +++ b/docs/plans/2026-04-10-status-tracker-design.md @@ -0,0 +1,186 @@ +# Status Tracker for Immediate Status Updates + +**Date:** 2026-04-10 +**Status:** Approved + +## Problem + +The Function controller currently only updates status at the end of reconciliation (lines 98-103 in `function_controller.go`). This causes delayed status updates during long operations like redeployment, leaving users without visibility into what's happening. + +## Solution + +Introduce a `StatusTracker` that: +1. Captures the original function state at reconciliation start +2. Lives in the context for easy access +3. Automatically flushes status updates when changes occur +4. Reduces unnecessary API calls by comparing before updating + +## Design + +### StatusTracker Structure + +```go +type StatusTracker struct { + client client.Client + original *v1alpha1.Function +} + +func NewStatusTracker(client client.Client, function *v1alpha1.Function) *StatusTracker { + return &StatusTracker{ + client: client, + original: function.DeepCopy(), // snapshot current state + } +} + +func (t *StatusTracker) Flush(ctx context.Context, current *v1alpha1.Function) error { + // Always calculate ready condition before comparing + current.CalculateReadyCondition() + + // Compare and update if changed + if !equality.Semantic.DeepEqual(t.original.Status, current.Status) { + if err := t.client.Status().Update(ctx, current); err != nil { + return err + } + // Update our snapshot to the new state + t.original = current.DeepCopy() + } + return nil +} +``` + +### Context Integration + +```go +type statusTrackerKey struct{} + +// WithStatusTracker adds a status tracker to the context +func WithStatusTracker(ctx context.Context, tracker *StatusTracker) context.Context { + return context.WithValue(ctx, statusTrackerKey{}, tracker) +} + +// GetStatusTracker retrieves the tracker from context +func GetStatusTracker(ctx context.Context) *StatusTracker { + tracker, ok := ctx.Value(statusTrackerKey{}).(*StatusTracker) + if !ok { + return nil + } + return tracker +} + +// FlushStatus is a convenience helper that gets tracker from context and flushes +func FlushStatus(ctx context.Context, function *v1alpha1.Function) error { + tracker := GetStatusTracker(ctx) + if tracker == nil { + return nil // gracefully handle missing tracker + } + return tracker.Flush(ctx, function) +} +``` + +### Reconciler Integration + +```go +func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx).WithValues("function", fmt.Sprintf("%s/%s", req.Namespace, req.Name)) + ctx = log.IntoContext(ctx, logger) + + original := &v1alpha1.Function{} + err := r.Get(ctx, req.NamespacedName, original) + if err != nil { + if apierrors.IsNotFound(err) { + logger.Info("function resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + logger.Error(err, "Failed to get function") + return ctrl.Result{}, err + } + + function := original.DeepCopy() + + // Create tracker and add to context + tracker := NewStatusTracker(r.Client, function) + ctx = WithStatusTracker(ctx, tracker) + + reconcileErr := r.reconcile(ctx, function) + + // Final flush at the end (handles ready condition calculation) + if err := tracker.Flush(ctx, function); err != nil { + logger.Error(err, "Unable to update Function status") + return ctrl.Result{}, err + } + + if reconcileErr != nil { + logger.Error(reconcileErr, "Failed to reconcile Function") + return ctrl.Result{}, reconcileErr + } + + logger.Info("Reconciliation complete") + return ctrl.Result{}, nil +} +``` + +### Checkpoint Placement Strategy + +**Flush on phase completions, not on errors** (final flush catches errors anyway): + +1. **After source preparation** - Users see git clone succeeded, commit info available +2. **Before long operations** - Users see "about to deploy" before redeployment starts +3. **Final flush** - Always happens in `Reconcile()` regardless of success/failure + +```go +func (r *FunctionReconciler) reconcile(ctx context.Context, function *v1alpha1.Function) error { + function.InitializeConditions() + + repo, metadata, err := r.prepareSource(ctx, function) + if err != nil { + return fmt.Errorf("prepare source failed: %w", err) + } + defer repo.Cleanup() + + r.updateFunctionStatusGit(function, repo) + FlushStatus(ctx, function) // Checkpoint 1: Source ready + + if err := r.ensureDeployment(ctx, function, repo, metadata); err != nil { + return fmt.Errorf("deploying function failed: %w", err) + } + + r.updateFunctionStatus(function, metadata) + return nil +} + +func (r *FunctionReconciler) handleMiddlewareUpdate(ctx context.Context, function *v1alpha1.Function, repo *git.Repository, metadata *funcfn.Function) error { + isOnLatestMiddleware, err := r.isMiddlewareLatest(ctx, metadata, function.Namespace) + if err != nil { + function.MarkMiddlewareNotUpToDate("MiddlewareCheckFailed", "Failed to check middleware version: %s", err.Error()) + return fmt.Errorf("failed to check if function is using latest middleware: %w", err) + } + + if !isOnLatestMiddleware { + logger.Info("Function is not on latest middleware. Will redeploy") + function.MarkMiddlewareNotUpToDate("MiddlewareOutdated", "Middleware is outdated, redeploying") + FlushStatus(ctx, function) // Checkpoint 2: Before long deploy operation + + if err := r.deploy(ctx, function, repo); err != nil { + function.MarkDeployNotReady("DeployFailed", "Redeployment failed: %s", err.Error()) + return fmt.Errorf("failed to redeploy function: %w", err) + } + } + + function.MarkMiddlewareUpToDate() + function.MarkDeployReady() + return nil +} +``` + +## Benefits + +1. **Immediate visibility** - Users see status updates as phases complete +2. **No signature changes** - Context pattern avoids passing tracker through every function +3. **Automatic deduplication** - Tracker prevents unnecessary API calls when status unchanged +4. **Error handling** - Final flush ensures errors are captured even without intermediate flushes +5. **Encapsulation** - All comparison/update logic lives in one place + +## Future Enhancements + +- Add retry logic with `retry.RetryOnConflict` for handling conflicts during intermediate updates +- Add metrics for tracking flush frequency and update patterns \ No newline at end of file diff --git a/docs/plans/2026-04-23-reconcile-state-design.md b/docs/plans/2026-04-23-reconcile-state-design.md new file mode 100644 index 0000000..7a8bcc0 --- /dev/null +++ b/docs/plans/2026-04-23-reconcile-state-design.md @@ -0,0 +1,211 @@ +# Reconcile State Design + +Centralize all status and condition updates in the function controller by introducing a `reconcileState` struct and a single `syncStatus` function. + +## Problem + +Conditions (`Mark*` calls) and status field writes are scattered across 6 methods in 2 files. It is hard to reason about what state the Function ends up in after reconciliation. The `reconcileError` type wraps condition info into errors, coupling error handling with status logic. + +## Design + +Inspired by the Kubernetes Job controller's `syncJobCtx` pattern: a state struct accumulates data during reconciliation, and one function translates it into status writes and conditions. + +### Structs + +```go +type reconcileState struct { + source *sourceState + deployment *deploymentState + middleware *middlewareState +} + +type sourceState struct { + name string + branch string + commit string + failReason string + failMessage string +} + +type deploymentState struct { + deployed bool + deployer string + runtime string + image string + ready string // "true"/"false"/unknown from describe + failReason string + failMessage string +} + +type middlewareState struct { + updateEnabled bool + updateSource string + isLatest bool + currentVersion string + latestVersion string + pendingRebuild bool + redeployed bool + lastRebuild metav1.Time + failReason string + failMessage string +} +``` + +Each phase struct is nil until that phase runs. Nil means conditions stay Unknown (from `InitializeConditions`). + +### `syncStatus` + +Single function that translates state into `Mark*` calls and `function.Status` field writes. It is the only place in the controller that calls `Mark*` or writes to `function.Status`. + +Each section follows the same shape: +1. Guard: if phase struct is nil, return +2. Check failure: if `failReason` is set, set the failure condition, return +3. Set success condition +4. Write status fields + +```go +func syncStatus(function *v1alpha1.Function, state *reconcileState) { + // --- Source --- + if state.source == nil { + return + } + if state.source.failReason != "" { + function.MarkSourceNotReady(state.source.failReason, "%s", state.source.failMessage) + return + } + function.MarkSourceReady() + function.Status.Name = state.source.name + function.Status.Git.ResolvedBranch = state.source.branch + function.Status.Git.ObservedCommit = state.source.commit + function.Status.Git.LastChecked = metav1.Now() + + // --- Deployment --- + if state.deployment == nil { + return + } + if state.deployment.failReason != "" { + function.MarkDeployNotReady(state.deployment.failReason, "%s", state.deployment.failMessage) + return + } + if !state.deployment.deployed { + function.MarkDeployNotReady("NotDeployed", "Function not deployed yet") + return + } + function.MarkDeployReady() + function.Status.Deployment.Deployer = state.deployment.deployer + function.Status.Deployment.Runtime = state.deployment.runtime + function.Status.Deployment.Image = state.deployment.image + markServiceStatus(state.deployment.ready, function) + + // --- Middleware --- + if state.middleware == nil { + return + } + if state.middleware.failReason != "" { + function.MarkMiddlewareNotUpToDate(state.middleware.failReason, "%s", state.middleware.failMessage) + return + } + switch { + case state.middleware.isLatest: + function.MarkMiddlewareUpToDate() + case !state.middleware.updateEnabled: + function.Status.Middleware.Available = ptr.To(state.middleware.latestVersion) + function.MarkMiddlewareNotUpToDateIntentionally("SkipMiddlewareUpdate", + "Skipping middleware update as update is disabled (source: %s)", state.middleware.updateSource) + case state.middleware.redeployed: + function.Status.Middleware.Available = nil + function.Status.Middleware.LastRebuild = state.middleware.lastRebuild + function.Status.Deployment.ImageBuilt = state.middleware.lastRebuild + function.MarkMiddlewareUpToDate() + function.MarkDeployReady() + } + function.Status.Middleware.AutoUpdate.Enabled = state.middleware.updateEnabled + function.Status.Middleware.AutoUpdate.Source = state.middleware.updateSource + function.Status.Middleware.Current = state.middleware.currentVersion + function.Status.Middleware.PendingRebuild = state.middleware.pendingRebuild +} +``` + +### `Reconcile` and `reconcile` flow + +`Reconcile()` calls `syncStatus` once before flushing, so `reconcile()` never thinks about status: + +```go +func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // ... get function, deep copy ... + state := &reconcileState{} + statusTracker := NewStatusTracker(r.Client, function) + ctx = WithStatusTracker(ctx, statusTracker) + + reconcileErr := r.reconcile(ctx, function, state) + syncStatus(function, state) + + if err := statusTracker.Flush(ctx, function); err != nil { + return ctrl.Result{}, err + } + // ... +} + +func (r *FunctionReconciler) reconcile(ctx context.Context, function *v1alpha1.Function, state *reconcileState) error { + function.InitializeConditions() + + repo, err := r.prepareSource(ctx, function, state) + if err != nil { + return fmt.Errorf("prepare source failed: %w", err) + } + defer repo.Cleanup() + + applyLastDeployedAnnotation(ctx, function, state) + + if err := r.ensureDeployment(ctx, function, repo, state); err != nil { + return fmt.Errorf("deploying function failed: %w", err) + } + return nil +} +``` + +### Mid-reconcile flush + +For long-running deploys, `redeployMiddleware` updates the state struct and explicitly flushes: + +```go +state.middleware.pendingRebuild = true +state.middleware.failReason = "MiddlewareOutdated" +state.middleware.failMessage = fmt.Sprintf("Middleware is outdated (%s available), redeploying...", state.middleware.latestVersion) + +syncStatus(function, state) +FlushStatus(ctx, function) + +// ... deploy happens ... + +state.middleware.pendingRebuild = false +state.middleware.redeployed = true +state.middleware.failReason = "" +state.middleware.failMessage = "" +``` + +### Helpers + +Helpers populate the state struct and return plain errors. They never call `Mark*` or write to `function.Status`. + +`reconcileError` is removed. + +### Files + +| File | Change | +|---|---| +| `function_status.go` | New: `reconcileState`, `sourceState`, `deploymentState` structs, `syncStatus` function | +| `function_controller.go` | `Reconcile()` calls `syncStatus`, `reconcile()` passes state, `reconcileError` removed, `prepareSource`/`ensureDeployment` rewritten | +| `function_middleware.go` | All methods take state, no `Mark*` calls, `middlewareState` extended | +| `function_deploy.go` | Unchanged | +| `function_rbac.go` | Unchanged | +| `status_tracker.go` | Unchanged | +| `function_lifecycle.go` | Unchanged | +| `function_controller_test.go` | Behavior-compatible, no structural changes expected | + +### Tradeoffs + +- **Pro**: All condition logic in one place, easy to read the full status story +- **Pro**: Helpers are pure data gatherers, easy to test +- **Pro**: Mid-reconcile flushes use the same mechanism +- **Con**: State struct and `syncStatus` must be kept in sync with helpers — two places to update when adding new status fields \ No newline at end of file diff --git a/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md b/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md new file mode 100644 index 0000000..48c408d --- /dev/null +++ b/docs/plans/2026-04-24-refactor-handleMiddlewareUpdate-design.md @@ -0,0 +1,78 @@ +# Refactor `handleMiddlewareUpdate` + +## Problem + +`handleMiddlewareUpdate` (lines 251-332 in `function_controller.go`) interleaves three concerns: + +1. **Querying state** - two `Describe` calls, `isMiddlewareUpdateEnabled`, `isMiddlewareLatest`, `GetLatestMiddlewareVersion` +2. **Decision logic** - should we redeploy or not? +3. **Status bookkeeping** - ~15 status field assignments scattered throughout + +This makes the method hard to follow. There's also a redundant `Describe` call — the second one (line 321) re-fetches data that only changes after a redeploy. + +## Design + +### Sum-type pattern for middleware check results + +Introduce a sealed interface with two concrete types, separating "what we observed" from "what we should do": + +```go +type middlewareCheck interface { + middlewareCheck() // sealed marker +} + +type middlewareUpToDate struct { + currentImage string + serviceReady string + currentVersion string + autoUpdate autoUpdateStatus +} + +type middlewareOutdated struct { + currentImage string + serviceReady string + currentVersion string + availableVersion string + autoUpdate autoUpdateStatus +} + +type autoUpdateStatus struct { + enabled bool + source string +} +``` + +- `middlewareUpToDate`: deployed version == latest. No `availableVersion` field needed. +- `middlewareOutdated`: deployed version != latest. Carries `availableVersion` and `autoUpdate.enabled` to distinguish "needs update" from "update disabled." + +### `checkMiddlewareState` method + +Consolidates all querying into one method. Uses a single `Describe` call (instead of two) since we already have the current version from `Describe` and only need `GetLatestMiddlewareVersion` to compare: + +```go +func (r *FunctionReconciler) checkMiddlewareState( + ctx context.Context, + function *v1alpha1.Function, + metadata *funcfn.Function, +) (middlewareCheck, error) +``` + +### Refactored `handleMiddlewareUpdate` + +Becomes a thin orchestrator with a type switch: + +- `middlewareUpToDate`: set status fields, mark middleware up-to-date +- `middlewareOutdated` + `autoUpdate.enabled == false`: set status fields, mark intentionally not up-to-date +- `middlewareOutdated` + `autoUpdate.enabled == true`: flush status, deploy, re-describe, update status, record history event + +The second `Describe` call only happens inside the deploy branch (where it's actually needed). + +### Cleanup + +`isMiddlewareLatest` is no longer needed — the version comparison happens inside `checkMiddlewareState` using data from the single `Describe` call. + +## Decisions + +- **Keep status field duplication across switch cases** — each case is self-contained and readable top-to-bottom +- **`autoUpdate` lives as a field on `middlewareOutdated`** rather than being a third type — the two outdated cases share the same data +- **All types are unexported** — this is controller-internal \ No newline at end of file diff --git a/docs/plans/2026-04-29-ssh-url-support-design.md b/docs/plans/2026-04-29-ssh-url-support-design.md new file mode 100644 index 0000000..e7fa30a --- /dev/null +++ b/docs/plans/2026-04-29-ssh-url-support-design.md @@ -0,0 +1,71 @@ +# SSH URL Support for Function CR + +## Overview + +Add support for SSH repository URLs in the Function CR, allowing users to specify repositories using SCP-style (`git@host:owner/repo.git`) or `ssh://` URLs. Currently, only HTTP/HTTPS URLs are supported. + +## Design Decisions + +- **URL-based auth detection**: The URL scheme determines the transport (SSH vs HTTP). No need for a secret to exist for public repos cloned over SSH. +- **Optional host key verification**: The auth secret can include a `known_hosts` field. If absent, host key checking is skipped (`InsecureIgnoreHostKey`). +- **go-git's `transport.ParseURL()`**: Replaces `net/url.Parse()` for URL parsing. Handles SCP-style URLs natively by normalizing them to `ssh://` scheme. +- **Random temp directories**: Simplify temp dir naming to fully random (`repo-*`) instead of deriving from the URL. + +## Changes + +### 1. Git Manager (`internal/git/manager.go`) + +**`CloneRepository`:** +- Replace `neturl.Parse(repoUrl)` with `transport.ParseURL(repoUrl)` from `github.com/go-git/go-git/v6/plumbing/transport`. +- Simplify temp dir pattern to `os.MkdirTemp(cloneBaseDir, "repo-*")`. +- Pass the parsed URL to `getClientOptions` so it can branch on scheme. + +**`getClientOptions`:** +- Signature changes to accept the parsed `*url.URL`. +- For `ssh` scheme: + - If secret has `sshPrivateKey`: build `ssh.PublicKeys` via `ssh.NewPublicKeys("git", pemBytes, password)`. + - If secret has `known_hosts`: set `HostKeyCallback` from the known_hosts data. + - If no `known_hosts`: use `gossh.InsecureIgnoreHostKey()`. + - If no secret: return `WithSSHAuth` using `InsecureIgnoreHostKey` only (public repo). + - Wrap in `client.WithSSHAuth()`. +- For `http`/`https` scheme: existing token/username-password logic unchanged. + +**Auth secret fields (SSH):** +- `sshPrivateKey` (required for private repos) — PEM-encoded private key +- `sshPrivateKeyPassword` (optional) — passphrase for encrypted keys +- `known_hosts` (optional) — known_hosts file content for host key verification + +### 2. Unit Tests (`internal/git/manager_test.go`) + +New test file covering `getClientOptions`: +- Empty secret + SSH URL → nil auth with InsecureIgnoreHostKey +- `sshPrivateKey` + SSH URL → WithSSHAuth with PublicKeys +- `sshPrivateKey` + `known_hosts` + SSH URL → SSH auth with known_hosts callback +- `sshPrivateKey` + `sshPrivateKeyPassword` + SSH URL → decrypts key +- Existing HTTP cases still work (token, username/password, empty) +- URL parsing: SCP-style, `ssh://`, `http://`, `https://` all resolve correctly + +### 3. E2E Test Utilities + +**`test/utils/gitea.go`:** +- `GetSSHEndpoint()` — reads `ssh` key from `gitea-endpoint` ConfigMap +- `CreateSSHKey(username, password, title, publicKey string)` — registers SSH public key via Gitea SDK `CreatePublicKey()` +- `SSHRepoURL(owner, repo string)` — builds SCP-style URL from SSH endpoint + +**`test/utils/git.go`:** +- `WithSSHKey(privateKeyPath string)` option — configures `InitializeRepoWithFunction` to clone/push via SSH using `GIT_SSH_COMMAND` with the provided private key + +### 4. E2E Tests (`test/e2e/func_deploy_test.go`) + +Three new test cases under a new `Context("with an SSH repository URL", ...)`: + +1. **Public repo with SSH URL** — Create public repo, push via HTTP, create Function CR with SSH URL, verify function becomes ready. +2. **Private repo with SSH key auth** — Generate SSH keypair, register public key in Gitea, create Secret with `sshPrivateKey`, create Function CR with SSH URL + authSecretRef, verify function becomes ready. +3. **Private repo without auth secret** — Create private repo, create Function CR with SSH URL but no authSecretRef, verify function fails with auth error. + +### 5. README Updates + +Add SSH examples to the existing "Git Authentication" section: +- Secret format with `sshPrivateKey`, `sshPrivateKeyPassword`, `known_hosts` +- Function CR example with SCP-style SSH URL + authSecretRef +- Function CR example with SSH URL for public repo (no secret) \ No newline at end of file diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..8164cd4 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,65 @@ +# Release Process + +## Versioning + +The project uses [semantic versioning](https://semver.org/) with a `v` prefix: `v{MAJOR}.{MINOR}.{PATCH}` (e.g. `v0.3.0`). + +## Branch Model + +- **`main`** — Active development. All PRs target `main`. +- **`release-{MAJOR}.{MINOR}`** — Maintenance branches for each minor version (e.g. `release-0.3`). Created when a minor version is ready to ship. + +## Creating a New Minor Release + +1. Create a `release-{MAJOR}.{MINOR}` branch from `main` and push it +2. The `manage-release-tags` workflow detects the new branch and automatically creates the `v{MAJOR}.{MINOR}.0` tag +3. The `release` workflow triggers, builds multi-arch images, and creates the GitHub release + +## Patch Releases + +Patch releases are **automated**: + +1. Cherry-pick fixes to the `release-{MAJOR}.{MINOR}` branch (manually, or via `/cherry-pick release-X.Y` comment on a merged PR) +2. Every Tuesday at 08:00 UTC, the `manage-release-tags` workflow checks all release branches for new commits since the last tag +3. If new commits exist, it increments the patch version (e.g. `v0.3.0` → `v0.3.1`) and creates a new tag +4. The `release` workflow builds and publishes the patch release + +To trigger a patch release immediately (without waiting for Tuesday), manually run the `manage-release-tags` workflow via GitHub Actions. + +## What the Release Workflow Produces + +For each tag, the `release` workflow: + +1. **Builds multi-arch container images** (`linux/amd64`, `linux/arm64`) and pushes to GHCR: + - `ghcr.io/functions-dev/func-operator:{VERSION}` + - `ghcr.io/functions-dev/func-operator:{MAJOR}.{MINOR}` + - `ghcr.io/functions-dev/func-operator:{MAJOR}` + - `ghcr.io/functions-dev/func-operator:latest` (only for the newest version) + +2. **Generates an install manifest** (`func-operator.yaml`) with CRDs, RBAC, and deployment (using image digest for reproducibility) + +3. **Builds an OLM bundle image** for catalog distribution: + - `ghcr.io/functions-dev/func-operator-bundle:{VERSION}` + +4. **Creates a GitHub release** with auto-generated release notes and the install manifest attached + +## Nightly Builds + +The `nightly-build` workflow runs daily at 00:00 UTC and pushes images from `main` tagged as `:main`. + +## Cherry-Pick Automation + +The `cherry-pick` workflow supports automated backporting: +- Comment `/cherry-pick release-X.Y` on a merged PR +- A new PR with the cherry-picked changes is automatically created against the target branch + +## Emergency Release + +For urgent fixes outside the Tuesday schedule: +1. Merge the fix to the release branch +2. Either manually trigger `manage-release-tags` via GitHub Actions, or push a tag directly: + ```bash + git tag v{MAJOR}.{MINOR}.{PATCH} + git push origin v{MAJOR}.{MINOR}.{PATCH} + ``` +3. The `release` workflow triggers automatically on tag push \ No newline at end of file