Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# EDP Codebase Operator — AI Agent Instructions

This is a Kubernetes operator built with [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime), written in Go. It manages git-repository lifecycle resources (Codebase, CodebaseBranch, GitServer, etc.) on Kubernetes/OpenShift clusters as part of the EPAM Delivery Platform (EDP).

## Build & Test Commands

```bash
make build # Build operator binary (CGO_ENABLED=0)
make test # Run tests with coverage (runs fmt + vet + envtest first)
make lint # Run golangci-lint v2.8.0
make lint-fix # Auto-fix linting issues
make fmt # go fmt
make vet # go vet
make manifests # Regenerate CRDs, RBAC, webhooks (controller-gen)
make generate # Regenerate DeepCopy methods
make mocks # Regenerate mocks via mockery v3.6.2
make api-docs # Regenerate docs/api.md
make validate-docs # Verify docs are current
```

After modifying types in `api/v1/`, always run `make manifests && make generate`.
After modifying interfaces in `pkg/`, run `make mocks` to regenerate mocks.

## Architecture

```
api/v1/ → CRD type definitions (Codebase, CodebaseBranch, GitServer, etc.)
api/v1alpha1/ → Alpha CRD types (Template)
controllers/ → One subdirectory per controller (reconciler + handler chain)
pkg/ → Shared business logic (git, gitlab, gerrit, util, webhook, …)
cmd/main.go → Operator entrypoint: manager init + controller registration
config/ → Kustomize manifests (CRDs, RBAC, webhooks)
deploy-templates/ → Helm chart
mocks/ → Auto-generated mocks (do not edit manually)
```

**Module:** `github.com/epam/edp-codebase-operator/v2`

## Controller Pattern

Each controller in `controllers/<name>/` follows this structure:

- **Reconciler struct** holds `client.Client`, `scheme`, `log logr.Logger`, and a `chainGetter` for the handler chain.
- **`SetupWithManager(mgr)`** registers custom predicates (e.g., `codebasepredicate.NewPause`, `predicate.Funcs`) then calls `ctrl.NewControllerManagedBy(mgr).For(...).Complete(r)`.
- **`Reconcile(ctx, req)`** pattern:
1. Fetch CR; ignore `IsNotFound`
2. Apply object modifications (defaults via `objectmodifier`)
3. Set/handle finalizers
4. Execute handler chain
5. Update `.status` via `r.client.Status().Update(ctx, cr)`
6. Return `reconcile.Result{RequeueAfter: util.GetTimeout(failureCount, timeout)}` on error

**Finalizer name convention:** `"<resource>.operator.finalizer.name"` (e.g., `"codebase.operator.finalizer.name"`)

**Status constants:** `util.StatusFinished`, `util.StatusFailed` (from `pkg/util/`)

## Testing Conventions

- Test files: `*_test.go` alongside the file under test.
- Always call `t.Parallel()` at the top of each test function and sub-test.
- Use **table-driven tests** (`tests := []struct{ ... }{ ... }`).
- Assertions: `github.com/stretchr/testify/require` (fail-fast preferred over `assert`).
- Mocking: `github.com/stretchr/testify/mock` with `mock.On(...).Return(...)`. Generated mocks live in `mocks/` subdirectories (e.g., `controllers/codebase/mocks/`).
- Fake Kubernetes clients: `fake.NewClientBuilder().WithScheme(scheme).WithObjects(...).Build()`.
- Always add CRD types to the scheme in tests: `require.NoError(t, codebaseApi.AddToScheme(scheme))`.

## Code Generation Markers

Place these on type definitions in `api/v1/`:

```go
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:rbac:groups=v2.edp.epam.com,resources=codebases,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:validation:Enum=create;clone;import
// +kubebuilder:default:="tekton"
// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=.status.status
```

Long lines from markers are excluded from the `lll` linter in `api/`.

## Common Patterns

**Logging** — always use context-derived logger:
```go
log := ctrl.LoggerFrom(ctx)
log.Info("Reconciling Codebase", "name", req.Name)
log.Error(err, "Failed to fetch CR")
```

**Error wrapping:**
```go
return fmt.Errorf("failed to fetch Codebase: %w", err)
```

**Kubernetes error checks:**
```go
k8sErrors.IsNotFound(err)
k8sErrors.IsAlreadyExists(err)
```

