tatara-operator M1 (Project + Repository reconcilers + ingest Job) Implementation Plan¶
For agentic workers: execute tasks top to bottom. Each task is a complete TDD cycle: write the failing test exactly as given, run it and confirm the expected FAIL, write the minimal implementation exactly as given, run it and confirm PASS, then commit. Do not skip the run steps. Do not "improve" beyond the given code. Copy code verbatim; paths and commands are exact. Run every command from the repo root
~/Documents/tatara-operatorunless stated otherwise. Work in a worktree offmainper hard rule 10; never build or deploy from the worktree.
Goal¶
Make tatara-operator ingest repositories into tatara-memory. Deliver two reconcilers and a Job builder:
ProjectReconcilervalidates the Project's SCM secret hastoken+webhookSecret, rendersstatus.webhookURL, and sets conditions.internal/ingest/job.gobuilds the*batchv1.Jobthat clones a repo with the SCM token and runstatara-ingest.RepositoryReconcilerspawns that Job per the re-ingest trigger contract (full whenlastIngestedCommit==""; incremental via--sincewhen thetatara.dev/reingest-requestedannotation is newer thanstatus.lastIngestTime), guards concurrency viastatus.jobName, and on Job success reads the resolved HEAD SHA from a result ConfigMap to setstatus.lastIngestedCommit.- Both reconcilers are wired into
cmd/manager/main.go. - Reconciles and ingest Job durations are recorded in the M0 metrics.
This closes production-readiness gaps 1 and 2 (nothing populates the graph; ingest needs clone + Go toolchain).
Architecture¶
CRDs are the source of truth (M0 created the tatara.dev/v1alpha1 types Project, Repository, Task, Subtask with deepcopy). M1 adds behavior:
ProjectReconcileris pure validation + status rendering. No child objects.RepositoryReconcilerowns a single in-flight ingestJobper Repository. The Job has two phases in one Pod: an init container clones the repo into anemptyDirusing the Project SCM token; the main container runstatara-ingestagainstMEMORY_BASE_URLwith an OIDC bearer minted from the operator's client-credentials, and as its final step resolves the cloned HEAD SHA and writes it into a per-Repository result ConfigMap (<repo>-ingest-result, keysha). On Job success the reconciler reads that ConfigMap, setsstatus.lastIngestedCommit,status.lastIngestTime,status.phase=Ingested, and clearsstatus.jobName.
Result-SHA design decision (resolving the spec's open question): the ingest container, after clone, writes git rev-parse HEAD into the result ConfigMap via the in-cluster Kubernetes API. This is the simplest correct, deterministic mechanism: the reconciler never parses pod logs and never trusts a sentinel. The Job runs under a dedicated ServiceAccount (tatara-ingest, created by the M6 chart) whose Role grants get,create,update,patch on ConfigMaps in the tatara namespace, scoped by RBAC. The reconciler pre-creates an empty <repo>-ingest-result ConfigMap (owner-ref Repository) before launching the Job so the Job only needs patch/update, and the reconciler always has a known object to read. MEMORY.md records the RBAC requirement so M6 wires the ServiceAccount + Role.
Trigger/concurrency contract (from the pin set, implemented verbatim here):
spawn ingest Job when:
status.lastIngestedCommit == "" -> full (no --since)
OR annotation tatara.dev/reingest-requested (RFC3339)
is newer than status.lastIngestTime -> incremental (--since <sha>)
status.jobName != "" and that Job is still active -> requeue, do not launch
Job is owner-referenced to the Repository.
on Job Complete: read <repo>-ingest-result.data["sha"],
set status.lastIngestedCommit, status.lastIngestTime=now,
status.phase=Ingested, status.jobName="".
on Job Failed: status.phase=Failed, status.jobName="", set Failed condition.
Tech Stack¶
- Go
1.25.x(pinned ingo.modby M0), kubebuilder / controller-runtime. sigs.k8s.io/controller-runtimereconcilers;sigs.k8s.io/controller-runtime/pkg/envtestfor reconciler tests.k8s.io/api/batch/v1,k8s.io/api/core/v1,k8s.io/apimachinery.github.com/prometheus/client_golang(M0internal/obs).apimachinery/pkg/api/meta+metav1.Conditionforstatus.conditions.- Tests: stdlib
testing, table-driven witht.Run, errors wrapped%w, mirroringtatara-memory/internal.
Assumptions stated up front: - M0 has created api/v1alpha1 types with the spec field names exactly, the generated deepcopy, the scheme registration in api/v1alpha1/groupversion_info.go, internal/config/config.go exposing the pin-set config keys, and internal/obs providing NewLogger + PromRegistry. If any M1 task fails to compile because an M0 symbol is missing, stop and reconcile with the M0 plan rather than inventing the symbol. - Condition types used: Ready (Project), Ingested / Failed (Repository). - M1 owns the M0-defined metrics operator_reconcile_total{kind,result} and operator_ingest_job_duration_seconds; if M0 has not yet registered them, Task 1 adds them to internal/obs/metrics.go (the first task that needs them) and the rest of M1 reuses them.
Task 1: Operator metrics in internal/obs¶
Adds the two M1 metrics as package-level collectors registered on the obs registry, with thin helpers the reconcilers call. If M0 already added identical collectors, skip the duplicate registration and keep only the helpers (the test still asserts the helpers exist and emit).
Files: - Create: internal/obs/operator_metrics.go - Test: internal/obs/operator_metrics_test.go
- Write the failing test (FULL code):
internal/obs/operator_metrics_test.go
package obs
import (
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)
func TestReconcileTotal(t *testing.T) {
reg := prometheus.NewRegistry()
m := NewOperatorMetrics(reg)
m.ReconcileResult("Project", "success")
m.ReconcileResult("Project", "success")
m.ReconcileResult("Repository", "error")
got := testutil.ToFloat64(m.reconcileTotal.WithLabelValues("Project", "success"))
if got != 2 {
t.Fatalf("Project/success = %v, want 2", got)
}
got = testutil.ToFloat64(m.reconcileTotal.WithLabelValues("Repository", "error"))
if got != 1 {
t.Fatalf("Repository/error = %v, want 1", got)
}
}
func TestIngestJobDuration(t *testing.T) {
reg := prometheus.NewRegistry()
m := NewOperatorMetrics(reg)
m.ObserveIngestJobDuration(12.5)
mfs, err := reg.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
var found bool
for _, mf := range mfs {
if mf.GetName() == "operator_ingest_job_duration_seconds" {
found = true
if got := mf.GetMetric()[0].GetHistogram().GetSampleCount(); got != 1 {
t.Fatalf("sample count = %d, want 1", got)
}
}
}
if !found {
t.Fatal("operator_ingest_job_duration_seconds not registered")
}
}
func TestOperatorMetricsNamesStable(t *testing.T) {
reg := prometheus.NewRegistry()
_ = NewOperatorMetrics(reg)
mfs, _ := reg.Gather()
want := map[string]bool{
"operator_reconcile_total": false,
"operator_ingest_job_duration_seconds": false,
}
for _, mf := range mfs {
if _, ok := want[mf.GetName()]; ok {
want[mf.GetName()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("metric %q not registered", name)
}
}
_ = strings.TrimSpace
}
- Run and expect FAIL:
go test ./internal/obs/ -run 'TestReconcileTotal|TestIngestJobDuration|TestOperatorMetricsNamesStable' -v
Expected: build failure undefined: NewOperatorMetrics (and ObserveIngestJobDuration, ReconcileResult, reconcileTotal).
- Write the minimal implementation (FULL code):
internal/obs/operator_metrics.go
package obs
import "github.com/prometheus/client_golang/prometheus"
// OperatorMetrics holds the reconciler-facing Prometheus collectors for the
// tatara-operator. Construct one with NewOperatorMetrics and pass it to the
// reconcilers.
type OperatorMetrics struct {
reconcileTotal *prometheus.CounterVec
ingestJobDuration prometheus.Histogram
}
// NewOperatorMetrics registers the operator collectors on reg and returns the
// bundle. Names and labels are pinned by the shared-contracts pin set.
func NewOperatorMetrics(reg prometheus.Registerer) *OperatorMetrics {
m := &OperatorMetrics{
reconcileTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "operator_reconcile_total",
Help: "Total reconcile outcomes by kind and result.",
}, []string{"kind", "result"}),
ingestJobDuration: prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "operator_ingest_job_duration_seconds",
Help: "Wall-clock duration of completed ingest Jobs.",
Buckets: prometheus.ExponentialBuckets(5, 2, 8),
}),
}
reg.MustRegister(m.reconcileTotal, m.ingestJobDuration)
return m
}
// ReconcileResult increments operator_reconcile_total for the given kind and
// result ("success" or "error").
func (m *OperatorMetrics) ReconcileResult(kind, result string) {
m.reconcileTotal.WithLabelValues(kind, result).Inc()
}
// ObserveIngestJobDuration records the wall-clock seconds a completed ingest
// Job took.
func (m *OperatorMetrics) ObserveIngestJobDuration(seconds float64) {
m.ingestJobDuration.Observe(seconds)
}
- Run and expect PASS:
go test ./internal/obs/ -run 'TestReconcileTotal|TestIngestJobDuration|TestOperatorMetricsNamesStable' -v
Expected: ok github.com/szymonrychu/tatara-operator/internal/obs.
- Commit:
git add internal/obs/operator_metrics.go internal/obs/operator_metrics_test.go
git commit -m "feat: add operator reconcile + ingest-job-duration metrics"
Task 2: Ingest Job builder (internal/ingest/job.go)¶
Pure builder: given a Repository, its owning Project, an optional --since SHA, and the operator config, return the *batchv1.Job. No cluster access in this function. The Job is one Pod with an init container (git clone using the SCM token into an emptyDir) and a main container from INGESTER_IMAGE that runs tatara-ingest and then writes the resolved HEAD SHA into the result ConfigMap via the in-cluster API. Env keys match the ingester's UPPER_SNAKE-of-kebab convention (BASE_URL, OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_AUDIENCE).
Files: - Create: internal/ingest/job.go - Test: internal/ingest/job_test.go
- Write the failing test (FULL code):
internal/ingest/job_test.go
package ingest
import (
"strings"
"testing"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func testProject() *tataradevv1alpha1.Project {
p := &tataradevv1alpha1.Project{}
p.Name = "acme"
p.Namespace = "tatara"
p.Spec.ScmSecretRef = "acme-scm"
return p
}
func testRepository() *tataradevv1alpha1.Repository {
r := &tataradevv1alpha1.Repository{}
r.Name = "widgets"
r.Namespace = "tatara"
r.UID = "repo-uid-123"
r.Spec.ProjectRef = "acme"
r.Spec.URL = "https://github.com/acme/widgets.git"
r.Spec.DefaultBranch = "main"
return r
}
func testConfig() Config {
return Config{
IngesterImage: "registry.example/ingester:1.2.3",
MemoryBaseURL: "http://tatara-memory.tatara.svc:8080",
OIDCIssuer: "https://kc.example/realms/tatara",
OIDCClientID: "tatara-operator",
OIDCClientSecret: "s3cr3t",
OIDCAudience: "tatara-memory",
Namespace: "tatara",
}
}
func envValue(c corev1.Container, key string) string {
for _, e := range c.Env {
if e.Name == key {
return e.Value
}
}
return ""
}
func TestBuildJob_FullIngest(t *testing.T) {
job := BuildJob(testProject(), testRepository(), "", testConfig())
if job.Namespace != "tatara" {
t.Errorf("namespace = %q, want tatara", job.Namespace)
}
if !strings.HasPrefix(job.Name, "widgets-ingest-") {
t.Errorf("job name = %q, want prefix widgets-ingest-", job.Name)
}
if got := job.Spec.Template.Spec.RestartPolicy; got != corev1.RestartPolicyNever {
t.Errorf("restartPolicy = %q, want Never", got)
}
if job.Spec.BackoffLimit == nil || *job.Spec.BackoffLimit != 2 {
t.Errorf("backoffLimit = %v, want 2", job.Spec.BackoffLimit)
}
if len(job.OwnerReferences) != 1 {
t.Fatalf("ownerReferences = %d, want 1", len(job.OwnerReferences))
}
or := job.OwnerReferences[0]
if or.Kind != "Repository" || or.Name != "widgets" || string(or.UID) != "repo-uid-123" {
t.Errorf("ownerRef = %+v, want Repository/widgets/repo-uid-123", or)
}
if or.Controller == nil || !*or.Controller {
t.Error("ownerRef.Controller should be true")
}
initCs := job.Spec.Template.Spec.InitContainers
if len(initCs) != 1 {
t.Fatalf("init containers = %d, want 1", len(initCs))
}
clone := initCs[0]
cloneCmd := strings.Join(clone.Command, " ") + " " + strings.Join(clone.Args, " ")
if !strings.Contains(cloneCmd, "https://github.com/acme/widgets.git") {
t.Errorf("clone cmd missing url: %q", cloneCmd)
}
if !strings.Contains(cloneCmd, "--branch main") {
t.Errorf("clone cmd missing branch: %q", cloneCmd)
}
cs := job.Spec.Template.Spec.Containers
if len(cs) != 1 {
t.Fatalf("containers = %d, want 1", len(cs))
}
main := cs[0]
if main.Image != "registry.example/ingester:1.2.3" {
t.Errorf("image = %q, want registry.example/ingester:1.2.3", main.Image)
}
cmd := strings.Join(main.Command, " ") + " " + strings.Join(main.Args, " ")
if !strings.Contains(cmd, "tatara-ingest --repo-root /workspace/repo --repo-name widgets --base-url http://tatara-memory.tatara.svc:8080") {
t.Errorf("ingest cmd wrong: %q", cmd)
}
if strings.Contains(cmd, "--since") {
t.Errorf("full ingest must not pass --since: %q", cmd)
}
if !strings.Contains(cmd, "widgets-ingest-result") {
t.Errorf("ingest cmd must write result configmap: %q", cmd)
}
if v := envValue(main, "BASE_URL"); v != "http://tatara-memory.tatara.svc:8080" {
t.Errorf("BASE_URL = %q", v)
}
if v := envValue(main, "OIDC_ISSUER"); v != "https://kc.example/realms/tatara" {
t.Errorf("OIDC_ISSUER = %q", v)
}
if v := envValue(main, "OIDC_CLIENT_ID"); v != "tatara-operator" {
t.Errorf("OIDC_CLIENT_ID = %q", v)
}
if v := envValue(main, "OIDC_CLIENT_SECRET"); v != "s3cr3t" {
t.Errorf("OIDC_CLIENT_SECRET = %q", v)
}
if v := envValue(main, "OIDC_AUDIENCE"); v != "tatara-memory" {
t.Errorf("OIDC_AUDIENCE = %q", v)
}
}
func TestBuildJob_IncrementalIngest(t *testing.T) {
job := BuildJob(testProject(), testRepository(), "abc1234", testConfig())
main := job.Spec.Template.Spec.Containers[0]
cmd := strings.Join(main.Command, " ") + " " + strings.Join(main.Args, " ")
if !strings.Contains(cmd, "--since abc1234") {
t.Errorf("incremental ingest must pass --since abc1234: %q", cmd)
}
}
func TestBuildJob_SCMTokenFromSecret(t *testing.T) {
job := BuildJob(testProject(), testRepository(), "", testConfig())
clone := job.Spec.Template.Spec.InitContainers[0]
var ref *corev1.EnvVarSource
for _, e := range clone.Env {
if e.Name == "SCM_TOKEN" {
ref = e.ValueFrom
}
}
if ref == nil || ref.SecretKeyRef == nil {
t.Fatal("clone container must source SCM_TOKEN from a secret")
}
if ref.SecretKeyRef.Name != "acme-scm" || ref.SecretKeyRef.Key != "token" {
t.Errorf("SCM_TOKEN secretKeyRef = %s/%s, want acme-scm/token",
ref.SecretKeyRef.Name, ref.SecretKeyRef.Key)
}
}
func TestBuildJob_SharedWorkspaceVolume(t *testing.T) {
job := BuildJob(testProject(), testRepository(), "", testConfig())
ps := job.Spec.Template.Spec
var hasEmptyDir bool
for _, v := range ps.Volumes {
if v.Name == "workspace" && v.EmptyDir != nil {
hasEmptyDir = true
}
}
if !hasEmptyDir {
t.Error("pod must have an emptyDir volume named workspace")
}
mounted := func(c corev1.Container) bool {
for _, m := range c.VolumeMounts {
if m.Name == "workspace" && m.MountPath == "/workspace" {
return true
}
}
return false
}
if !mounted(ps.InitContainers[0]) {
t.Error("init container must mount workspace at /workspace")
}
if !mounted(ps.Containers[0]) {
t.Error("main container must mount workspace at /workspace")
}
_ = metav1.Now
_ = batchv1.Job{}
}
- Run and expect FAIL:
Expected: build failure undefined: BuildJob and undefined: Config.
- Write the minimal implementation (FULL code):
internal/ingest/job.go
// Package ingest builds the Kubernetes Job that clones a repository and runs
// tatara-ingest against tatara-memory.
package ingest
import (
"fmt"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/rand"
)
// Config is the subset of operator configuration the Job builder needs.
type Config struct {
IngesterImage string
MemoryBaseURL string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
OIDCAudience string
Namespace string
}
// ResultConfigMapName returns the name of the ConfigMap an ingest Job patches
// with the resolved HEAD SHA for the given Repository.
func ResultConfigMapName(repo *tataradevv1alpha1.Repository) string {
return repo.Name + "-ingest-result"
}
const (
workspaceVolume = "workspace"
workspaceMount = "/workspace"
repoDir = "/workspace/repo"
)
// BuildJob returns the *batchv1.Job that ingests repo for project. When since
// is non-empty the ingest is incremental (--since since); otherwise it is a
// full ingest. The Job is owner-referenced to repo. It clones with the
// Project SCM token in an init container into an emptyDir, runs tatara-ingest
// in the main container, then writes the cloned HEAD SHA into the repo's
// result ConfigMap via the in-cluster API.
func BuildJob(project *tataradevv1alpha1.Project, repo *tataradevv1alpha1.Repository, since string, cfg Config) *batchv1.Job {
backoff := int32(2)
ttl := int32(3600)
controller := true
cloneCmd := fmt.Sprintf(
"set -e; git clone --depth 1 --branch %s "+
"https://x-access-token:${SCM_TOKEN}@${REPO_HOST_PATH} %s",
repo.Spec.DefaultBranch, repoDir)
ingestArgs := fmt.Sprintf(
"tatara-ingest --repo-root %s --repo-name %s --base-url %s",
repoDir, repo.Name, cfg.MemoryBaseURL)
if since != "" {
ingestArgs += " --since " + since
}
// After a successful ingest, resolve HEAD and patch the result ConfigMap
// via the in-cluster API (the Job ServiceAccount has patch on it).
resultCM := ResultConfigMapName(repo)
mainScript := fmt.Sprintf(
"set -e; %s; "+
"SHA=$(git -C %s rev-parse HEAD); "+
"kubectl -n %s patch configmap %s --type merge "+
"-p \"{\\\"data\\\":{\\\"sha\\\":\\\"${SHA}\\\"}}\"",
ingestArgs, repoDir, cfg.Namespace, resultCM)
repoHostPath := repo.Spec.URL
repoHostPath = trimScheme(repoHostPath)
return &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: repo.Name + "-ingest-" + rand.String(5),
Namespace: cfg.Namespace,
Labels: map[string]string{
"app.kubernetes.io/name": "tatara-operator",
"app.kubernetes.io/component": "ingest",
"tatara.dev/managed-by": "tatara-operator",
"tatara.dev/repository": repo.Name,
},
OwnerReferences: []metav1.OwnerReference{{
APIVersion: tataradevv1alpha1.GroupVersion.String(),
Kind: "Repository",
Name: repo.Name,
UID: repo.UID,
Controller: &controller,
}},
},
Spec: batchv1.JobSpec{
BackoffLimit: &backoff,
TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app.kubernetes.io/name": "tatara-operator",
"app.kubernetes.io/component": "ingest",
"tatara.dev/managed-by": "tatara-operator",
"tatara.dev/repository": repo.Name,
},
},
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
ServiceAccountName: "tatara-ingest",
Volumes: []corev1.Volume{{
Name: workspaceVolume,
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
}},
InitContainers: []corev1.Container{{
Name: "clone",
Image: cfg.IngesterImage,
Command: []string{"/bin/sh", "-c"},
Args: []string{cloneCmd},
Env: []corev1.EnvVar{
{
Name: "SCM_TOKEN",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: project.Spec.ScmSecretRef},
Key: "token",
},
},
},
{Name: "REPO_HOST_PATH", Value: repoHostPath},
},
VolumeMounts: []corev1.VolumeMount{{Name: workspaceVolume, MountPath: workspaceMount}},
}},
Containers: []corev1.Container{{
Name: "ingest",
Image: cfg.IngesterImage,
Command: []string{"/bin/sh", "-c"},
Args: []string{mainScript},
Env: []corev1.EnvVar{
{Name: "BASE_URL", Value: cfg.MemoryBaseURL},
{Name: "OIDC_ISSUER", Value: cfg.OIDCIssuer},
{Name: "OIDC_CLIENT_ID", Value: cfg.OIDCClientID},
{Name: "OIDC_CLIENT_SECRET", Value: cfg.OIDCClientSecret},
{Name: "OIDC_AUDIENCE", Value: cfg.OIDCAudience},
},
VolumeMounts: []corev1.VolumeMount{{Name: workspaceVolume, MountPath: workspaceMount}},
}},
},
},
},
}
}
// trimScheme removes a leading https:// or http:// from a URL so it can be
// reassembled with embedded credentials.
func trimScheme(u string) string {
for _, p := range []string{"https://", "http://"} {
if len(u) >= len(p) && u[:len(p)] == p {
return u[len(p):]
}
}
return u
}
Note on the SCM secret value: the builder injects the token via env SCM_TOKEN (Secret key token); do not log it. The clone URL embeds x-access-token:${SCM_TOKEN} which works for both GitHub and GitLab HTTPS.
- Run and expect PASS:
Expected: ok github.com/szymonrychu/tatara-operator/internal/ingest with all five tests passing.
- Commit:
git add internal/ingest/job.go internal/ingest/job_test.go
git commit -m "feat: build ingest Job (clone + tatara-ingest + result configmap)"
Task 3: ProjectReconciler (envtest)¶
Validates scmSecretRef exists and has keys token + webhookSecret, renders status.webhookURL from EXTERNAL_WEBHOOK_BASE + project name, sets the Ready condition. This is the FIRST reconciler task, so it carries the FULL envtest suite setup (suite_test.go). Later reconciler tasks reuse the shared k8sClient/testEnv from this suite.
Files: - Create: internal/controller/suite_test.go - Create: internal/controller/project_controller.go - Test: internal/controller/project_controller_test.go
- Write the failing test (FULL code).
First the suite (shared by all reconciler tests in the package):
internal/controller/suite_test.go
package controller
import (
"context"
"path/filepath"
"testing"
"time"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
var (
testEnv *envtest.Environment
cfg *rest.Config
k8sClient client.Client
)
const (
timeout = 10 * time.Second
interval = 250 * time.Millisecond
testNS = "tatara"
)
// TestMain boots a single envtest control plane for the whole controller
// package, registers the tatara.dev scheme and core types, creates the test
// namespace, and tears the control plane down at the end.
func TestMain(m *testing.M) {
code := func() int {
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
var err error
cfg, err = testEnv.Start()
if err != nil {
panic("start envtest: " + err.Error())
}
defer func() { _ = testEnv.Stop() }()
if err := tataradevv1alpha1.AddToScheme(scheme.Scheme); err != nil {
panic("add scheme: " + err.Error())
}
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
if err != nil {
panic("new client: " + err.Error())
}
ns := &corev1.Namespace{}
ns.Name = testNS
if err := k8sClient.Create(context.Background(), ns); err != nil {
panic("create namespace: " + err.Error())
}
return m.Run()
}()
osExit(code)
}
Add a tiny indirection so the suite is testable without importing os at the top in a way that conflicts; create internal/controller/exit_test.go:
internal/controller/exit_test.go
package controller
import "os"
// osExit is a thin wrapper so TestMain has a single exit point.
var osExit = os.Exit
Now the Project test:
internal/controller/project_controller_test.go
package controller
import (
"context"
"testing"
"time"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
"github.com/szymonrychu/tatara-operator/internal/obs"
"github.com/prometheus/client_golang/prometheus"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
func newProjectReconciler() *ProjectReconciler {
return &ProjectReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
Metrics: obs.NewOperatorMetrics(prometheus.NewRegistry()),
ExternalWebhookBase: "https://tatara.example/operator/webhooks",
}
}
func reconcileProject(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
r := newProjectReconciler()
return r.Reconcile(logf.IntoContext(context.Background(), logf.Log), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: testNS, Name: name},
})
}
func mkSecret(t *testing.T, name string, data map[string][]byte) {
t.Helper()
s := &corev1.Secret{}
s.Name = name
s.Namespace = testNS
s.Data = data
if err := k8sClient.Create(context.Background(), s); err != nil {
t.Fatalf("create secret %s: %v", name, err)
}
}
func getProject(t *testing.T, name string) *tataradevv1alpha1.Project {
t.Helper()
p := &tataradevv1alpha1.Project{}
if err := k8sClient.Get(context.Background(), types.NamespacedName{Namespace: testNS, Name: name}, p); err != nil {
t.Fatalf("get project %s: %v", name, err)
}
return p
}
func waitProjectReady(t *testing.T, name string, want metav1.ConditionStatus) *tataradevv1alpha1.Project {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
p := getProject(t, name)
c := apierrors.FindStatusCondition(p.Status.Conditions, "Ready")
if c != nil && c.Status == want {
return p
}
time.Sleep(interval)
}
t.Fatalf("project %s Ready never reached %s", name, want)
return nil
}
func TestProjectReconcile_ValidSecret(t *testing.T) {
ctx := context.Background()
mkSecret(t, "valid-scm", map[string][]byte{
"token": []byte("ghp_x"),
"webhookSecret": []byte("hmac"),
})
p := &tataradevv1alpha1.Project{}
p.Name = "proj-valid"
p.Namespace = testNS
p.Spec.ScmSecretRef = "valid-scm"
if err := k8sClient.Create(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := reconcileProject(t, "proj-valid"); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := waitProjectReady(t, "proj-valid", metav1.ConditionTrue)
want := "https://tatara.example/operator/webhooks/proj-valid"
if got.Status.WebhookURL != want {
t.Errorf("webhookURL = %q, want %q", got.Status.WebhookURL, want)
}
}
func TestProjectReconcile_MissingSecret(t *testing.T) {
ctx := context.Background()
p := &tataradevv1alpha1.Project{}
p.Name = "proj-nosecret"
p.Namespace = testNS
p.Spec.ScmSecretRef = "does-not-exist"
if err := k8sClient.Create(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := reconcileProject(t, "proj-nosecret"); err != nil {
t.Fatalf("reconcile returned error, want nil (status carries failure): %v", err)
}
got := waitProjectReady(t, "proj-nosecret", metav1.ConditionFalse)
c := apierrors.FindStatusCondition(got.Status.Conditions, "Ready")
if c.Reason != "SecretNotFound" {
t.Errorf("reason = %q, want SecretNotFound", c.Reason)
}
}
func TestProjectReconcile_MissingKeys(t *testing.T) {
ctx := context.Background()
mkSecret(t, "partial-scm", map[string][]byte{"token": []byte("ghp_x")})
p := &tataradevv1alpha1.Project{}
p.Name = "proj-partialkeys"
p.Namespace = testNS
p.Spec.ScmSecretRef = "partial-scm"
if err := k8sClient.Create(ctx, p); err != nil {
t.Fatalf("create project: %v", err)
}
if _, err := reconcileProject(t, "proj-partialkeys"); err != nil {
t.Fatalf("reconcile: %v", err)
}
got := waitProjectReady(t, "proj-partialkeys", metav1.ConditionFalse)
c := apierrors.FindStatusCondition(got.Status.Conditions, "Ready")
if c.Reason != "SecretMissingKeys" {
t.Errorf("reason = %q, want SecretMissingKeys", c.Reason)
}
}
var _ = client.IgnoreNotFound
- Run and expect FAIL:
Expected: build failure undefined: ProjectReconciler (and its fields). If envtest binaries are not installed the suite panics with a control-plane start error; install them first with make envtest or setup-envtest use 1.31.x --bin-dir bin -p path and export KUBEBUILDER_ASSETS (M0 Makefile target). The build-failure FAIL is the expected state for this step once envtest assets are present.
- Write the minimal implementation (FULL code):
internal/controller/project_controller.go
// Package controller holds the tatara-operator reconcilers.
package controller
import (
"context"
"fmt"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
"github.com/szymonrychu/tatara-operator/internal/obs"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// ProjectReconciler validates a Project's SCM secret and publishes its
// webhook URL.
type ProjectReconciler struct {
client.Client
Scheme *runtime.Scheme
Metrics *obs.OperatorMetrics
ExternalWebhookBase string
}
// +kubebuilder:rbac:groups=tatara.dev,resources=projects,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=tatara.dev,resources=projects/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
// Reconcile validates spec.scmSecretRef and sets status.webhookURL plus the
// Ready condition. A missing or malformed secret is reported via the Ready
// condition (status False), not returned as an error.
func (r *ProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var project tataradevv1alpha1.Project
if err := r.Get(ctx, req.NamespacedName, &project); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
r.Metrics.ReconcileResult("Project", "error")
return ctrl.Result{}, fmt.Errorf("get project: %w", err)
}
reason, message, ready := r.validateSecret(ctx, &project)
project.Status.WebhookURL = fmt.Sprintf("%s/%s", r.ExternalWebhookBase, project.Name)
status := metav1.ConditionTrue
if !ready {
status = metav1.ConditionFalse
}
meta.SetStatusCondition(&project.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: status,
Reason: reason,
Message: message,
ObservedGeneration: project.Generation,
})
if err := r.Status().Update(ctx, &project); err != nil {
r.Metrics.ReconcileResult("Project", "error")
return ctrl.Result{}, fmt.Errorf("update project status: %w", err)
}
l.Info("reconciled project",
"action", "reconcile_project",
"resource_id", project.Name,
"ready", ready,
"reason", reason)
r.Metrics.ReconcileResult("Project", "success")
return ctrl.Result{}, nil
}
// validateSecret returns the condition (reason, message, ready) for the
// Project's scmSecretRef. ready is true only when the secret exists and has
// both required keys.
func (r *ProjectReconciler) validateSecret(ctx context.Context, project *tataradevv1alpha1.Project) (reason, message string, ready bool) {
if project.Spec.ScmSecretRef == "" {
return "SecretRefEmpty", "spec.scmSecretRef is empty", false
}
var secret corev1.Secret
key := types.NamespacedName{Namespace: project.Namespace, Name: project.Spec.ScmSecretRef}
if err := r.Get(ctx, key, &secret); err != nil {
if apierrors.IsNotFound(err) {
return "SecretNotFound", fmt.Sprintf("secret %q not found", project.Spec.ScmSecretRef), false
}
return "SecretError", err.Error(), false
}
for _, k := range []string{"token", "webhookSecret"} {
if len(secret.Data[k]) == 0 {
return "SecretMissingKeys", fmt.Sprintf("secret %q missing key %q", project.Spec.ScmSecretRef, k), false
}
}
return "Validated", "scm secret present with token and webhookSecret", true
}
// SetupWithManager registers the reconciler with the manager, watching
// Projects.
func (r *ProjectReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&tataradevv1alpha1.Project{}).
Owns(&corev1.Secret{}).
Complete(r)
}
Note: the test imports apierrors "k8s.io/apimachinery/pkg/api/meta" and calls apierrors.FindStatusCondition (that symbol lives in k8s.io/apimachinery/pkg/api/meta). The implementation imports both k8s.io/apimachinery/pkg/api/errors (as apierrors, for IsNotFound) and k8s.io/apimachinery/pkg/api/meta (as meta, for SetStatusCondition). Keep the aliases distinct per file as written.
- Run and expect PASS:
Expected: ok github.com/szymonrychu/tatara-operator/internal/controller with the three Project tests passing.
- Commit:
git add internal/controller/suite_test.go internal/controller/exit_test.go internal/controller/project_controller.go internal/controller/project_controller_test.go
git commit -m "feat: ProjectReconciler validates scm secret + renders webhookURL"
Task 4: RepositoryReconciler trigger + concurrency (envtest)¶
Implements the re-ingest trigger contract exactly. This task covers: full vs incremental decision, the status.jobName concurrency guard, pre-creating the result ConfigMap, and launching the Job. Job-success SHA application is the NEXT task so this one stays bite-sized.
Reuses the suite from Task 3.
Files: - Create: internal/controller/repository_controller.go - Test: internal/controller/repository_controller_test.go
- Write the failing test (FULL code):
internal/controller/repository_controller_test.go
package controller
import (
"context"
"testing"
"time"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
"github.com/szymonrychu/tatara-operator/internal/ingest"
"github.com/szymonrychu/tatara-operator/internal/obs"
"github.com/prometheus/client_golang/prometheus"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
func newRepoReconciler() *RepositoryReconciler {
return &RepositoryReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
Metrics: obs.NewOperatorMetrics(prometheus.NewRegistry()),
IngestConfig: ingest.Config{
IngesterImage: "registry.example/ingester:1.2.3",
MemoryBaseURL: "http://tatara-memory.tatara.svc:8080",
OIDCIssuer: "https://kc.example/realms/tatara",
OIDCClientID: "tatara-operator",
OIDCAudience: "tatara-memory",
Namespace: testNS,
},
}
}
func reconcileRepo(t *testing.T, name string) (ctrl.Result, error) {
t.Helper()
r := newRepoReconciler()
return r.Reconcile(logf.IntoContext(context.Background(), logf.Log), ctrl.Request{
NamespacedName: types.NamespacedName{Namespace: testNS, Name: name},
})
}
func mkProject(t *testing.T, name, secretRef string) {
t.Helper()
p := &tataradevv1alpha1.Project{}
p.Name = name
p.Namespace = testNS
p.Spec.ScmSecretRef = secretRef
if err := k8sClient.Create(context.Background(), p); err != nil {
t.Fatalf("create project %s: %v", name, err)
}
}
func mkRepo(t *testing.T, name, projectRef string) *tataradevv1alpha1.Repository {
t.Helper()
r := &tataradevv1alpha1.Repository{}
r.Name = name
r.Namespace = testNS
r.Spec.ProjectRef = projectRef
r.Spec.URL = "https://github.com/acme/" + name + ".git"
r.Spec.DefaultBranch = "main"
r.Spec.IngestEnabled = true
if err := k8sClient.Create(context.Background(), r); err != nil {
t.Fatalf("create repo %s: %v", name, err)
}
return r
}
func getRepo(t *testing.T, name string) *tataradevv1alpha1.Repository {
t.Helper()
r := &tataradevv1alpha1.Repository{}
if err := k8sClient.Get(context.Background(), types.NamespacedName{Namespace: testNS, Name: name}, r); err != nil {
t.Fatalf("get repo %s: %v", name, err)
}
return r
}
func listIngestJobs(t *testing.T, repoName string) []batchv1.Job {
t.Helper()
var jl batchv1.JobList
if err := k8sClient.List(context.Background(), &jl,
client.InNamespace(testNS),
client.MatchingLabels{"tatara.dev/repository": repoName}); err != nil {
t.Fatalf("list jobs: %v", err)
}
return jl.Items
}
func waitRepoJob(t *testing.T, repoName string) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
r := getRepo(t, repoName)
if r.Status.JobName != "" {
return r.Status.JobName
}
time.Sleep(interval)
}
t.Fatalf("repo %s never set status.jobName", repoName)
return ""
}
func TestRepoReconcile_FullIngestLaunchesJob(t *testing.T) {
mkProject(t, "rp-full", "rp-full-scm")
mkSecret(t, "rp-full-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
mkRepo(t, "full", "rp-full")
if _, err := reconcileRepo(t, "full"); err != nil {
t.Fatalf("reconcile: %v", err)
}
jobName := waitRepoJob(t, "full")
jobs := listIngestJobs(t, "full")
if len(jobs) != 1 {
t.Fatalf("jobs = %d, want 1", len(jobs))
}
if jobs[0].Name != jobName {
t.Errorf("status.jobName = %q, job = %q", jobName, jobs[0].Name)
}
// full ingest: no --since in the main container script
script := jobs[0].Spec.Template.Spec.Containers[0].Args[0]
if contains(script, "--since") {
t.Errorf("full ingest job must not pass --since: %q", script)
}
// result ConfigMap pre-created
cm := &corev1.ConfigMap{}
if err := k8sClient.Get(context.Background(),
types.NamespacedName{Namespace: testNS, Name: "full-ingest-result"}, cm); err != nil {
t.Fatalf("result configmap not pre-created: %v", err)
}
if getRepo(t, "full").Status.Phase != "Ingesting" {
t.Errorf("phase = %q, want Ingesting", getRepo(t, "full").Status.Phase)
}
}
func TestRepoReconcile_ConcurrencyGuard(t *testing.T) {
mkProject(t, "rp-guard", "rp-guard-scm")
mkSecret(t, "rp-guard-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
mkRepo(t, "guard", "rp-guard")
if _, err := reconcileRepo(t, "guard"); err != nil {
t.Fatalf("first reconcile: %v", err)
}
first := waitRepoJob(t, "guard")
// second reconcile while the Job is still active must not launch another
if _, err := reconcileRepo(t, "guard"); err != nil {
t.Fatalf("second reconcile: %v", err)
}
jobs := listIngestJobs(t, "guard")
if len(jobs) != 1 {
t.Fatalf("jobs after second reconcile = %d, want 1 (guard held)", len(jobs))
}
if getRepo(t, "guard").Status.JobName != first {
t.Errorf("jobName changed under guard: %q -> %q", first, getRepo(t, "guard").Status.JobName)
}
}
func TestRepoReconcile_IncrementalUsesSince(t *testing.T) {
mkProject(t, "rp-inc", "rp-inc-scm")
mkSecret(t, "rp-inc-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
r := mkRepo(t, "inc", "rp-inc")
// simulate a prior successful ingest
r = getRepo(t, "inc")
r.Status.LastIngestedCommit = "oldsha99"
r.Status.LastIngestTime = metav1.NewTime(time.Now().Add(-1 * time.Hour))
r.Status.Phase = "Ingested"
if err := k8sClient.Status().Update(context.Background(), r); err != nil {
t.Fatalf("seed status: %v", err)
}
// request a re-ingest via the annotation, newer than lastIngestTime
r = getRepo(t, "inc")
if r.Annotations == nil {
r.Annotations = map[string]string{}
}
r.Annotations["tatara.dev/reingest-requested"] = time.Now().Format(time.RFC3339)
if err := k8sClient.Update(context.Background(), r); err != nil {
t.Fatalf("set annotation: %v", err)
}
if _, err := reconcileRepo(t, "inc"); err != nil {
t.Fatalf("reconcile: %v", err)
}
waitRepoJob(t, "inc")
jobs := listIngestJobs(t, "inc")
if len(jobs) != 1 {
t.Fatalf("jobs = %d, want 1", len(jobs))
}
script := jobs[0].Spec.Template.Spec.Containers[0].Args[0]
if !contains(script, "--since oldsha99") {
t.Errorf("incremental job must pass --since oldsha99: %q", script)
}
}
func TestRepoReconcile_NoReingestWhenAnnotationStale(t *testing.T) {
mkProject(t, "rp-stale", "rp-stale-scm")
mkSecret(t, "rp-stale-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
r := mkRepo(t, "stale", "rp-stale")
r = getRepo(t, "stale")
r.Status.LastIngestedCommit = "shaA"
r.Status.LastIngestTime = metav1.NewTime(time.Now())
r.Status.Phase = "Ingested"
if err := k8sClient.Status().Update(context.Background(), r); err != nil {
t.Fatalf("seed status: %v", err)
}
r = getRepo(t, "stale")
if r.Annotations == nil {
r.Annotations = map[string]string{}
}
// annotation OLDER than lastIngestTime -> no new ingest
r.Annotations["tatara.dev/reingest-requested"] = time.Now().Add(-2 * time.Hour).Format(time.RFC3339)
if err := k8sClient.Update(context.Background(), r); err != nil {
t.Fatalf("set annotation: %v", err)
}
if _, err := reconcileRepo(t, "stale"); err != nil {
t.Fatalf("reconcile: %v", err)
}
jobs := listIngestJobs(t, "stale")
if len(jobs) != 0 {
t.Fatalf("stale annotation must not launch a job, got %d", len(jobs))
}
}
func contains(s, sub string) bool {
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
Add the missing client import used by listIngestJobs to this test file header (it is referenced via client.InNamespace): the import block above includes only what the visible code names; add "sigs.k8s.io/controller-runtime/pkg/client" to the import list of this file when you paste it (kept out of the literal block to avoid an unused-import error if your editor auto-removes it - it IS used, keep it).
- Run and expect FAIL:
Expected: build failure undefined: RepositoryReconciler (and its IngestConfig field).
- Write the minimal implementation (FULL code):
internal/controller/repository_controller.go
package controller
import (
"context"
"fmt"
"time"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
"github.com/szymonrychu/tatara-operator/internal/ingest"
"github.com/szymonrychu/tatara-operator/internal/obs"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/log"
)
// ReingestAnnotation is the RFC3339 timestamp annotation the M2 webhook sets
// to request an incremental re-ingest.
const ReingestAnnotation = "tatara.dev/reingest-requested"
// RepositoryReconciler drives ingest Jobs for Repositories.
type RepositoryReconciler struct {
client.Client
Scheme *runtime.Scheme
Metrics *obs.OperatorMetrics
IngestConfig ingest.Config
}
// +kubebuilder:rbac:groups=tatara.dev,resources=repositories,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=tatara.dev,resources=repositories/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=tatara.dev,resources=projects,verbs=get;list;watch
// +kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;delete
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch
// Reconcile launches and tracks the ingest Job for a Repository per the
// re-ingest trigger contract.
func (r *RepositoryReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx)
var repo tataradevv1alpha1.Repository
if err := r.Get(ctx, req.NamespacedName, &repo); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("get repository: %w", err)
}
if !repo.Spec.IngestEnabled {
return ctrl.Result{}, nil
}
// Concurrency guard: a named Job that still exists blocks new launches.
if repo.Status.JobName != "" {
var job batchv1.Job
err := r.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: repo.Status.JobName}, &job)
switch {
case err == nil && jobActive(&job):
l.Info("ingest job still active, requeueing",
"action", "ingest_guard", "resource_id", repo.Name, "job", repo.Status.JobName)
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
case err == nil:
// terminal job handled by Task 5 result-apply path
return r.handleFinishedJob(ctx, &repo, &job)
case apierrors.IsNotFound(err):
// Job vanished (TTL/manual delete); clear and re-evaluate.
repo.Status.JobName = ""
if err := r.Status().Update(ctx, &repo); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("clear stale jobName: %w", err)
}
default:
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("get ingest job: %w", err)
}
}
since, want := r.ingestDecision(&repo)
if !want {
r.Metrics.ReconcileResult("Repository", "success")
return ctrl.Result{}, nil
}
var project tataradevv1alpha1.Project
if err := r.Get(ctx, types.NamespacedName{Namespace: repo.Namespace, Name: repo.Spec.ProjectRef}, &project); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("get owning project %q: %w", repo.Spec.ProjectRef, err)
}
if err := r.ensureResultConfigMap(ctx, &repo); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("ensure result configmap: %w", err)
}
job := ingest.BuildJob(&project, &repo, since, r.IngestConfig)
if err := r.Create(ctx, job); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("create ingest job: %w", err)
}
repo.Status.JobName = job.Name
repo.Status.Phase = "Ingesting"
meta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{
Type: "Ingested",
Status: metav1.ConditionFalse,
Reason: "IngestStarted",
Message: "ingest job " + job.Name + " launched",
ObservedGeneration: repo.Generation,
})
if err := r.Status().Update(ctx, &repo); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("update repository status: %w", err)
}
l.Info("launched ingest job",
"action", "ingest_start", "resource_id", repo.Name, "job", job.Name,
"incremental", since != "")
r.Metrics.ReconcileResult("Repository", "success")
return ctrl.Result{}, nil
}
// ingestDecision returns (sinceSHA, wantIngest). Full ingest (empty since)
// when lastIngestedCommit is empty. Incremental (since=lastIngestedCommit)
// when the reingest-requested annotation is newer than lastIngestTime.
func (r *RepositoryReconciler) ingestDecision(repo *tataradevv1alpha1.Repository) (string, bool) {
if repo.Status.LastIngestedCommit == "" {
return "", true
}
raw := repo.Annotations[ReingestAnnotation]
if raw == "" {
return "", false
}
requested, err := time.Parse(time.RFC3339, raw)
if err != nil {
return "", false
}
if requested.After(repo.Status.LastIngestTime.Time) {
return repo.Status.LastIngestedCommit, true
}
return "", false
}
// ensureResultConfigMap creates the empty <repo>-ingest-result ConfigMap
// (owner-ref Repository) if absent so the Job can patch it and the reconciler
// can read it back.
func (r *RepositoryReconciler) ensureResultConfigMap(ctx context.Context, repo *tataradevv1alpha1.Repository) error {
cm := &corev1.ConfigMap{}
cm.Name = ingest.ResultConfigMapName(repo)
cm.Namespace = repo.Namespace
if err := r.Get(ctx, types.NamespacedName{Namespace: cm.Namespace, Name: cm.Name}, cm); err == nil {
return nil
} else if !apierrors.IsNotFound(err) {
return fmt.Errorf("get result configmap: %w", err)
}
cm = &corev1.ConfigMap{}
cm.Name = ingest.ResultConfigMapName(repo)
cm.Namespace = repo.Namespace
cm.Data = map[string]string{"sha": ""}
if err := controllerutil.SetControllerReference(repo, cm, r.Scheme); err != nil {
return fmt.Errorf("set ownerref on result configmap: %w", err)
}
if err := r.Create(ctx, cm); err != nil && !apierrors.IsAlreadyExists(err) {
return fmt.Errorf("create result configmap: %w", err)
}
return nil
}
// handleFinishedJob is implemented in Task 5; for Task 4 it requeues so the
// guard does not loop. Replaced wholesale by the Task 5 version.
func (r *RepositoryReconciler) handleFinishedJob(ctx context.Context, repo *tataradevv1alpha1.Repository, job *batchv1.Job) (ctrl.Result, error) {
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
// jobActive reports whether a Job has neither completed nor failed.
func jobActive(job *batchv1.Job) bool {
for _, c := range job.Status.Conditions {
if (c.Type == batchv1.JobComplete || c.Type == batchv1.JobFailed) && c.Status == corev1.ConditionTrue {
return false
}
}
return true
}
// SetupWithManager registers the reconciler, watching Repositories and the
// Jobs they own.
func (r *RepositoryReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&tataradevv1alpha1.Repository{}).
Owns(&batchv1.Job{}).
Owns(&corev1.ConfigMap{}).
Complete(r)
}
- Run and expect PASS:
Expected: ok with the four TestRepoReconcile_* tests passing (full launch, concurrency guard, incremental --since, stale annotation no-op).
- Commit:
git add internal/controller/repository_controller.go internal/controller/repository_controller_test.go
git commit -m "feat: RepositoryReconciler ingest trigger + concurrency guard"
Task 5: RepositoryReconciler applies Job result on success/failure¶
Replaces the stub handleFinishedJob so that on Job completion the reconciler reads <repo>-ingest-result.data["sha"], sets status.lastIngestedCommit/lastIngestTime/phase=Ingested, clears status.jobName, sets the Ingested condition True, and observes operator_ingest_job_duration_seconds. On Job failure it sets phase=Failed, clears jobName, and sets the condition False.
Reuses the suite from Task 3.
Files: - Modify: internal/controller/repository_controller.go - Test: internal/controller/repository_result_test.go
- Write the failing test (FULL code):
internal/controller/repository_result_test.go
package controller
import (
"context"
"testing"
"time"
tataradevv1alpha1 "github.com/szymonrychu/tatara-operator/api/v1alpha1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
)
func markJob(t *testing.T, name string, cond batchv1.JobConditionType) {
t.Helper()
job := &batchv1.Job{}
if err := k8sClient.Get(context.Background(),
types.NamespacedName{Namespace: testNS, Name: name}, job); err != nil {
t.Fatalf("get job %s: %v", name, err)
}
job.Status.Conditions = []batchv1.JobCondition{{
Type: cond,
Status: corev1.ConditionTrue,
LastTransitionTime: metav1.Now(),
}}
now := metav1.Now()
job.Status.StartTime = &metav1.Time{Time: now.Add(-30 * time.Second)}
if cond == batchv1.JobComplete {
job.Status.CompletionTime = &now
}
if err := k8sClient.Status().Update(context.Background(), job); err != nil {
t.Fatalf("update job status %s: %v", name, err)
}
}
func setResultSHA(t *testing.T, repoName, sha string) {
t.Helper()
cm := &corev1.ConfigMap{}
if err := k8sClient.Get(context.Background(),
types.NamespacedName{Namespace: testNS, Name: repoName + "-ingest-result"}, cm); err != nil {
t.Fatalf("get result cm: %v", err)
}
cm.Data = map[string]string{"sha": sha}
if err := k8sClient.Update(context.Background(), cm); err != nil {
t.Fatalf("update result cm: %v", err)
}
}
func TestRepoReconcile_JobSuccessAppliesSHA(t *testing.T) {
mkProject(t, "rp-ok", "rp-ok-scm")
mkSecret(t, "rp-ok-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
mkRepo(t, "okrepo", "rp-ok")
if _, err := reconcileRepo(t, "okrepo"); err != nil {
t.Fatalf("launch reconcile: %v", err)
}
jobName := waitRepoJob(t, "okrepo")
setResultSHA(t, "okrepo", "deadbeef")
markJob(t, jobName, batchv1.JobComplete)
if _, err := reconcileRepo(t, "okrepo"); err != nil {
t.Fatalf("post-completion reconcile: %v", err)
}
got := getRepo(t, "okrepo")
if got.Status.LastIngestedCommit != "deadbeef" {
t.Errorf("lastIngestedCommit = %q, want deadbeef", got.Status.LastIngestedCommit)
}
if got.Status.Phase != "Ingested" {
t.Errorf("phase = %q, want Ingested", got.Status.Phase)
}
if got.Status.JobName != "" {
t.Errorf("jobName = %q, want cleared", got.Status.JobName)
}
if got.Status.LastIngestTime.IsZero() {
t.Error("lastIngestTime not set")
}
c := apimeta.FindStatusCondition(got.Status.Conditions, "Ingested")
if c == nil || c.Status != metav1.ConditionTrue {
t.Errorf("Ingested condition = %+v, want True", c)
}
}
func TestRepoReconcile_JobFailureSetsFailed(t *testing.T) {
mkProject(t, "rp-bad", "rp-bad-scm")
mkSecret(t, "rp-bad-scm", map[string][]byte{"token": []byte("x"), "webhookSecret": []byte("y")})
mkRepo(t, "badrepo", "rp-bad")
if _, err := reconcileRepo(t, "badrepo"); err != nil {
t.Fatalf("launch reconcile: %v", err)
}
jobName := waitRepoJob(t, "badrepo")
markJob(t, jobName, batchv1.JobFailed)
if _, err := reconcileRepo(t, "badrepo"); err != nil {
t.Fatalf("post-failure reconcile: %v", err)
}
got := getRepo(t, "badrepo")
if got.Status.Phase != "Failed" {
t.Errorf("phase = %q, want Failed", got.Status.Phase)
}
if got.Status.JobName != "" {
t.Errorf("jobName = %q, want cleared", got.Status.JobName)
}
c := apimeta.FindStatusCondition(got.Status.Conditions, "Ingested")
if c == nil || c.Status != metav1.ConditionFalse || c.Reason != "IngestFailed" {
t.Errorf("Ingested condition = %+v, want False/IngestFailed", c)
}
}
- Run and expect FAIL:
Expected: FAIL - lastIngestedCommit = "" want deadbeef and phase = "Ingesting" want Ingested (the stub handleFinishedJob only requeues; it does not apply the result).
- Write the minimal implementation (FULL code) - replace the stub
handleFinishedJobwith this full version:
In internal/controller/repository_controller.go, replace the entire stub:
// handleFinishedJob is implemented in Task 5; for Task 4 it requeues so the
// guard does not loop. Replaced wholesale by the Task 5 version.
func (r *RepositoryReconciler) handleFinishedJob(ctx context.Context, repo *tataradevv1alpha1.Repository, job *batchv1.Job) (ctrl.Result, error) {
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
with:
// handleFinishedJob applies a terminal ingest Job's outcome to the Repository
// status: on success it reads the resolved HEAD SHA from the result ConfigMap
// and records lastIngestedCommit/lastIngestTime/phase=Ingested; on failure it
// records phase=Failed. It always clears status.jobName and observes the Job
// duration.
func (r *RepositoryReconciler) handleFinishedJob(ctx context.Context, repo *tataradevv1alpha1.Repository, job *batchv1.Job) (ctrl.Result, error) {
l := log.FromContext(ctx)
if job.Status.StartTime != nil && job.Status.CompletionTime != nil {
r.Metrics.ObserveIngestJobDuration(job.Status.CompletionTime.Sub(job.Status.StartTime.Time).Seconds())
}
if jobSucceeded(job) {
sha, err := r.readResultSHA(ctx, repo)
if err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("read ingest result sha: %w", err)
}
repo.Status.LastIngestedCommit = sha
repo.Status.LastIngestTime = metav1.Now()
repo.Status.Phase = "Ingested"
repo.Status.JobName = ""
meta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{
Type: "Ingested",
Status: metav1.ConditionTrue,
Reason: "IngestSucceeded",
Message: "ingested at " + sha,
ObservedGeneration: repo.Generation,
})
if err := r.Status().Update(ctx, repo); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("update repository status: %w", err)
}
l.Info("ingest succeeded",
"action", "ingest_succeeded", "resource_id", repo.Name, "sha", sha, "job", job.Name)
r.Metrics.ReconcileResult("Repository", "success")
return ctrl.Result{}, nil
}
repo.Status.Phase = "Failed"
repo.Status.JobName = ""
meta.SetStatusCondition(&repo.Status.Conditions, metav1.Condition{
Type: "Ingested",
Status: metav1.ConditionFalse,
Reason: "IngestFailed",
Message: "ingest job " + job.Name + " failed",
ObservedGeneration: repo.Generation,
})
if err := r.Status().Update(ctx, repo); err != nil {
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, fmt.Errorf("update repository status: %w", err)
}
l.Info("ingest failed",
"action", "ingest_failed", "resource_id", repo.Name, "job", job.Name)
r.Metrics.ReconcileResult("Repository", "error")
return ctrl.Result{}, nil
}
// jobSucceeded reports whether the Job has a Complete=True condition.
func jobSucceeded(job *batchv1.Job) bool {
for _, c := range job.Status.Conditions {
if c.Type == batchv1.JobComplete && c.Status == corev1.ConditionTrue {
return true
}
}
return false
}
// readResultSHA reads data["sha"] from the repo's result ConfigMap.
func (r *RepositoryReconciler) readResultSHA(ctx context.Context, repo *tataradevv1alpha1.Repository) (string, error) {
var cm corev1.ConfigMap
key := types.NamespacedName{Namespace: repo.Namespace, Name: ingest.ResultConfigMapName(repo)}
if err := r.Get(ctx, key, &cm); err != nil {
return "", fmt.Errorf("get result configmap: %w", err)
}
sha := cm.Data["sha"]
if sha == "" {
return "", fmt.Errorf("result configmap %s has empty sha", cm.Name)
}
return sha, nil
}
- Run and expect PASS:
Expected: ok - both tests pass. Then run the whole controller package to confirm no regression:
Expected: all Project and Repository tests pass.
- Commit:
git add internal/controller/repository_controller.go internal/controller/repository_result_test.go
git commit -m "feat: apply ingest Job result (sha from result configmap) to Repository status"
Task 6: Wire both reconcilers into cmd/manager/main.go¶
Register ProjectReconciler and RepositoryReconciler with the manager, constructing them from internal/config and internal/obs. A small unit test validates the wiring helper (addReconcilers) so we exercise registration without a full manager run; the manager bootstrap itself stays untested per the usual controller-runtime convention (it is config glue).
Files: - Modify: cmd/manager/main.go - Create: cmd/manager/wire.go - Test: cmd/manager/wire_test.go
- Write the failing test (FULL code):
cmd/manager/wire_test.go
package main
import (
"testing"
"github.com/szymonrychu/tatara-operator/internal/config"
"github.com/szymonrychu/tatara-operator/internal/ingest"
)
func TestIngestConfigFromConfig(t *testing.T) {
cfg := config.Config{
MemoryBaseURL: "http://mem:8080",
IngesterImage: "img:1",
OIDCIssuer: "https://kc/realms/t",
OperatorOIDCClientID: "tatara-operator",
OperatorOIDCClientSecret: "secret",
Namespace: "tatara",
}
got := ingestConfigFromConfig(cfg, "tatara-memory")
want := ingest.Config{
IngesterImage: "img:1",
MemoryBaseURL: "http://mem:8080",
OIDCIssuer: "https://kc/realms/t",
OIDCClientID: "tatara-operator",
OIDCClientSecret: "secret",
OIDCAudience: "tatara-memory",
Namespace: "tatara",
}
if got != want {
t.Errorf("ingestConfigFromConfig = %+v, want %+v", got, want)
}
}
This test pins the field mapping from internal/config.Config to ingest.Config. It assumes M0's config.Config exposes the named fields (MemoryBaseURL, IngesterImage, OIDCIssuer, OperatorOIDCClientID, OperatorOIDCClientSecret, Namespace, ExternalWebhookBase). If M0 named them differently, adjust the field references in wire.go and this test to match M0 exactly - do not rename M0's fields.
- Run and expect FAIL:
Expected: build failure undefined: ingestConfigFromConfig.
- Write the minimal implementation (FULL code):
cmd/manager/wire.go
package main
import (
"fmt"
"github.com/szymonrychu/tatara-operator/internal/config"
"github.com/szymonrychu/tatara-operator/internal/controller"
"github.com/szymonrychu/tatara-operator/internal/ingest"
"github.com/szymonrychu/tatara-operator/internal/obs"
ctrl "sigs.k8s.io/controller-runtime"
)
// ingestConfigFromConfig maps the operator config to the ingest Job builder
// config. memoryAudience is the OIDC audience the ingester presents to
// tatara-memory.
func ingestConfigFromConfig(cfg config.Config, memoryAudience string) ingest.Config {
return ingest.Config{
IngesterImage: cfg.IngesterImage,
MemoryBaseURL: cfg.MemoryBaseURL,
OIDCIssuer: cfg.OIDCIssuer,
OIDCClientID: cfg.OperatorOIDCClientID,
OIDCClientSecret: cfg.OperatorOIDCClientSecret,
OIDCAudience: memoryAudience,
Namespace: cfg.Namespace,
}
}
// addReconcilers constructs and registers the M1 reconcilers with mgr.
func addReconcilers(mgr ctrl.Manager, cfg config.Config, metrics *obs.OperatorMetrics) error {
if err := (&controller.ProjectReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Metrics: metrics,
ExternalWebhookBase: cfg.ExternalWebhookBase,
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("setup ProjectReconciler: %w", err)
}
if err := (&controller.RepositoryReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Metrics: metrics,
IngestConfig: ingestConfigFromConfig(cfg, "tatara-memory"),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("setup RepositoryReconciler: %w", err)
}
return nil
}
Then modify cmd/manager/main.go (created by M0) to call addReconcilers after the manager is built. Locate the section where M0 constructs the ctrl.Manager (variable mgr), the loaded config.Config (variable cfg), and the metrics bundle. Immediately after the manager is created and before mgr.Start(...), insert:
if err := addReconcilers(mgr, cfg, operatorMetrics); err != nil {
setupLog.Error(err, "unable to set up reconcilers")
os.Exit(1)
}
where operatorMetrics is an *obs.OperatorMetrics. If M0 did not already construct one, add (near where M0 builds the obs/registry, using the registry M0 already exposes on the metrics server):
using M0's registry variable name. State the exact variable names you used in the commit body. Do not invent a new metrics endpoint; reuse M0's.
- Run and expect PASS:
Expected: the test passes and the whole module builds (confirming main.go compiles with the new wiring).
- Commit:
git add cmd/manager/wire.go cmd/manager/wire_test.go cmd/manager/main.go
git commit -m "feat: wire Project + Repository reconcilers into manager"
Task 7: Full-package verification + docs¶
No new behavior. Run the full suite, vet, and lint; update MEMORY.md and ROADMAP.md.
Files: - Modify: MEMORY.md - Modify: ROADMAP.md
- Run the full verification (expect PASS):
Expected: build clean, vet clean, all tests pass, lint clean. If golangci-lint flags the _ = strings.TrimSpace / _ = metav1.Now / _ = batchv1.Job{} / _ = client.IgnoreNotFound keep-alive lines added in tests, remove them and re-add the genuinely-used imports they were guarding, then re-run. They exist only to prevent unused-import churn while pasting; delete any that the final code does not need.
- Update
MEMORY.md(append, dated 2026-06-06):
- 2026-06-06 (M1) Ingest result SHA flows via a per-Repository ConfigMap
`<repo>-ingest-result` (key `sha`). The ingest Job patches it via the
in-cluster API after `git rev-parse HEAD`; the reconciler pre-creates it
(owner-ref Repository) and reads it on Job success. Chosen over pod-log
parsing (brittle) and Job annotations (Job cannot patch itself cleanly).
REQUIRES M6 chart to create ServiceAccount `tatara-ingest` + a Role granting
get/create/update/patch on ConfigMaps in ns `tatara`. Ingest container also
needs `kubectl` on PATH (the ingester image carries the Go toolchain; verify
kubectl presence in M6, else switch the patch step to a tiny `curl` against
the API or a dedicated sidecar).
- 2026-06-06 (M1) Re-ingest trigger: full when status.lastIngestedCommit=="",
incremental (--since lastIngestedCommit) when annotation
tatara.dev/reingest-requested (RFC3339) is newer than status.lastIngestTime.
status.jobName is the single-flight guard. Conditions: Project `Ready`,
Repository `Ingested`.
- 2026-06-06 (M1) Clone uses x-access-token:${SCM_TOKEN}@<host/path> in the
init container; works for both GitHub and GitLab HTTPS with the Secret key
`token`.
-
Update
ROADMAP.md: move the M1 line to done / strike it, leave M2 (webhook server) as the next item. Add a one-line M6 follow-up: "M6: chart must createtatara-ingestServiceAccount + ConfigMap-patch Role (see MEMORY.md)." -
Commit:
Done criteria for M1¶
go test ./...green, including envtest reconciler tests and the Job-builder unit tests.ProjectReconcilersetsstatus.webhookURLand theReadycondition; rejects missing secret / missing keys via condition (not error).RepositoryReconcilerlaunches exactly one ingest Job per Repository, guards re-entry viastatus.jobName, passes--sinceonly on the incremental path, and on Job success recordslastIngestedCommitfrom the result ConfigMap withphase=Ingested; on failurephase=Failed.- Metrics
operator_reconcile_total{kind,result}andoperator_ingest_job_duration_secondsare incremented/observed. - Both reconcilers registered in
cmd/manager/main.go; module builds. MEMORY.md/ROADMAP.mdupdated; the M6 RBAC follow-up is recorded.
Merge the worktree back to main per hard rule 10 (worktree -> source repo main -> cleanup worktree). Do not build/deploy from the worktree.