From 229b0c9b563ab4d6e36d72b829bc387e02f0d36d Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sat, 21 Feb 2026 07:11:49 +0530 Subject: [PATCH 1/2] feat(runners): implement Tekton Pipeline runner with native metadata discovery Implement the TektonPipeline runner with two-tier native metadata discovery and all SupportedRunner interface methods. Tier 1 (always available): reads HOSTNAME env var and ServiceAccount namespace file from the pod filesystem. Tier 2 (best-effort): queries the K8s API for pod labels with tekton.dev/* prefix, providing rich pipeline context. Gracefully degrades when RBAC is denied or SA token is missing. Interface methods: - RunURI: Tekton Dashboard URL when configured, tekton-pipeline:// identifier URI as fallback - Report: writes attestation summary to /tekton/results/ with 3500-byte truncation for Tekton Results size limits - Environment: detects GKE/EKS/AKS as Managed, plain K8s as SelfHosted - ResolveEnvVars: synthesizes discovered metadata as key-value entries - ListEnvVars: returns HOSTNAME as the only consumed env var Includes 26 unit tests covering discovery success, RBAC denial, missing SA token, all RunURI variants, Report truncation, Environment detection, and ResolveEnvVars with full and minimal label sets. Signed-off-by: Vibhav Bobade --- pkg/attestation/crafter/runner.go | 4 +- .../crafter/runners/tektonpipeline.go | 352 +++++++++- .../crafter/runners/tektonpipeline_test.go | 650 ++++++++++++++++++ 3 files changed, 998 insertions(+), 8 deletions(-) create mode 100644 pkg/attestation/crafter/runners/tektonpipeline_test.go diff --git a/pkg/attestation/crafter/runner.go b/pkg/attestation/crafter/runner.go index 07eaaf38f..47b491824 100644 --- a/pkg/attestation/crafter/runner.go +++ b/pkg/attestation/crafter/runner.go @@ -96,8 +96,8 @@ var RunnerFactories = map[schemaapi.CraftingSchema_Runner_RunnerType]RunnerFacto schemaapi.CraftingSchema_Runner_TEAMCITY_PIPELINE: func(_ string, _ *zerolog.Logger) SupportedRunner { return runners.NewTeamCityPipeline() }, - schemaapi.CraftingSchema_Runner_TEKTON_PIPELINE: func(_ string, _ *zerolog.Logger) SupportedRunner { - return runners.NewTektonPipeline() + schemaapi.CraftingSchema_Runner_TEKTON_PIPELINE: func(_ string, logger *zerolog.Logger) SupportedRunner { + return runners.NewTektonPipeline(logger) }, } diff --git a/pkg/attestation/crafter/runners/tektonpipeline.go b/pkg/attestation/crafter/runners/tektonpipeline.go index cbab19d40..adfd44d07 100644 --- a/pkg/attestation/crafter/runners/tektonpipeline.go +++ b/pkg/attestation/crafter/runners/tektonpipeline.go @@ -17,16 +17,123 @@ package runners import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" "os" + "path/filepath" + "strings" + "time" schemaapi "github.com/chainloop-dev/chainloop/app/controlplane/api/workflowcontract/v1" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/runners/commitverification" + "github.com/rs/zerolog" ) -type TektonPipeline struct{} +// Default paths for Kubernetes service account credentials +const ( + defaultSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + defaultSANamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + defaultSACACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" +) + +// Constants for Report() — writing attestation output to Tekton Results +const ( + defaultResultsDir = "/tekton/results" + tektonReportResultName = "attestation-report" + maxTektonResultSize = 3500 +) + +// podMetadata is a minimal struct for parsing K8s API pod response. +// Only the metadata.labels field is needed for Tekton label discovery. +type podMetadata struct { + Metadata struct { + Labels map[string]string `json:"labels"` + } `json:"metadata"` +} + +// TektonPipeline implements the SupportedRunner interface for Tekton Pipeline environments. +// It discovers Tekton metadata natively using a two-tier approach: +// - Tier 1: HOSTNAME env var and SA namespace file (always available in K8s pods) +// - Tier 2: K8s API pod labels for rich tekton.dev/* metadata (best-effort) +type TektonPipeline struct { + logger *zerolog.Logger + podName string // from HOSTNAME env var + namespace string // from /var/run/secrets/kubernetes.io/serviceaccount/namespace + labels map[string]string // tekton.dev/* labels from K8s API + httpClient *http.Client // injectable for testing + resultsDir string // default: "/tekton/results", injectable via WithResultsDir for testing + + // Injectable file paths for testing (defaults set in constructor) + saTokenPath string + saNamespacePath string + saCACertPath string +} + +// TektonPipelineOption is a functional option for configuring TektonPipeline. +type TektonPipelineOption func(*TektonPipeline) + +// WithHTTPClient sets a custom HTTP client for K8s API calls. +// This is primarily used for testing with httptest.NewTLSServer. +func WithHTTPClient(client *http.Client) TektonPipelineOption { + return func(t *TektonPipeline) { t.httpClient = client } +} + +// WithSATokenPath overrides the default service account token file path. +func WithSATokenPath(path string) TektonPipelineOption { + return func(t *TektonPipeline) { t.saTokenPath = path } +} + +// WithNamespacePath overrides the default service account namespace file path. +func WithNamespacePath(path string) TektonPipelineOption { + return func(t *TektonPipeline) { t.saNamespacePath = path } +} -func NewTektonPipeline() *TektonPipeline { - return &TektonPipeline{} +// WithCACertPath overrides the default service account CA certificate file path. +func WithCACertPath(path string) TektonPipelineOption { + return func(t *TektonPipeline) { t.saCACertPath = path } +} + +// WithResultsDir overrides the default Tekton Results directory path. +// This is primarily used for testing Report() without requiring /tekton/results. +func WithResultsDir(dir string) TektonPipelineOption { + return func(t *TektonPipeline) { t.resultsDir = dir } +} + +// NewTektonPipeline creates a new TektonPipeline runner with two-tier native metadata discovery. +// The logger is required for debug-level logging of discovery failures. +// Functional options allow injecting test dependencies. +func NewTektonPipeline(logger *zerolog.Logger, opts ...TektonPipelineOption) *TektonPipeline { + r := &TektonPipeline{ + logger: logger, + labels: make(map[string]string), + saTokenPath: defaultSATokenPath, + saNamespacePath: defaultSANamespacePath, + saCACertPath: defaultSACACertPath, + resultsDir: defaultResultsDir, + } + + // Apply functional options before discovery (allows test injection) + for _, opt := range opts { + opt(r) + } + + // Tier 1: Always-available sources (no configuration required) + r.podName = os.Getenv("HOSTNAME") + + if nsBytes, err := os.ReadFile(r.saNamespacePath); err == nil { + r.namespace = strings.TrimSpace(string(nsBytes)) + } else { + r.logger.Debug().Err(err).Msg("cannot read namespace file, namespace will be empty") + } + + // Tier 2: K8s API for pod labels (best-effort, logs failures at debug level) + r.discoverLabelsFromKubeAPI() + + return r } func (r *TektonPipeline) ID() schemaapi.CraftingSchema_Runner_RunnerType { @@ -44,15 +151,83 @@ func (r *TektonPipeline) CheckEnv() bool { } func (r *TektonPipeline) ListEnvVars() []*EnvVarDefinition { - return []*EnvVarDefinition{} + // Tekton does not inject CI-specific env vars natively. + // All metadata is discovered from K8s API labels and filesystem. + // Return HOSTNAME as the only env var we consume (for traceability in attestation). + return []*EnvVarDefinition{ + {"HOSTNAME", true}, + } } func (r *TektonPipeline) RunURI() string { + taskRunName := r.labels["tekton.dev/taskRun"] + pipelineRunName := r.labels["tekton.dev/pipelineRun"] + + // Fallback: derive TaskRun name from HOSTNAME if K8s API labels unavailable + if taskRunName == "" { + taskRunName = r.taskRunNameFromHostname() + } + + // Check for dashboard URL (opportunistic -- NOT required, NOT part of env var contract) + dashboardURL := os.Getenv("TEKTON_DASHBOARD_URL") + if dashboardURL != "" { + dashboardURL = strings.TrimRight(dashboardURL, "/") + // Prefer PipelineRun link if available + if pipelineRunName != "" && r.namespace != "" { + return fmt.Sprintf("%s/#/namespaces/%s/pipelineruns/%s", + dashboardURL, r.namespace, pipelineRunName) + } + if taskRunName != "" && r.namespace != "" { + return fmt.Sprintf("%s/#/namespaces/%s/taskruns/%s", + dashboardURL, r.namespace, taskRunName) + } + } + + // Fallback: construct a non-HTTP identifier URI for traceability + if pipelineRunName != "" && r.namespace != "" { + return fmt.Sprintf("tekton-pipeline://%s/pipelineruns/%s", r.namespace, pipelineRunName) + } + if taskRunName != "" && r.namespace != "" { + return fmt.Sprintf("tekton-pipeline://%s/taskruns/%s", r.namespace, taskRunName) + } + return "" } +// ResolveEnvVars returns internally-discovered metadata as key-value entries. +// Unlike other runners, this does NOT delegate to resolveEnvVars(r.ListEnvVars()) +// because the real metadata comes from K8s API labels and filesystem, not from env vars. +// The returned keys (TEKTON_TASKRUN_NAME, etc.) are synthesized from discovered labels -- +// they are NOT actual environment variables in the container. func (r *TektonPipeline) ResolveEnvVars() (map[string]string, []*error) { - return resolveEnvVars(r.ListEnvVars()) + resolved := make(map[string]string) + + if hostname := os.Getenv("HOSTNAME"); hostname != "" { + resolved["HOSTNAME"] = hostname + } + + // Populate from discovered labels (these appear in attestation's EnvVars for traceability) + if taskRun := r.labels["tekton.dev/taskRun"]; taskRun != "" { + resolved["TEKTON_TASKRUN_NAME"] = taskRun + } + if pipeline := r.labels["tekton.dev/pipeline"]; pipeline != "" { + resolved["TEKTON_PIPELINE_NAME"] = pipeline + } + if pipelineRun := r.labels["tekton.dev/pipelineRun"]; pipelineRun != "" { + resolved["TEKTON_PIPELINERUN_NAME"] = pipelineRun + } + if task := r.labels["tekton.dev/task"]; task != "" { + resolved["TEKTON_TASK_NAME"] = task + } + if pipelineTask := r.labels["tekton.dev/pipelineTask"]; pipelineTask != "" { + resolved["TEKTON_PIPELINE_TASK_NAME"] = pipelineTask + } + if r.namespace != "" { + resolved["TEKTON_NAMESPACE"] = r.namespace + } + + // No errors -- all fields are optional/best-effort + return resolved, nil } func (r *TektonPipeline) WorkflowFilePath() string { @@ -63,7 +238,30 @@ func (r *TektonPipeline) IsAuthenticated() bool { return false } +// Environment detects managed K8s (GKE/EKS/AKS) vs self-hosted via cloud-provider env vars. +// These env vars are genuinely injected by the cloud platform when workload identity is configured, +// NOT by user configuration. Returns SelfHosted for plain K8s and Unknown if not in K8s at all. func (r *TektonPipeline) Environment() RunnerEnvironment { + // GKE with Workload Identity + if os.Getenv("GOOGLE_CLOUD_PROJECT") != "" { + return Managed + } + + // EKS with IRSA/Pod Identity + if os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE") != "" { + return Managed + } + + // AKS with Workload Identity + if os.Getenv("AZURE_FEDERATED_TOKEN_FILE") != "" { + return Managed + } + + // We know we're in K8s (CheckEnv passed), but can't determine managed vs self-hosted + if os.Getenv("KUBERNETES_SERVICE_HOST") != "" { + return SelfHosted + } + return Unknown } @@ -71,6 +269,148 @@ func (r *TektonPipeline) VerifyCommitSignature(_ context.Context, _ string) *com return nil // Not supported for this runner } -func (r *TektonPipeline) Report(_ []byte, _ string) error { +// Report writes attestation summary to Tekton Results with 3500-byte truncation. +// The Tekton Results system has a default max-result-size of 4096 bytes (shared with +// internal metadata), so we truncate at 3500 bytes to leave room for Tekton overhead. +func (r *TektonPipeline) Report(tableOutput []byte, attestationViewURL string) error { + resultPath := filepath.Join(r.resultsDir, tektonReportResultName) + + content := fmt.Sprintf("Chainloop Attestation Report\n\n%s", tableOutput) + if attestationViewURL != "" { + content += fmt.Sprintf("\nView details: %s\n", attestationViewURL) + } + + if len(content) > maxTektonResultSize { + truncateAt := maxTektonResultSize - len("\n... (truncated)") + content = content[:truncateAt] + "\n... (truncated)" + } + + if err := os.WriteFile(resultPath, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write attestation report to Tekton Results: %w", err) + } + return nil } + +// taskRunNameFromHostname derives the TaskRun name from the pod HOSTNAME as a best-effort +// fallback when K8s API labels are unavailable. Tekton names pods as "-pod" +// (or "-pod-retryN" for retries). For long TaskRun names (>59 chars), +// kmeta.ChildName hashes the name, making hostname-to-taskrun derivation unreliable -- +// in that case we return empty string. +func (r *TektonPipeline) taskRunNameFromHostname() string { + hostname := r.podName + if hostname == "" { + return "" + } + // Handle retry suffix: -pod-retryN + if idx := strings.Index(hostname, "-pod-retry"); idx != -1 { + return hostname[:idx] + } + // Normal case: -pod + if strings.HasSuffix(hostname, "-pod") { + return strings.TrimSuffix(hostname, "-pod") + } + // Hashed name or unknown format -- can't reliably parse + return "" +} + +// discoverLabelsFromKubeAPI performs Tier 2 discovery by reading the pod's own labels +// from the Kubernetes API using the service account token. This is best-effort: +// if any step fails (missing SA token, RBAC denied, network error), it logs at debug +// level and returns without error. The runner continues with Tier 1 data only. +func (r *TektonPipeline) discoverLabelsFromKubeAPI() { + // Read SA token + token, err := os.ReadFile(r.saTokenPath) + if err != nil { + r.logger.Debug().Err(err).Msg("cannot read SA token, skipping K8s API discovery") + return + } + + // Read CA cert for TLS verification + caCert, err := os.ReadFile(r.saCACertPath) + if err != nil { + r.logger.Debug().Err(err).Msg("cannot read CA cert, skipping K8s API discovery") + return + } + + // Build TLS config with cluster CA + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + r.logger.Debug().Msg("failed to parse CA cert, skipping K8s API discovery") + return + } + + // Create HTTP client with custom TLS if not injected (tests inject their own) + if r.httpClient == nil { + r.httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, + }, + Timeout: 5 * time.Second, + } + } + + // Build K8s API URL + apiHost := os.Getenv("KUBERNETES_SERVICE_HOST") + apiPort := os.Getenv("KUBERNETES_SERVICE_PORT") + if apiPort == "" { + apiPort = "443" + } + + if apiHost == "" || r.namespace == "" || r.podName == "" { + r.logger.Debug(). + Str("apiHost", apiHost). + Str("namespace", r.namespace). + Str("podName", r.podName). + Msg("missing required fields for K8s API discovery") + return + } + + url := fmt.Sprintf("https://%s:%s/api/v1/namespaces/%s/pods/%s", + apiHost, apiPort, r.namespace, r.podName) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + r.logger.Debug().Err(err).Msg("failed to create K8s API request") + return + } + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(string(token))) + + resp, err := r.httpClient.Do(req) + if err != nil { + r.logger.Debug().Err(err).Msg("K8s API request failed") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + r.logger.Debug().Int("status", resp.StatusCode).Msg("K8s API returned non-200") + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + r.logger.Debug().Err(err).Msg("failed to read K8s API response body") + return + } + + var pod podMetadata + if err := json.Unmarshal(body, &pod); err != nil { + r.logger.Debug().Err(err).Msg("failed to parse pod metadata") + return + } + + // Filter labels with tekton.dev/ prefix + for k, v := range pod.Metadata.Labels { + if strings.HasPrefix(k, "tekton.dev/") { + r.labels[k] = v + } + } + + r.logger.Debug(). + Int("labelCount", len(r.labels)). + Msg("discovered Tekton labels from K8s API") +} diff --git a/pkg/attestation/crafter/runners/tektonpipeline_test.go b/pkg/attestation/crafter/runners/tektonpipeline_test.go new file mode 100644 index 000000000..342d5977a --- /dev/null +++ b/pkg/attestation/crafter/runners/tektonpipeline_test.go @@ -0,0 +1,650 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package runners + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/suite" +) + +type tektonPipelineSuite struct { + suite.Suite +} + +func TestTektonPipelineRunner(t *testing.T) { + suite.Run(t, new(tektonPipelineSuite)) +} + +// newTestLogger creates a disabled logger for testing (no output noise). +func newTestLogger() *zerolog.Logger { + l := zerolog.New(zerolog.Nop()).Level(zerolog.Disabled) + return &l +} + +// writeTempFile creates a file in dir with the given name and content. +// Returns the full path to the created file. +func writeTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + err := os.WriteFile(path, []byte(content), 0600) + if err != nil { + t.Fatalf("failed to write temp file %s: %v", path, err) + } + return path +} + +// extractCACertPEM extracts the CA certificate from an httptest.NewTLSServer +// and returns it as PEM-encoded bytes suitable for writing to a file. +func extractCACertPEM(server *httptest.Server) []byte { + // The test server's TLS config has the certificate + cert := server.TLS.Certificates[0] + // Parse the leaf cert + leaf, _ := x509.ParseCertificate(cert.Certificate[0]) + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: leaf.Raw, + }) +} + +// TestDiscoverLabelsSuccess tests successful K8s API label discovery. +// Verifies that tekton.dev/* labels are extracted and non-tekton labels are filtered out. +func (s *tektonPipelineSuite) TestDiscoverLabelsSuccess() { + t := s.T() + + // Create mock K8s API server that returns pod with Tekton labels + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify the request includes authorization + s.NotEmpty(r.Header.Get("Authorization")) + s.Contains(r.Header.Get("Authorization"), "Bearer ") + + // Return pod metadata with tekton.dev/* labels and a non-tekton label + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{ + "tekton.dev/taskRun": "my-taskrun", + "tekton.dev/pipeline": "my-pipeline", + "tekton.dev/pipelineRun": "my-pipelinerun", + "tekton.dev/task": "my-task", + "tekton.dev/pipelineTask": "build", + "app": "other", // non-tekton label, should be filtered + }, + }, + }) + })) + defer server.Close() + + // Parse server URL to extract host and port + serverURL, err := url.Parse(server.URL) + s.Require().NoError(err) + + // Set env vars for Tier 1 and Tier 2 discovery + t.Setenv("HOSTNAME", "my-taskrun-pod") + t.Setenv("KUBERNETES_SERVICE_HOST", serverURL.Hostname()) + t.Setenv("KUBERNETES_SERVICE_PORT", serverURL.Port()) + + // Create temp directory for SA files + tmpDir := t.TempDir() + + // Write SA token file + tokenPath := writeTempFile(t, tmpDir, "token", "test-sa-token") + + // Write namespace file + nsPath := writeTempFile(t, tmpDir, "namespace", "test-ns") + + // Write CA cert file (extract from test server's TLS cert) + caCertPEM := extractCACertPEM(server) + caCertPath := writeTempFile(t, tmpDir, "ca.crt", string(caCertPEM)) + + // Create runner with injected httpClient (bypasses TLS verification against our self-signed cert) + r := NewTektonPipeline( + newTestLogger(), + WithHTTPClient(server.Client()), + WithSATokenPath(tokenPath), + WithNamespacePath(nsPath), + WithCACertPath(caCertPath), + ) + + // Verify Tier 1 discovery + s.Equal("my-taskrun-pod", r.podName, "podName should be set from HOSTNAME") + s.Equal("test-ns", r.namespace, "namespace should be read from file") + + // Verify Tier 2 discovery: tekton.dev/* labels are populated + s.Equal("my-taskrun", r.labels["tekton.dev/taskRun"]) + s.Equal("my-pipeline", r.labels["tekton.dev/pipeline"]) + s.Equal("my-pipelinerun", r.labels["tekton.dev/pipelineRun"]) + s.Equal("my-task", r.labels["tekton.dev/task"]) + s.Equal("build", r.labels["tekton.dev/pipelineTask"]) + + // Verify non-tekton label is filtered out + _, hasApp := r.labels["app"] + s.False(hasApp, "non-tekton label 'app' should be filtered out") + + // Verify total label count (only tekton.dev/* labels) + s.Len(r.labels, 5, "should have exactly 5 tekton.dev/* labels") +} + +// TestDiscoverLabelsRBACDenied tests graceful degradation when K8s API returns 403 Forbidden. +// Tier 1 data (podName, namespace) should still be populated. Labels should be empty but not nil. +func (s *tektonPipelineSuite) TestDiscoverLabelsRBACDenied() { + t := s.T() + + // Create mock K8s API server that returns 403 Forbidden + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + s.Require().NoError(err) + + t.Setenv("HOSTNAME", "my-taskrun-pod") + t.Setenv("KUBERNETES_SERVICE_HOST", serverURL.Hostname()) + t.Setenv("KUBERNETES_SERVICE_PORT", serverURL.Port()) + + tmpDir := t.TempDir() + tokenPath := writeTempFile(t, tmpDir, "token", "test-sa-token") + nsPath := writeTempFile(t, tmpDir, "namespace", "test-ns") + caCertPEM := extractCACertPEM(server) + caCertPath := writeTempFile(t, tmpDir, "ca.crt", string(caCertPEM)) + + r := NewTektonPipeline( + newTestLogger(), + WithHTTPClient(server.Client()), + WithSATokenPath(tokenPath), + WithNamespacePath(nsPath), + WithCACertPath(caCertPath), + ) + + // Tier 1 data should still be populated despite Tier 2 failure + s.Equal("my-taskrun-pod", r.podName, "podName should be set from HOSTNAME (Tier 1)") + s.Equal("test-ns", r.namespace, "namespace should be read from file (Tier 1)") + + // Labels should be empty map (not nil) -- Tier 2 failed gracefully + s.NotNil(r.labels, "labels should not be nil") + s.Empty(r.labels, "labels should be empty when K8s API returns 403") +} + +// TestDiscoverWithoutSAToken tests graceful degradation when SA token file does not exist. +// K8s API discovery should be skipped entirely. Tier 1 data should still be populated. +func (s *tektonPipelineSuite) TestDiscoverWithoutSAToken() { + t := s.T() + + t.Setenv("HOSTNAME", "my-taskrun-pod") + + tmpDir := t.TempDir() + + // Write namespace file but NOT the SA token file + nsPath := writeTempFile(t, tmpDir, "namespace", "test-ns") + + // Use a non-existent path for SA token + nonExistentTokenPath := filepath.Join(tmpDir, "nonexistent-token") + + r := NewTektonPipeline( + newTestLogger(), + WithSATokenPath(nonExistentTokenPath), + WithNamespacePath(nsPath), + // No CA cert path needed -- won't reach that code + ) + + // Tier 1 data should be populated + s.Equal("my-taskrun-pod", r.podName, "podName should be set from HOSTNAME") + s.Equal("test-ns", r.namespace, "namespace should be read from file") + + // Labels should be empty map (K8s API skipped due to missing SA token) + s.NotNil(r.labels, "labels should not be nil") + s.Empty(r.labels, "labels should be empty when SA token is missing") +} + +// TestDiscoverWithoutNamespaceFile tests behavior when namespace file does not exist. +// Namespace should be empty string. PodName should still be populated from HOSTNAME. +func (s *tektonPipelineSuite) TestDiscoverWithoutNamespaceFile() { + t := s.T() + + t.Setenv("HOSTNAME", "my-taskrun-pod") + + tmpDir := t.TempDir() + + // Use a non-existent path for namespace file + nonExistentNSPath := filepath.Join(tmpDir, "nonexistent-namespace") + + r := NewTektonPipeline( + newTestLogger(), + WithNamespacePath(nonExistentNSPath), + // SA token also non-existent, so K8s API will be skipped + WithSATokenPath(filepath.Join(tmpDir, "nonexistent-token")), + ) + + // PodName from HOSTNAME should still work + s.Equal("my-taskrun-pod", r.podName, "podName should be set from HOSTNAME") + + // Namespace should be empty since file doesn't exist + s.Empty(r.namespace, "namespace should be empty when file is missing") + + // Labels should be empty (K8s API not called due to missing token) + s.NotNil(r.labels, "labels should not be nil") + s.Empty(r.labels, "labels should be empty") +} + +// TestCheckEnvTektonPresent tests that CheckEnv returns true when /tekton/results exists. +// Note: CheckEnv hardcodes the /tekton/results path, so this test creates a temp directory +// structure but cannot override the path. We test the false case (always works outside Tekton) +// and document the limitation for the true case. +func (s *tektonPipelineSuite) TestCheckEnvTektonPresent() { + t := s.T() + + tmpDir := t.TempDir() + + r := NewTektonPipeline( + newTestLogger(), + WithSATokenPath(filepath.Join(tmpDir, "nonexistent-token")), + WithNamespacePath(filepath.Join(tmpDir, "nonexistent-namespace")), + ) + + // Outside a Tekton environment, /tekton/results does not exist + // so CheckEnv should return false + s.False(r.CheckEnv(), "CheckEnv should return false when /tekton/results does not exist") + + // Note: Testing the true case would require either: + // (a) Making the results path injectable (like SA paths), or + // (b) Actually creating /tekton/results (requires root privileges) + // The false case validates that the directory check logic works correctly. + // The true case is validated by the existing integration test environment. +} + +// TestRunnerID verifies the runner returns the correct ID. +func (s *tektonPipelineSuite) TestRunnerID() { + t := s.T() + tmpDir := t.TempDir() + + r := NewTektonPipeline( + newTestLogger(), + WithSATokenPath(filepath.Join(tmpDir, "nonexistent-token")), + WithNamespacePath(filepath.Join(tmpDir, "nonexistent-namespace")), + ) + + s.Equal("TEKTON_PIPELINE", r.ID().String()) +} + +// TestListEnvVars verifies that ListEnvVars returns minimal list with HOSTNAME only. +// HOSTNAME is marked optional because discovery is best-effort. +func (s *tektonPipelineSuite) TestListEnvVars() { + t := s.T() + tmpDir := t.TempDir() + + r := NewTektonPipeline( + newTestLogger(), + WithSATokenPath(filepath.Join(tmpDir, "nonexistent-token")), + WithNamespacePath(filepath.Join(tmpDir, "nonexistent-namespace")), + ) + + envVars := r.ListEnvVars() + s.Require().Len(envVars, 1, "ListEnvVars should return exactly 1 entry") + s.Equal("HOSTNAME", envVars[0].Name) + s.True(envVars[0].Optional, "HOSTNAME should be optional") +} + +// ============================================================================ +// taskRunNameFromHostname tests +// ============================================================================ + +// TestTaskRunNameFromHostname tests hostname-to-taskrun-name derivation with a table-driven approach. +func (s *tektonPipelineSuite) TestTaskRunNameFromHostname() { + t := s.T() + + tests := []struct { + hostname string + expected string + desc string + }{ + {"my-taskrun-pod", "my-taskrun", "Normal case: strip -pod suffix"}, + {"build-images-pod", "build-images", "Multi-dash name"}, + {"my-taskrun-pod-retry1", "my-taskrun", "Retry suffix (single digit)"}, + {"my-taskrun-pod-retry12", "my-taskrun", "Retry suffix (double digit)"}, + {"", "", "Empty hostname"}, + {"abc123def456", "", "Hashed name (no -pod suffix)"}, + } + + for _, tt := range tests { + s.Run(tt.desc, func() { + tmpDir := t.TempDir() + r := &TektonPipeline{ + logger: newTestLogger(), + podName: tt.hostname, + labels: make(map[string]string), + } + _ = tmpDir // just to avoid unused warning + s.Equal(tt.expected, r.taskRunNameFromHostname(), tt.desc) + }) + } +} + +// ============================================================================ +// RunURI tests +// ============================================================================ + +// TestRunURIWithDashboardAndPipelineRun tests RunURI with dashboard URL and PipelineRun label. +// PipelineRun link should be preferred over TaskRun. +func (s *tektonPipelineSuite) TestRunURIWithDashboardAndPipelineRun() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "https://dashboard.example.com") + + r := &TektonPipeline{ + logger: newTestLogger(), + namespace: "default", + labels: map[string]string{ + "tekton.dev/taskRun": "tr1", + "tekton.dev/pipelineRun": "pr1", + }, + } + + s.Equal("https://dashboard.example.com/#/namespaces/default/pipelineruns/pr1", r.RunURI()) +} + +// TestRunURIWithDashboardTaskRunOnly tests RunURI with dashboard URL but no PipelineRun. +// Should use TaskRun link. Also tests trailing slash trimming on dashboard URL. +func (s *tektonPipelineSuite) TestRunURIWithDashboardTaskRunOnly() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "https://dashboard.example.com/") + + r := &TektonPipeline{ + logger: newTestLogger(), + namespace: "default", + labels: map[string]string{ + "tekton.dev/taskRun": "tr1", + }, + } + + s.Equal("https://dashboard.example.com/#/namespaces/default/taskruns/tr1", r.RunURI()) +} + +// TestRunURINoDashboardWithLabels tests RunURI without dashboard URL but with labels. +// Should return tekton-pipeline:// identifier URI with PipelineRun preferred. +func (s *tektonPipelineSuite) TestRunURINoDashboardWithLabels() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "") + + r := &TektonPipeline{ + logger: newTestLogger(), + namespace: "ci", + labels: map[string]string{ + "tekton.dev/taskRun": "tr1", + "tekton.dev/pipelineRun": "pr1", + }, + } + + s.Equal("tekton-pipeline://ci/pipelineruns/pr1", r.RunURI()) +} + +// TestRunURINoDashboardTaskRunOnly tests RunURI without dashboard URL and no PipelineRun. +// Should return tekton-pipeline:// URI with TaskRun. +func (s *tektonPipelineSuite) TestRunURINoDashboardTaskRunOnly() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "") + + r := &TektonPipeline{ + logger: newTestLogger(), + namespace: "ci", + labels: map[string]string{ + "tekton.dev/taskRun": "tr1", + }, + } + + s.Equal("tekton-pipeline://ci/taskruns/tr1", r.RunURI()) +} + +// TestRunURIFallbackToHostname tests RunURI with no labels -- derives TaskRun name from HOSTNAME. +func (s *tektonPipelineSuite) TestRunURIFallbackToHostname() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "") + + r := &TektonPipeline{ + logger: newTestLogger(), + podName: "my-taskrun-pod", + namespace: "default", + labels: make(map[string]string), + } + + s.Equal("tekton-pipeline://default/taskruns/my-taskrun", r.RunURI()) +} + +// TestRunURIEmpty tests RunURI when no labels, no parseable hostname, and no namespace. +// Should return empty string. +func (s *tektonPipelineSuite) TestRunURIEmpty() { + t := s.T() + t.Setenv("TEKTON_DASHBOARD_URL", "") + + r := &TektonPipeline{ + logger: newTestLogger(), + podName: "abc123", + namespace: "", + labels: make(map[string]string), + } + + s.Equal("", r.RunURI()) +} + +// ============================================================================ +// Report tests +// ============================================================================ + +// TestReportWritesFile tests that Report writes a file with the expected content. +func (s *tektonPipelineSuite) TestReportWritesFile() { + t := s.T() + tmpDir := t.TempDir() + + r := &TektonPipeline{ + logger: newTestLogger(), + labels: make(map[string]string), + resultsDir: tmpDir, + } + + err := r.Report([]byte("table output"), "https://app.chainloop.dev/att/123") + s.Require().NoError(err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "attestation-report")) + s.Require().NoError(err) + + s.Contains(string(content), "Chainloop Attestation Report") + s.Contains(string(content), "table output") + s.Contains(string(content), "View details: https://app.chainloop.dev/att/123") +} + +// TestReportTruncation tests that Report truncates oversized content at 3500 bytes. +func (s *tektonPipelineSuite) TestReportTruncation() { + t := s.T() + tmpDir := t.TempDir() + + r := &TektonPipeline{ + logger: newTestLogger(), + labels: make(map[string]string), + resultsDir: tmpDir, + } + + // Create a 4000-byte table output + largeTable := []byte(strings.Repeat("x", 4000)) + err := r.Report(largeTable, "") + s.Require().NoError(err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "attestation-report")) + s.Require().NoError(err) + + s.LessOrEqual(len(content), maxTektonResultSize, "Report content should not exceed maxTektonResultSize") + s.True(strings.HasSuffix(string(content), "\n... (truncated)"), "Truncated report should end with truncation marker") +} + +// TestReportNoURL tests that Report works without an attestation URL. +func (s *tektonPipelineSuite) TestReportNoURL() { + t := s.T() + tmpDir := t.TempDir() + + r := &TektonPipeline{ + logger: newTestLogger(), + labels: make(map[string]string), + resultsDir: tmpDir, + } + + err := r.Report([]byte("table"), "") + s.Require().NoError(err) + + content, err := os.ReadFile(filepath.Join(tmpDir, "attestation-report")) + s.Require().NoError(err) + + s.NotContains(string(content), "View details", "Report without URL should not contain 'View details'") +} + +// TestReportMissingDir tests that Report returns an error when results directory doesn't exist. +func (s *tektonPipelineSuite) TestReportMissingDir() { + r := &TektonPipeline{ + logger: newTestLogger(), + labels: make(map[string]string), + resultsDir: "/nonexistent/path/that/does/not/exist", + } + + err := r.Report([]byte("table"), "") + s.Error(err, "Report should return error when results directory doesn't exist") + s.Contains(err.Error(), "failed to write attestation report to Tekton Results") +} + +// ============================================================================ +// Environment tests +// ============================================================================ + +// TestEnvironmentGKE tests Environment returns Managed for GKE with Workload Identity. +func (s *tektonPipelineSuite) TestEnvironmentGKE() { + t := s.T() + t.Setenv("GOOGLE_CLOUD_PROJECT", "my-project") + // Ensure other cloud vars are unset + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "") + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + + r := &TektonPipeline{logger: newTestLogger(), labels: make(map[string]string)} + s.Equal(Managed, r.Environment()) +} + +// TestEnvironmentEKS tests Environment returns Managed for EKS with IRSA. +func (s *tektonPipelineSuite) TestEnvironmentEKS() { + t := s.T() + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") + // Ensure other cloud vars are unset + t.Setenv("GOOGLE_CLOUD_PROJECT", "") + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + + r := &TektonPipeline{logger: newTestLogger(), labels: make(map[string]string)} + s.Equal(Managed, r.Environment()) +} + +// TestEnvironmentAKS tests Environment returns Managed for AKS with Workload Identity. +func (s *tektonPipelineSuite) TestEnvironmentAKS() { + t := s.T() + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "/var/run/secrets/azure/tokens/azure-identity-token") + // Ensure other cloud vars are unset + t.Setenv("GOOGLE_CLOUD_PROJECT", "") + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "") + + r := &TektonPipeline{logger: newTestLogger(), labels: make(map[string]string)} + s.Equal(Managed, r.Environment()) +} + +// TestEnvironmentSelfHosted tests Environment returns SelfHosted for plain K8s. +func (s *tektonPipelineSuite) TestEnvironmentSelfHosted() { + t := s.T() + t.Setenv("KUBERNETES_SERVICE_HOST", "10.0.0.1") + // Ensure cloud vars are unset + t.Setenv("GOOGLE_CLOUD_PROJECT", "") + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "") + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + + r := &TektonPipeline{logger: newTestLogger(), labels: make(map[string]string)} + s.Equal(SelfHosted, r.Environment()) +} + +// TestEnvironmentUnknown tests Environment returns Unknown when no K8s env vars present. +func (s *tektonPipelineSuite) TestEnvironmentUnknown() { + t := s.T() + t.Setenv("GOOGLE_CLOUD_PROJECT", "") + t.Setenv("AWS_WEB_IDENTITY_TOKEN_FILE", "") + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + t.Setenv("KUBERNETES_SERVICE_HOST", "") + + r := &TektonPipeline{logger: newTestLogger(), labels: make(map[string]string)} + s.Equal(Unknown, r.Environment()) +} + +// ============================================================================ +// ResolveEnvVars tests +// ============================================================================ + +// TestResolveEnvVarsWithLabels tests ResolveEnvVars with full label set. +// All discovered metadata should be returned as key-value entries. +func (s *tektonPipelineSuite) TestResolveEnvVarsWithLabels() { + t := s.T() + t.Setenv("HOSTNAME", "my-taskrun-pod") + + r := &TektonPipeline{ + logger: newTestLogger(), + namespace: "ci", + labels: map[string]string{ + "tekton.dev/taskRun": "my-taskrun", + "tekton.dev/pipeline": "my-pipeline", + "tekton.dev/pipelineRun": "my-pipelinerun", + "tekton.dev/task": "my-task", + "tekton.dev/pipelineTask": "build", + }, + } + + resolved, errs := r.ResolveEnvVars() + s.Nil(errs, "ResolveEnvVars should not return errors") + s.Equal("my-taskrun-pod", resolved["HOSTNAME"]) + s.Equal("my-taskrun", resolved["TEKTON_TASKRUN_NAME"]) + s.Equal("my-pipeline", resolved["TEKTON_PIPELINE_NAME"]) + s.Equal("my-pipelinerun", resolved["TEKTON_PIPELINERUN_NAME"]) + s.Equal("my-task", resolved["TEKTON_TASK_NAME"]) + s.Equal("build", resolved["TEKTON_PIPELINE_TASK_NAME"]) + s.Equal("ci", resolved["TEKTON_NAMESPACE"]) + s.Len(resolved, 7, "should have 7 entries with full label set") +} + +// TestResolveEnvVarsMinimal tests ResolveEnvVars with no labels (Tier 1 only). +// Only HOSTNAME and TEKTON_NAMESPACE should be present. +func (s *tektonPipelineSuite) TestResolveEnvVarsMinimal() { + t := s.T() + t.Setenv("HOSTNAME", "my-taskrun-pod") + + r := &TektonPipeline{ + logger: newTestLogger(), + podName: "my-taskrun-pod", + namespace: "default", + labels: make(map[string]string), + } + + resolved, errs := r.ResolveEnvVars() + s.Nil(errs, "ResolveEnvVars should not return errors") + s.Equal("my-taskrun-pod", resolved["HOSTNAME"]) + s.Equal("default", resolved["TEKTON_NAMESPACE"]) + s.Len(resolved, 2, "should have 2 entries with minimal data") +} From a81e1d5131757c61744e27590afec4b99df37caf Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Tue, 24 Feb 2026 17:59:18 +0530 Subject: [PATCH 2/2] refactor(runners): use tekton:// URI scheme instead of tekton-pipeline:// Shorten the RunURI scheme from tekton-pipeline:// to tekton:// for consistency and brevity. Signed-off-by: Vibhav Bobade --- pkg/attestation/crafter/runners/tektonpipeline.go | 4 ++-- pkg/attestation/crafter/runners/tektonpipeline_test.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/attestation/crafter/runners/tektonpipeline.go b/pkg/attestation/crafter/runners/tektonpipeline.go index adfd44d07..d0cd44e09 100644 --- a/pkg/attestation/crafter/runners/tektonpipeline.go +++ b/pkg/attestation/crafter/runners/tektonpipeline.go @@ -185,10 +185,10 @@ func (r *TektonPipeline) RunURI() string { // Fallback: construct a non-HTTP identifier URI for traceability if pipelineRunName != "" && r.namespace != "" { - return fmt.Sprintf("tekton-pipeline://%s/pipelineruns/%s", r.namespace, pipelineRunName) + return fmt.Sprintf("tekton://%s/pipelineruns/%s", r.namespace, pipelineRunName) } if taskRunName != "" && r.namespace != "" { - return fmt.Sprintf("tekton-pipeline://%s/taskruns/%s", r.namespace, taskRunName) + return fmt.Sprintf("tekton://%s/taskruns/%s", r.namespace, taskRunName) } return "" diff --git a/pkg/attestation/crafter/runners/tektonpipeline_test.go b/pkg/attestation/crafter/runners/tektonpipeline_test.go index 342d5977a..43e38f9a9 100644 --- a/pkg/attestation/crafter/runners/tektonpipeline_test.go +++ b/pkg/attestation/crafter/runners/tektonpipeline_test.go @@ -383,7 +383,7 @@ func (s *tektonPipelineSuite) TestRunURIWithDashboardTaskRunOnly() { } // TestRunURINoDashboardWithLabels tests RunURI without dashboard URL but with labels. -// Should return tekton-pipeline:// identifier URI with PipelineRun preferred. +// Should return tekton:// identifier URI with PipelineRun preferred. func (s *tektonPipelineSuite) TestRunURINoDashboardWithLabels() { t := s.T() t.Setenv("TEKTON_DASHBOARD_URL", "") @@ -397,11 +397,11 @@ func (s *tektonPipelineSuite) TestRunURINoDashboardWithLabels() { }, } - s.Equal("tekton-pipeline://ci/pipelineruns/pr1", r.RunURI()) + s.Equal("tekton://ci/pipelineruns/pr1", r.RunURI()) } // TestRunURINoDashboardTaskRunOnly tests RunURI without dashboard URL and no PipelineRun. -// Should return tekton-pipeline:// URI with TaskRun. +// Should return tekton:// URI with TaskRun. func (s *tektonPipelineSuite) TestRunURINoDashboardTaskRunOnly() { t := s.T() t.Setenv("TEKTON_DASHBOARD_URL", "") @@ -414,7 +414,7 @@ func (s *tektonPipelineSuite) TestRunURINoDashboardTaskRunOnly() { }, } - s.Equal("tekton-pipeline://ci/taskruns/tr1", r.RunURI()) + s.Equal("tekton://ci/taskruns/tr1", r.RunURI()) } // TestRunURIFallbackToHostname tests RunURI with no labels -- derives TaskRun name from HOSTNAME. @@ -429,7 +429,7 @@ func (s *tektonPipelineSuite) TestRunURIFallbackToHostname() { labels: make(map[string]string), } - s.Equal("tekton-pipeline://default/taskruns/my-taskrun", r.RunURI()) + s.Equal("tekton://default/taskruns/my-taskrun", r.RunURI()) } // TestRunURIEmpty tests RunURI when no labels, no parseable hostname, and no namespace.