**Utility helpers** (`pkg/util/`):
- `util.GetStringP(val)` — string to `*string`
- `util.GetWorkDir(name, namespace)` — respects `WORKING_DIR` env var
- `util.GetAssetsDir()` — respects `ASSETS_DIR` env var
- `util.GetTimeout(failureCount, base)` — exponential backoff duration

## Key CRD Types

| Type | Purpose |
|------|---------|
| `Codebase` | Git repository resource; strategies: `create`, `clone`, `import` |
| `CodebaseBranch` | Branch within a Codebase |
| `GitServer` | VCS connection config (GitHub, GitLab, Gerrit, Bitbucket) |
| `JiraServer` | JIRA integration settings |
| `CodebaseImageStream` | Container image stream tracking |
| `CDStageDeploy` | CD pipeline stage deployment record |

## Git Commit Format

```
[EPMDEDP-XXXXX: ]<type>: <subject>
```

Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
Example: `EPMDEDP-12345: feat: add reconciliation for QuickLink`

See [CONTRIBUTING.md](CONTRIBUTING.md) and [.chglog/config.yml](.chglog/config.yml).

## Linting

Config: `.golangci.yaml` (golangci-lint v2.8.0).
Key linters: `errcheck`, `govet`, `staticcheck`, `gocyclo`, `revive`, `ginkgolinter`, `wsl_v5`.
Formatters: `gofmt`, `goimports` (imports must be grouped).
Generated files (`zz_generated.*`, `mocks/`) are excluded from most rules.

## Further References

