diff --git a/cmd/axon-controller/main.go b/cmd/axon-controller/main.go index 36a09687..98d19f38 100644 --- a/cmd/axon-controller/main.go +++ b/cmd/axon-controller/main.go @@ -10,12 +10,14 @@ import ( "k8s.io/client-go/kubernetes" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" axonv1alpha1 "github.com/axon-core/axon/api/v1alpha1" "github.com/axon-core/axon/internal/controller" "github.com/axon-core/axon/internal/githubapp" + "github.com/axon-core/axon/internal/telemetry" ) var ( @@ -44,6 +46,8 @@ func main() { var spawnerImagePullPolicy string var tokenRefresherImage string var tokenRefresherImagePullPolicy string + var telemetryReport bool + var telemetryEndpoint string flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -62,6 +66,8 @@ func main() { flag.StringVar(&spawnerImagePullPolicy, "spawner-image-pull-policy", "", "The image pull policy for spawner Deployments (e.g., Always, Never, IfNotPresent).") flag.StringVar(&tokenRefresherImage, "token-refresher-image", controller.DefaultTokenRefresherImage, "The image to use for the token refresher sidecar.") flag.StringVar(&tokenRefresherImagePullPolicy, "token-refresher-image-pull-policy", "", "The image pull policy for the token refresher sidecar (e.g., Always, Never, IfNotPresent).") + flag.BoolVar(&telemetryReport, "telemetry-report", false, "Run a one-shot telemetry report and exit.") + flag.StringVar(&telemetryEndpoint, "telemetry-endpoint", "https://telemetry.axon.dev/v1/report", "The endpoint to send telemetry reports to.") opts := zap.Options{ Development: true, @@ -71,6 +77,25 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + if telemetryReport { + cfg := ctrl.GetConfigOrDie() + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + setupLog.Error(err, "Unable to create client for telemetry") + os.Exit(1) + } + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + setupLog.Error(err, "Unable to create clientset for telemetry") + os.Exit(1) + } + if err := telemetry.Run(ctrl.SetupSignalHandler(), c, clientset, telemetryEndpoint); err != nil { + setupLog.Error(err, "Telemetry report failed") + os.Exit(1) + } + os.Exit(0) + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ Scheme: scheme, HealthProbeBindAddress: probeAddr, diff --git a/go.mod b/go.mod index 7f4ddf8b..5f9ea190 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/axon-core/axon go 1.25.0 require ( + github.com/google/uuid v1.6.0 github.com/google/yamlfmt v0.21.0 github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.3 @@ -44,7 +45,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/renameio/v2 v2.0.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/install.yaml b/install.yaml index 4e0f2cd1..8ac761f3 100644 --- a/install.yaml +++ b/install.yaml @@ -292,3 +292,41 @@ spec: requests: cpu: 10m memory: 64Mi +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: axon-telemetry + namespace: axon-system + labels: + app.kubernetes.io/name: axon + app.kubernetes.io/component: telemetry +spec: + schedule: "0 6 * * *" + concurrencyPolicy: Replace + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + backoffLimit: 3 + template: + spec: + serviceAccountName: axon-controller + restartPolicy: OnFailure + containers: + - name: telemetry + image: gjkim42/axon-controller:latest + args: + - --telemetry-report + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + limits: + cpu: 100m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi diff --git a/internal/manifests/install.yaml b/internal/manifests/install.yaml index 4e0f2cd1..8ac761f3 100644 --- a/internal/manifests/install.yaml +++ b/internal/manifests/install.yaml @@ -292,3 +292,41 @@ spec: requests: cpu: 10m memory: 64Mi +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: axon-telemetry + namespace: axon-system + labels: + app.kubernetes.io/name: axon + app.kubernetes.io/component: telemetry +spec: + schedule: "0 6 * * *" + concurrencyPolicy: Replace + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 1 + jobTemplate: + spec: + backoffLimit: 3 + template: + spec: + serviceAccountName: axon-controller + restartPolicy: OnFailure + containers: + - name: telemetry + image: gjkim42/axon-controller:latest + args: + - --telemetry-report + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + limits: + cpu: 100m + memory: 64Mi + requests: + cpu: 10m + memory: 32Mi diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000..f0aa12b0 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,325 @@ +package telemetry + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "time" + + "github.com/google/uuid" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + axonv1alpha1 "github.com/axon-core/axon/api/v1alpha1" + "github.com/axon-core/axon/internal/version" +) + +var log = ctrl.Log.WithName("telemetry") + +const ( + // configMapName is the name of the ConfigMap storing the installation ID. + configMapName = "axon-telemetry" + // installationIDKey is the key in the ConfigMap for the installation ID. + installationIDKey = "installation-id" + // defaultNamespace is the namespace for the telemetry ConfigMap. + defaultNamespace = "axon-system" + // sendTimeout is the HTTP timeout for sending telemetry reports. + sendTimeout = 10 * time.Second +) + +// Report contains anonymous aggregate telemetry data. +type Report struct { + InstallationID string `json:"installationId"` + Version string `json:"version"` + K8sVersion string `json:"k8sVersion"` + Timestamp time.Time `json:"timestamp"` + Tasks TaskReport `json:"tasks"` + Features FeatureReport `json:"features"` + Scale ScaleReport `json:"scale"` + Usage UsageReport `json:"usage"` +} + +// TaskReport contains aggregate task counts. +type TaskReport struct { + Total int `json:"total"` + ByType map[string]int `json:"byType"` + ByPhase map[string]int `json:"byPhase"` +} + +// FeatureReport contains feature usage counts. +type FeatureReport struct { + TaskSpawners int `json:"taskSpawners"` + AgentConfigs int `json:"agentConfigs"` + Workspaces int `json:"workspaces"` + SourceTypes []string `json:"sourceTypes"` +} + +// ScaleReport contains scale metrics. +type ScaleReport struct { + Namespaces int `json:"namespaces"` +} + +// UsageReport contains aggregate usage metrics. +type UsageReport struct { + TotalCostUSD float64 `json:"totalCostUsd"` + TotalInputTokens float64 `json:"totalInputTokens"` + TotalOutputTokens float64 `json:"totalOutputTokens"` +} + +// Run collects and sends a telemetry report. It is designed to be called +// as a one-shot operation from a CronJob. +func Run(ctx context.Context, c client.Client, clientset kubernetes.Interface, endpoint string) error { + report, err := collect(ctx, c, clientset) + if err != nil { + return fmt.Errorf("collecting telemetry: %w", err) + } + + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("marshaling report: %w", err) + } + log.Info("Telemetry report collected", "report", string(data)) + + if err := send(ctx, endpoint, report); err != nil { + return fmt.Errorf("sending telemetry: %w", err) + } + + log.Info("Telemetry report sent successfully") + return nil +} + +// collect gathers aggregate data from the Kubernetes API. +func collect(ctx context.Context, c client.Client, clientset kubernetes.Interface) (*Report, error) { + installID, err := getOrCreateInstallationID(ctx, c, defaultNamespace) + if err != nil { + return nil, fmt.Errorf("getting installation ID: %w", err) + } + + // Get K8s server version. + k8sVersion := "unknown" + if clientset != nil { + sv, err := clientset.Discovery().ServerVersion() + if err != nil { + log.Error(err, "Failed to get Kubernetes server version") + } else { + k8sVersion = sv.GitVersion + } + } + + report := &Report{ + InstallationID: installID, + Version: version.Version, + K8sVersion: k8sVersion, + Timestamp: time.Now().UTC(), + } + + // Collect task data. + namespaces := make(map[string]struct{}) + + var taskList axonv1alpha1.TaskList + if err := c.List(ctx, &taskList); err != nil { + return nil, fmt.Errorf("listing tasks: %w", err) + } + + byType := make(map[string]int) + byPhase := make(map[string]int) + var totalCost, totalInput, totalOutput float64 + + for i := range taskList.Items { + task := &taskList.Items[i] + namespaces[task.Namespace] = struct{}{} + byType[task.Spec.Type]++ + if task.Status.Phase != "" { + byPhase[string(task.Status.Phase)]++ + } + + // Aggregate cost/token data from results. + if results := task.Status.Results; results != nil { + if costStr, ok := results["cost-usd"]; ok { + if cost, err := strconv.ParseFloat(costStr, 64); err == nil && cost > 0 { + totalCost += cost + } + } + if inputStr, ok := results["input-tokens"]; ok { + if tokens, err := strconv.ParseFloat(inputStr, 64); err == nil && tokens > 0 { + totalInput += tokens + } + } + if outputStr, ok := results["output-tokens"]; ok { + if tokens, err := strconv.ParseFloat(outputStr, 64); err == nil && tokens > 0 { + totalOutput += tokens + } + } + } + } + + report.Tasks = TaskReport{ + Total: len(taskList.Items), + ByType: byType, + ByPhase: byPhase, + } + report.Usage = UsageReport{ + TotalCostUSD: totalCost, + TotalInputTokens: totalInput, + TotalOutputTokens: totalOutput, + } + + // Collect TaskSpawner data. + var spawnerList axonv1alpha1.TaskSpawnerList + if err := c.List(ctx, &spawnerList); err != nil { + return nil, fmt.Errorf("listing task spawners: %w", err) + } + + sourceTypesSet := make(map[string]struct{}) + for i := range spawnerList.Items { + spawner := &spawnerList.Items[i] + namespaces[spawner.Namespace] = struct{}{} + for _, st := range extractSourceTypes(&spawner.Spec.When) { + sourceTypesSet[st] = struct{}{} + } + } + + sourceTypes := make([]string, 0, len(sourceTypesSet)) + for st := range sourceTypesSet { + sourceTypes = append(sourceTypes, st) + } + sort.Strings(sourceTypes) + + // Collect AgentConfig data. + var agentConfigList axonv1alpha1.AgentConfigList + if err := c.List(ctx, &agentConfigList); err != nil { + return nil, fmt.Errorf("listing agent configs: %w", err) + } + for i := range agentConfigList.Items { + namespaces[agentConfigList.Items[i].Namespace] = struct{}{} + } + + // Collect Workspace data. + var workspaceList axonv1alpha1.WorkspaceList + if err := c.List(ctx, &workspaceList); err != nil { + return nil, fmt.Errorf("listing workspaces: %w", err) + } + for i := range workspaceList.Items { + namespaces[workspaceList.Items[i].Namespace] = struct{}{} + } + + report.Features = FeatureReport{ + TaskSpawners: len(spawnerList.Items), + AgentConfigs: len(agentConfigList.Items), + Workspaces: len(workspaceList.Items), + SourceTypes: sourceTypes, + } + + report.Scale = ScaleReport{ + Namespaces: len(namespaces), + } + + return report, nil +} + +// send posts the telemetry report to the given endpoint. +func send(ctx context.Context, endpoint string, report *Report) error { + data, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("marshaling report: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "axon-telemetry/"+version.Version) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// getOrCreateInstallationID retrieves or creates a persistent installation ID +// stored in a ConfigMap. This ID is a random UUID with no correlation to any +// identifiable information. +func getOrCreateInstallationID(ctx context.Context, c client.Client, namespace string) (string, error) { + var cm corev1.ConfigMap + key := types.NamespacedName{Name: configMapName, Namespace: namespace} + + err := c.Get(ctx, key, &cm) + switch { + case err == nil: + // ConfigMap exists; return the stored ID if present. + if id, ok := cm.Data[installationIDKey]; ok && id != "" { + return id, nil + } + // ConfigMap exists but missing the key — update it below. + + case errors.IsNotFound(err): + // ConfigMap does not exist — create it with a new ID. + id := uuid.New().String() + cm = corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/name": "axon", + "app.kubernetes.io/component": "telemetry", + }, + }, + Data: map[string]string{ + installationIDKey: id, + }, + } + if err := c.Create(ctx, &cm); err != nil { + return "", fmt.Errorf("creating configmap: %w", err) + } + return id, nil + + default: + return "", fmt.Errorf("getting configmap: %w", err) + } + + // ConfigMap exists but the installation ID key is missing or empty. + id := uuid.New().String() + if cm.Data == nil { + cm.Data = make(map[string]string) + } + cm.Data[installationIDKey] = id + if err := c.Update(ctx, &cm); err != nil { + return "", fmt.Errorf("updating configmap: %w", err) + } + return id, nil +} + +// extractSourceTypes returns the source types configured on a When spec. +func extractSourceTypes(when *axonv1alpha1.When) []string { + var types []string + if when.GitHubIssues != nil { + types = append(types, "github") + } + if when.Cron != nil { + types = append(types, "cron") + } + if when.Jira != nil { + types = append(types, "jira") + } + return types +} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000..7f9cba29 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,457 @@ +package telemetry + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + discoveryfake "k8s.io/client-go/discovery/fake" + k8sfake "k8s.io/client-go/kubernetes/fake" + clientfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + axonv1alpha1 "github.com/axon-core/axon/api/v1alpha1" + "github.com/axon-core/axon/internal/version" +) + +func newScheme() *runtime.Scheme { + s := runtime.NewScheme() + _ = corev1.AddToScheme(s) + _ = axonv1alpha1.AddToScheme(s) + return s +} + +func TestCollect(t *testing.T) { + scheme := newScheme() + + tasks := []axonv1alpha1.Task{ + { + ObjectMeta: metav1.ObjectMeta{Name: "task-1", Namespace: "ns-a"}, + Spec: axonv1alpha1.TaskSpec{Type: "claude-code"}, + Status: axonv1alpha1.TaskStatus{ + Phase: axonv1alpha1.TaskPhaseSucceeded, + Results: map[string]string{ + "cost-usd": "1.50", + "input-tokens": "1000", + "output-tokens": "200", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "task-2", Namespace: "ns-a"}, + Spec: axonv1alpha1.TaskSpec{Type: "claude-code"}, + Status: axonv1alpha1.TaskStatus{Phase: axonv1alpha1.TaskPhaseFailed}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "task-3", Namespace: "ns-b"}, + Spec: axonv1alpha1.TaskSpec{Type: "codex"}, + Status: axonv1alpha1.TaskStatus{ + Phase: axonv1alpha1.TaskPhaseSucceeded, + Results: map[string]string{ + "cost-usd": "0.50", + "input-tokens": "500", + "output-tokens": "100", + }, + }, + }, + } + + spawners := []axonv1alpha1.TaskSpawner{ + { + ObjectMeta: metav1.ObjectMeta{Name: "spawner-1", Namespace: "ns-a"}, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{}, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "spawner-2", Namespace: "ns-b"}, + Spec: axonv1alpha1.TaskSpawnerSpec{ + When: axonv1alpha1.When{ + Cron: &axonv1alpha1.Cron{Schedule: "0 * * * *"}, + }, + }, + }, + } + + agentConfigs := []axonv1alpha1.AgentConfig{ + {ObjectMeta: metav1.ObjectMeta{Name: "config-1", Namespace: "ns-a"}}, + } + + workspaces := []axonv1alpha1.Workspace{ + {ObjectMeta: metav1.ObjectMeta{Name: "ws-1", Namespace: "ns-a"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "ws-2", Namespace: "ns-c"}}, + } + + // Pre-create the telemetry ConfigMap for installation ID. + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: defaultNamespace}, + Data: map[string]string{installationIDKey: "test-install-id"}, + } + + objs := []runtime.Object{cm} + for i := range tasks { + objs = append(objs, &tasks[i]) + } + for i := range spawners { + objs = append(objs, &spawners[i]) + } + for i := range agentConfigs { + objs = append(objs, &agentConfigs[i]) + } + for i := range workspaces { + objs = append(objs, &workspaces[i]) + } + + c := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(objs...). + Build() + + clientset := k8sfake.NewSimpleClientset() + + report, err := collect(context.Background(), c, clientset) + if err != nil { + t.Fatalf("collect() error: %v", err) + } + + if report.InstallationID != "test-install-id" { + t.Errorf("InstallationID = %q, want %q", report.InstallationID, "test-install-id") + } + + if report.Tasks.Total != 3 { + t.Errorf("Tasks.Total = %d, want 3", report.Tasks.Total) + } + if report.Tasks.ByType["claude-code"] != 2 { + t.Errorf("Tasks.ByType[claude-code] = %d, want 2", report.Tasks.ByType["claude-code"]) + } + if report.Tasks.ByType["codex"] != 1 { + t.Errorf("Tasks.ByType[codex] = %d, want 1", report.Tasks.ByType["codex"]) + } + if report.Tasks.ByPhase["Succeeded"] != 2 { + t.Errorf("Tasks.ByPhase[Succeeded] = %d, want 2", report.Tasks.ByPhase["Succeeded"]) + } + if report.Tasks.ByPhase["Failed"] != 1 { + t.Errorf("Tasks.ByPhase[Failed] = %d, want 1", report.Tasks.ByPhase["Failed"]) + } + + if report.Usage.TotalCostUSD != 2.0 { + t.Errorf("Usage.TotalCostUSD = %f, want 2.0", report.Usage.TotalCostUSD) + } + if report.Usage.TotalInputTokens != 1500 { + t.Errorf("Usage.TotalInputTokens = %f, want 1500", report.Usage.TotalInputTokens) + } + if report.Usage.TotalOutputTokens != 300 { + t.Errorf("Usage.TotalOutputTokens = %f, want 300", report.Usage.TotalOutputTokens) + } + + if report.Features.TaskSpawners != 2 { + t.Errorf("Features.TaskSpawners = %d, want 2", report.Features.TaskSpawners) + } + if report.Features.AgentConfigs != 1 { + t.Errorf("Features.AgentConfigs = %d, want 1", report.Features.AgentConfigs) + } + if report.Features.Workspaces != 2 { + t.Errorf("Features.Workspaces = %d, want 2", report.Features.Workspaces) + } + + // Source types should be sorted. + if len(report.Features.SourceTypes) != 2 { + t.Fatalf("Features.SourceTypes length = %d, want 2", len(report.Features.SourceTypes)) + } + if report.Features.SourceTypes[0] != "cron" || report.Features.SourceTypes[1] != "github" { + t.Errorf("Features.SourceTypes = %v, want [cron, github]", report.Features.SourceTypes) + } + + // ns-a, ns-b, ns-c from tasks/spawners/workspaces + axon-system from ConfigMap lookup. + if report.Scale.Namespaces != 3 { + t.Errorf("Scale.Namespaces = %d, want 3", report.Scale.Namespaces) + } +} + +func TestCollectEmpty(t *testing.T) { + scheme := newScheme() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: defaultNamespace}, + Data: map[string]string{installationIDKey: "empty-cluster-id"}, + } + + c := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(cm). + Build() + + clientset := k8sfake.NewSimpleClientset() + + report, err := collect(context.Background(), c, clientset) + if err != nil { + t.Fatalf("collect() error: %v", err) + } + + if report.Tasks.Total != 0 { + t.Errorf("Tasks.Total = %d, want 0", report.Tasks.Total) + } + if report.Features.TaskSpawners != 0 { + t.Errorf("Features.TaskSpawners = %d, want 0", report.Features.TaskSpawners) + } + if report.Features.AgentConfigs != 0 { + t.Errorf("Features.AgentConfigs = %d, want 0", report.Features.AgentConfigs) + } + if report.Features.Workspaces != 0 { + t.Errorf("Features.Workspaces = %d, want 0", report.Features.Workspaces) + } + if report.Scale.Namespaces != 0 { + t.Errorf("Scale.Namespaces = %d, want 0", report.Scale.Namespaces) + } + if report.Usage.TotalCostUSD != 0 { + t.Errorf("Usage.TotalCostUSD = %f, want 0", report.Usage.TotalCostUSD) + } +} + +func TestSend(t *testing.T) { + var receivedBody []byte + var receivedContentType string + var receivedUserAgent string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + receivedUserAgent = r.Header.Get("User-Agent") + var err error + receivedBody, err = io.ReadAll(r.Body) + if err != nil { + t.Errorf("reading body: %v", err) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + report := &Report{ + InstallationID: "test-id", + Version: "v0.1.0", + K8sVersion: "v1.28.0", + Tasks: TaskReport{Total: 5, ByType: map[string]int{"claude-code": 5}}, + } + + err := send(context.Background(), server.URL, report) + if err != nil { + t.Fatalf("send() error: %v", err) + } + + if receivedContentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", receivedContentType, "application/json") + } + wantUA := "axon-telemetry/" + version.Version + if receivedUserAgent != wantUA { + t.Errorf("User-Agent = %q, want %q", receivedUserAgent, wantUA) + } + + var decoded Report + if err := json.Unmarshal(receivedBody, &decoded); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if decoded.InstallationID != "test-id" { + t.Errorf("InstallationID = %q, want %q", decoded.InstallationID, "test-id") + } + if decoded.Tasks.Total != 5 { + t.Errorf("Tasks.Total = %d, want 5", decoded.Tasks.Total) + } +} + +func TestSendFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + report := &Report{InstallationID: "test-id"} + + err := send(context.Background(), server.URL, report) + if err == nil { + t.Fatal("send() expected error for 500 response, got nil") + } +} + +func TestGetOrCreateInstallationID(t *testing.T) { + scheme := newScheme() + + t.Run("creates new ConfigMap", func(t *testing.T) { + c := clientfake.NewClientBuilder().WithScheme(scheme).Build() + + id, err := getOrCreateInstallationID(context.Background(), c, defaultNamespace) + if err != nil { + t.Fatalf("getOrCreateInstallationID() error: %v", err) + } + if id == "" { + t.Fatal("expected non-empty installation ID") + } + + // Verify ConfigMap was created. + var cm corev1.ConfigMap + if err := c.Get(context.Background(), types.NamespacedName{Name: configMapName, Namespace: defaultNamespace}, &cm); err != nil { + t.Fatalf("getting configmap: %v", err) + } + if cm.Data[installationIDKey] != id { + t.Errorf("ConfigMap ID = %q, want %q", cm.Data[installationIDKey], id) + } + }) + + t.Run("returns existing ID", func(t *testing.T) { + existingCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: defaultNamespace}, + Data: map[string]string{installationIDKey: "existing-uuid"}, + } + c := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(existingCM). + Build() + + id, err := getOrCreateInstallationID(context.Background(), c, defaultNamespace) + if err != nil { + t.Fatalf("getOrCreateInstallationID() error: %v", err) + } + if id != "existing-uuid" { + t.Errorf("ID = %q, want %q", id, "existing-uuid") + } + }) + + t.Run("idempotent reads", func(t *testing.T) { + c := clientfake.NewClientBuilder().WithScheme(scheme).Build() + + id1, err := getOrCreateInstallationID(context.Background(), c, defaultNamespace) + if err != nil { + t.Fatalf("first call error: %v", err) + } + + id2, err := getOrCreateInstallationID(context.Background(), c, defaultNamespace) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if id1 != id2 { + t.Errorf("IDs differ: %q != %q", id1, id2) + } + }) +} + +func TestSourceTypeExtraction(t *testing.T) { + tests := []struct { + name string + when *axonv1alpha1.When + want []string + }{ + { + name: "github only", + when: &axonv1alpha1.When{GitHubIssues: &axonv1alpha1.GitHubIssues{}}, + want: []string{"github"}, + }, + { + name: "cron only", + when: &axonv1alpha1.When{Cron: &axonv1alpha1.Cron{Schedule: "0 * * * *"}}, + want: []string{"cron"}, + }, + { + name: "jira only", + when: &axonv1alpha1.When{Jira: &axonv1alpha1.Jira{BaseURL: "https://example.atlassian.net", Project: "TEST"}}, + want: []string{"jira"}, + }, + { + name: "empty when", + when: &axonv1alpha1.When{}, + want: nil, + }, + { + name: "multiple sources", + when: &axonv1alpha1.When{ + GitHubIssues: &axonv1alpha1.GitHubIssues{}, + Cron: &axonv1alpha1.Cron{Schedule: "0 * * * *"}, + }, + want: []string{"github", "cron"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractSourceTypes(tt.when) + if len(got) != len(tt.want) { + t.Fatalf("extractSourceTypes() = %v, want %v", got, tt.want) + } + for i, v := range got { + if v != tt.want[i] { + t.Errorf("extractSourceTypes()[%d] = %q, want %q", i, v, tt.want[i]) + } + } + }) + } +} + +func TestCollectK8sVersionFallback(t *testing.T) { + scheme := newScheme() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: defaultNamespace}, + Data: map[string]string{installationIDKey: "test-id"}, + } + + c := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(cm). + Build() + + // The fake clientset returns an empty version. Verify we handle it. + clientset := k8sfake.NewSimpleClientset() + fakeDiscovery, ok := clientset.Discovery().(*discoveryfake.FakeDiscovery) + if ok { + fakeDiscovery.FakedServerVersion = nil + } + + report, err := collect(context.Background(), c, clientset) + if err != nil { + t.Fatalf("collect() error: %v", err) + } + + // The fake discovery returns empty version, not an error. + if report.K8sVersion == "" { + t.Error("expected non-empty K8sVersion") + } +} + +func TestRun(t *testing.T) { + scheme := newScheme() + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: defaultNamespace}, + Data: map[string]string{installationIDKey: "run-test-id"}, + } + + c := clientfake.NewClientBuilder(). + WithScheme(scheme). + WithRuntimeObjects(cm). + Build() + + clientset := k8sfake.NewSimpleClientset() + + var receivedReport Report + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &receivedReport) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := Run(context.Background(), c, clientset, server.URL) + if err != nil { + t.Fatalf("Run() error: %v", err) + } + + if receivedReport.InstallationID != "run-test-id" { + t.Errorf("InstallationID = %q, want %q", receivedReport.InstallationID, "run-test-id") + } +}