- [API docs](docs/api.md)
- [Helm chart](deploy-templates/README.md)
- [Local development guide](https://epam.github.io/edp-install/developer-guide/local-development/)
2 changes: 2 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
k8sruntime "k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/certwatcher"
Expand Down Expand Up @@ -209,6 +210,7 @@ func main() {
utilruntime.Must(routeApi.AddToScheme(scheme))
utilruntime.Must(tektonTriggersApi.AddToScheme(scheme))
utilruntime.Must(tektonpipelineApi.AddToScheme(scheme))
utilruntime.Must(gatewayv1.Install(scheme))

ns, err := util.GetWatchNamespace()
if err != nil {
Expand Down
9 changes: 9 additions & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ rules:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- create
- get
- list
- watch
- apiGroups:
- route.openshift.io
resources:
Expand Down
31 changes: 28 additions & 3 deletions controllers/codebase/service/chain/put_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
networkingV1 "k8s.io/api/networking/v1"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
"github.com/epam/edp-codebase-operator/v2/controllers/gitserver"
Expand Down Expand Up @@ -160,11 +161,15 @@ func (s *PutWebHook) getWebHookUrl(ctx context.Context, gitServer *codebaseApi.G
return gitServer.Spec.WebhookUrl, nil
}

if platform.IsK8S() {
return s.getWebhookIngressUrl(ctx, gitServer.Name, gitServer.Namespace)
if platform.IsOpenshift() {
return s.getWebhookRouteUrl(ctx, gitServer.Name, gitServer.Namespace)
}

return s.getWebhookRouteUrl(ctx, gitServer.Name, gitServer.Namespace)
if platform.IsEnvoyGateway() {
return s.getWebhookHTTPRouteUrl(ctx, gitServer.Name, gitServer.Namespace)
}

return s.getWebhookIngressUrl(ctx, gitServer.Name, gitServer.Namespace)
}

func (*PutWebHook) processCodebaseError(codebase *codebaseApi.Codebase, err error) error {
Expand Down Expand Up @@ -208,3 +213,23 @@ func (s *PutWebHook) getWebhookRouteUrl(ctx context.Context, gitServerName, name

return util.GetHostWithProtocol(route.Status.Ingress[0].Host), nil
}

func (s *PutWebHook) getWebhookHTTPRouteUrl(ctx context.Context, gitServerName, namespace string) (string, error) {
httpRoute := &gatewayv1.HTTPRoute{}
if err := s.client.Get(
ctx,
client.ObjectKey{
Name: gitserver.GenerateIngressName(gitServerName),
Namespace: namespace,
},
httpRoute,
); err != nil {
return "", fmt.Errorf("failed to get webhook httproute: %w", err)
}

if len(httpRoute.Spec.Hostnames) == 0 {
return "", fmt.Errorf("httproute %s doesn't have hostnames", httpRoute.Name)
}

return util.GetHostWithProtocol(string(httpRoute.Spec.Hostnames[0])), nil
}
124 changes: 124 additions & 0 deletions controllers/codebase/service/chain/put_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

codebaseApi "github.com/epam/edp-codebase-operator/v2/api/v1"
"github.com/epam/edp-codebase-operator/v2/controllers/gitserver"
Expand All @@ -42,6 +43,7 @@ func TestPutWebHook_ServeRequest(t *testing.T) {
require.NoError(t, coreV1.AddToScheme(schema))
require.NoError(t, networkingV1.AddToScheme(schema))
require.NoError(t, routeApi.AddToScheme(schema))
require.NoError(t, gatewayv1.Install(schema))

gitURL := "test-owner/test-repo"
fakeUrlRegexp := regexp.MustCompile(`.*`)
Expand Down Expand Up @@ -787,6 +789,111 @@ func TestPutWebHook_ServeRequest(t *testing.T) {
wantErr: require.Error,
errContains: "failed to get webhook ingress",
},
{
name: "success gitlab with envoy gateway",
codebase: &codebaseApi.Codebase{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-codebase",
ResourceVersion: "1",
},
Spec: codebaseApi.CodebaseSpec{
GitServer: "test-git-server",
GitUrlPath: gitURL,
CiTool: util.CITekton,
},
},
prepare: func(t *testing.T) {
t.Setenv(platform.TypeEnv, platform.K8S)
t.Setenv(platform.GatewayTypeEnv, platform.GatewayTypeEnvoy)
},
k8sObjects: []client.Object{
&codebaseApi.Codebase{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-codebase",
ResourceVersion: "1",
},
},
&codebaseApi.GitServer{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-git-server",
},
Spec: codebaseApi.GitServerSpec{
GitHost: "fake.gitlab.com",
GitUser: "git",
HttpsPort: 443,
NameSshKeySecret: "test-secret",
GitProvider: codebaseApi.GitProviderGitlab,
},
},
&coreV1.Secret{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-secret",
},
Data: map[string][]byte{
util.GitServerSecretTokenField: []byte("test-token"),
util.GitServerSecretWebhookSecretField: []byte("test-webhook-secret"),
},
},
fakeHTTPRoute(gitserver.GenerateIngressName("test-git-server"), "fake.gitlab.com"),
},
responder: func(t *testing.T) {
POSTResponder := httpmock.NewStringResponder(http.StatusOK, "")
httpmock.RegisterRegexpResponder(http.MethodPost, fakeUrlRegexp, POSTResponder)

GETResponder := httpmock.NewStringResponder(http.StatusOK, "")
httpmock.RegisterRegexpResponder(http.MethodGet, fakeUrlRegexp, GETResponder)
},
wantErr: require.NoError,
},
{
name: "failed to get webhook httproute",
codebase: &codebaseApi.Codebase{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-codebase",
},
Spec: codebaseApi.CodebaseSpec{
GitServer: "test-git-server",
GitUrlPath: gitURL,
CiTool: util.CITekton,
},
},
prepare: func(t *testing.T) {
t.Setenv(platform.TypeEnv, platform.K8S)
t.Setenv(platform.GatewayTypeEnv, platform.GatewayTypeEnvoy)
},
k8sObjects: []client.Object{
&codebaseApi.GitServer{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-git-server",
},
Spec: codebaseApi.GitServerSpec{
GitHost: "fake.gitlab.com",
GitUser: "git",
HttpsPort: 443,
NameSshKeySecret: "test-secret",
GitProvider: codebaseApi.GitProviderGitlab,
},
},
&coreV1.Secret{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: "test-secret",
},
Data: map[string][]byte{
util.GitServerSecretTokenField: []byte("test-token"),
},
},
},
responder: func(t *testing.T) {},
wantErr: require.Error,
errContains: "failed to get webhook httproute",
},
{
name: "failed to get secret - no required field",
codebase: &codebaseApi.Codebase{
Expand Down Expand Up @@ -926,3 +1033,20 @@ func fakeIngress(name, host string) *networkingV1.Ingress {
},
}
}

func fakeHTTPRoute(name, host string) *gatewayv1.HTTPRoute {
return &gatewayv1.HTTPRoute{
ObjectMeta: metaV1.ObjectMeta{
Namespace: namespace,
Name: name,
Labels: map[string]string{
"app.edp.epam.com/gitServer": "test-git-server",
},
},
Spec: gatewayv1.HTTPRouteSpec{
Hostnames: []gatewayv1.Hostname{
gatewayv1.Hostname(host),
},
},
}
}
Loading
Loading