From e86732d2198610468169fecc5df31f6fb00284e1 Mon Sep 17 00:00:00 2001 From: eding Date: Sun, 13 Sep 2026 00:19:23 +0200 Subject: [PATCH] feat: add portable delivery E2E runner --- .dockerignore | 16 ++ Dockerfile | 15 ++ README.md | 1 + cmd/e2e.go | 77 ++++++ cmd/root.go | 10 +- docs/e2e.md | 31 +++ internal/bootstrap/bootstrap.go | 8 +- internal/bootstrap/bootstrap_test.go | 10 +- internal/e2e/e2e.go | 360 +++++++++++++++++++++++++++ internal/e2e/e2e_test.go | 137 ++++++++++ 10 files changed, 656 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cmd/e2e.go create mode 100644 docs/e2e.md create mode 100644 internal/e2e/e2e.go create mode 100644 internal/e2e/e2e_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df56121 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.age +.recovery +maidn-bootstrap*.yaml +maidn-workspace +*.sops.yaml +terraform.tfvars +*.tfvars +*.tfstate* +kubeconfig +*.kubeconfig +*.kube +.kube +clusterconfig +*.key +*.pem diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b201609 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.24.0-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/cicd-tool . + +FROM alpine:3.22 +ARG KUBECTL_VERSION=v1.33.4 +RUN apk add --no-cache ca-certificates curl \ + && curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl \ + && chmod 0755 /usr/local/bin/kubectl +COPY --from=build /out/cicd-tool /usr/local/bin/cicd-tool +USER 65532:65532 +ENTRYPOINT ["cicd-tool"] diff --git a/README.md b/README.md index 671c28e..02a6906 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ dlv version - `cicd-tool bootstrap --config maidn-bootstrap.yaml` skips the wizard and uses the saved config - `maidn bootstrap init --config --organization --create-organization --enable-delivery` locks an isolated workspace, initializes the Forgejo repositories, then runs the non-destructive bootstrap reconcile lifecycle; use `--mode=rebuild --yes` for an authorized rebuild. Delivery scaffolding requires `--enable-delivery`. - `maidn app onboard --config --from ` validates a clean configured checkout and adds its `.tekton` delivery contract. +- `cicd-tool e2e` runs bounded, read-only Flux, ExternalSecret, PipelineRun, preview, and promotion-PR checks with JSON output. See `docs/e2e.md`. See `docs/operations.md` for the authorized operating and verification runbook. App authors: see `docs/delivery-feedback.md` for preview feedback and the scoped Forgejo token contract. diff --git a/cmd/e2e.go b/cmd/e2e.go new file mode 100644 index 0000000..3e06259 --- /dev/null +++ b/cmd/e2e.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/Pingu-Studio/MaidnCLI/internal/e2e" + "github.com/spf13/cobra" +) + +var ( + e2eKubeconfig, e2eContext, e2eExternalSecret, e2ePipelineRun string + e2ePreviewURL, e2ePreviewSentinel string + e2ePromotionPullsURL, e2ePromotionOwner, e2ePromotionHead string + e2ePromotionTokenEnv, e2ePromotionTokenFile string + e2eFluxKustomizations []string + e2eTimeout, e2eInterval time.Duration + e2eRunner = e2e.DefaultRunner + errE2EChecks = errors.New("e2e checks failed") +) + +var e2eCmd = &cobra.Command{ + Use: "e2e", + Short: "Run bounded, read-only delivery checks and emit JSON.", + RunE: runE2E, +} + +func init() { + rootCmd.AddCommand(e2eCmd) + e2eCmd.Flags().StringVar(&e2eKubeconfig, "kubeconfig", "", "Path to a read-only kubeconfig") + e2eCmd.Flags().StringVar(&e2eContext, "context", "", "Kubernetes context name") + e2eCmd.Flags().StringSliceVar(&e2eFluxKustomizations, "flux-kustomization", nil, "Flux Kustomization namespace/name (repeatable)") + e2eCmd.Flags().StringVar(&e2eExternalSecret, "external-secret", "", "ExternalSecret namespace/name") + e2eCmd.Flags().StringVar(&e2ePipelineRun, "pipelinerun", "", "PipelineRun namespace/name") + e2eCmd.Flags().StringVar(&e2ePreviewURL, "preview-url", "", "Credential-free preview HTTP(S) URL") + e2eCmd.Flags().StringVar(&e2ePreviewSentinel, "preview-sentinel", "", "Non-secret text expected in the preview response") + e2eCmd.Flags().StringVar(&e2ePromotionPullsURL, "promotion-pulls-url", "", "Credential-free Forgejo pulls API URL without query parameters") + e2eCmd.Flags().StringVar(&e2ePromotionOwner, "promotion-owner", "", "Forgejo owner for the promotion branch") + e2eCmd.Flags().StringVar(&e2ePromotionHead, "promotion-head", "", "Expected promotion branch name") + e2eCmd.Flags().StringVar(&e2ePromotionTokenEnv, "promotion-token-env", "", "Environment variable containing the Forgejo token") + e2eCmd.Flags().StringVar(&e2ePromotionTokenFile, "promotion-token-file", "", "Path to a file containing the Forgejo token") + e2eCmd.Flags().DurationVar(&e2eTimeout, "timeout", 2*time.Minute, "Maximum wait for each check (up to 10m)") + e2eCmd.Flags().DurationVar(&e2eInterval, "interval", 2*time.Second, "Polling interval") + for _, name := range []string{"kubeconfig", "flux-kustomization", "external-secret", "pipelinerun", "preview-url", "preview-sentinel", "promotion-pulls-url", "promotion-owner", "promotion-head"} { + _ = e2eCmd.MarkFlagRequired(name) + } +} + +func runE2E(cmd *cobra.Command, _ []string) error { + token, err := e2e.ReadToken(e2ePromotionTokenEnv, e2ePromotionTokenFile) + if err != nil { + return err + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + result, err := e2eRunner().Run(ctx, e2e.Options{ + Kubeconfig: e2eKubeconfig, Context: e2eContext, FluxKustomizations: e2eFluxKustomizations, + ExternalSecret: e2eExternalSecret, PipelineRun: e2ePipelineRun, + PreviewURL: e2ePreviewURL, PreviewSentinel: e2ePreviewSentinel, + PromotionPullsURL: e2ePromotionPullsURL, PromotionOwner: e2ePromotionOwner, PromotionHead: e2ePromotionHead, + PromotionToken: token, Timeout: e2eTimeout, Interval: e2eInterval, + }) + if err != nil { + return err + } + if err := json.NewEncoder(cmd.OutOrStdout()).Encode(result); err != nil { + return err + } + if !result.Passed { + return errE2EChecks + } + return nil +} diff --git a/cmd/root.go b/cmd/root.go index 6bb6c1f..b67e320 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -8,14 +9,17 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "cicd-tool", - Short: "A CLI tool to manage CI/CD setup for applications.", + Use: "cicd-tool", + Short: "A CLI tool to manage CI/CD setup for applications.", + SilenceErrors: true, } // Execute adds all child commands to the root command and sets flags appropriately. func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Println(err) + if !errors.Is(err, errE2EChecks) { + fmt.Fprintln(os.Stderr, err) + } os.Exit(1) } } diff --git a/docs/e2e.md b/docs/e2e.md new file mode 100644 index 0000000..61ac56d --- /dev/null +++ b/docs/e2e.md @@ -0,0 +1,31 @@ +# OCI E2E runner + +`cicd-tool e2e` is a read-only verifier: it uses `kubectl get` and HTTP GET +only. It never applies resources, reconciles Flux, or calls bootstrap/rebuild. +It emits one JSON result and exits non-zero when a check fails. + +Supply explicit resource identifiers and credential-free URLs. The runner waits +independently (bounded by `--timeout`, maximum ten minutes) for Flux +Kustomizations and an ExternalSecret `Ready=True`, a terminal PipelineRun, +the preview response sentinel, and exactly one open Forgejo promotion PR for +the supplied branch. It reads the Forgejo token only from `--promotion-token-env` +or `--promotion-token-file`; do not pass tokens or credential-bearing URLs. + +```sh +cicd-tool e2e \ + --kubeconfig /run/secrets/kubeconfig \ + --flux-kustomization flux-system/tekton \ + --external-secret tekton-pipelines/forgejo-webhook \ + --pipelinerun tekton-pipelines/ \ + --preview-url https:/// \ + --preview-sentinel \ + --promotion-pulls-url https:///api/v1/repos///pulls \ + --promotion-owner \ + --promotion-head maidn/promotion-- \ + --promotion-token-env FORGEJO_TOKEN +``` + +Build the portable OCI runner with `docker build -t maidn-e2e-runner .`. +Mount the kubeconfig and optional token file read-only; ensure they are readable +by the image's non-root user. The build context excludes known secret-bearing +bootstrap inputs. diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 3cb1485..3af8d10 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -510,9 +510,9 @@ func writePreviewDeliveryConfig(dir string, cfg config.Config) error { Kind: "ConfigMap", Metadata: map[string]string{"name": "maidn-preview-delivery-config", "namespace": "tekton-pipelines"}, Data: map[string]string{ - "forgejo-origin": origin, - "manifests-url": manifestsURL, - "manifests-branch": cfg.Flux.Branch, + "forgejo-origin": origin, + "manifests-url": manifestsURL, + "manifests-branch": cfg.Flux.Branch, "tekton-dashboard-url": cfg.Delivery.TektonDashboardURL, }, }) @@ -669,7 +669,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) { if err := tmpl.Execute(&rendered, values); err != nil { return nil, err } - return rendered.Bytes(), nil + return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil } func deliveryRepository(baseURL, repositoryURL string) (string, error) { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index e2a30a7..8880f03 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -148,8 +148,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing. t.Fatalf("generated delivery does not contain %q", expected) } } - production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):] - production = production[:strings.Index(production, "\n else\n")] + start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`) + if start < 0 { + t.Fatal("generated delivery does not contain a production manifest path") + } + production := rendered[start:] + if end := strings.Index(production, "\n else\n"); end >= 0 { + production = production[:end] + } if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) { t.Fatal("production path pushes directly to manifests main") } diff --git a/internal/e2e/e2e.go b/internal/e2e/e2e.go new file mode 100644 index 0000000..bb33d04 --- /dev/null +++ b/internal/e2e/e2e.go @@ -0,0 +1,360 @@ +// Package e2e contains read-only delivery verification primitives. +package e2e + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +const maxResponseBytes = 1 << 20 + +var ( + dnsLabel = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$`) + forgejoPart = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + branchPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) +) + +type Options struct { + Kubeconfig string + Context string + FluxKustomizations []string + ExternalSecret string + PipelineRun string + PreviewURL string + PreviewSentinel string + PromotionPullsURL string + PromotionOwner string + PromotionHead string + PromotionToken string + Timeout time.Duration + Interval time.Duration +} + +type Check struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +type Result struct { + Passed bool `json:"passed"` + Checks []Check `json:"checks"` +} + +// Command is deliberately small so command boundaries can be faked in tests. +type Command interface { + Output(context.Context, string, ...string) ([]byte, error) +} + +type HTTPDoer interface { + Do(*http.Request) (*http.Response, error) +} + +type Runner struct { + Kubectl Command + HTTP HTTPDoer +} + +type execCommand struct{} + +func (execCommand) Output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).Output() +} + +// ReadToken accepts only a reference to a token, never a token flag. +func ReadToken(environment, path string) (string, error) { + if environment != "" && path != "" { + return "", errors.New("use only one promotion token reference") + } + var token string + if environment != "" { + var present bool + token, present = os.LookupEnv(environment) + if !present { + return "", errors.New("promotion token environment variable is not set") + } + } else if path != "" { + data, err := os.ReadFile(path) + if err != nil { + return "", errors.New("read promotion token file") + } + token = string(data) + } else { + return "", errors.New("a promotion token environment or file reference is required") + } + token = strings.TrimSpace(token) + if token == "" || strings.ContainsAny(token, "\r\n") { + return "", errors.New("promotion token reference is empty or invalid") + } + return token, nil +} + +func (o Options) Validate() error { + if o.Kubeconfig == "" { + return errors.New("kubeconfig path is required") + } + if len(o.FluxKustomizations) == 0 { + return errors.New("at least one Flux Kustomization is required") + } + seen := map[string]bool{} + for _, resource := range o.FluxKustomizations { + if !validNamespacedName(resource) || seen[resource] { + return errors.New("Flux Kustomizations must be unique namespace/name identifiers") + } + seen[resource] = true + } + for _, resource := range []string{o.ExternalSecret, o.PipelineRun} { + if !validNamespacedName(resource) { + return errors.New("ExternalSecret and PipelineRun must be namespace/name identifiers") + } + } + if err := validURL(o.PreviewURL); err != nil { + return fmt.Errorf("preview URL: %w", err) + } + if o.PreviewSentinel == "" { + return errors.New("preview sentinel is required") + } + if err := validURL(o.PromotionPullsURL); err != nil { + return fmt.Errorf("promotion pulls URL: %w", err) + } + if !forgejoPart.MatchString(o.PromotionOwner) || strings.Contains(o.PromotionOwner, "..") { + return errors.New("promotion owner is invalid") + } + if !branchPattern.MatchString(o.PromotionHead) || strings.Contains(o.PromotionHead, "..") || strings.HasPrefix(o.PromotionHead, "/") || strings.HasSuffix(o.PromotionHead, "/") || strings.Contains(o.PromotionHead, "//") { + return errors.New("promotion head is invalid") + } + if o.PromotionToken == "" || strings.ContainsAny(o.PromotionToken, "\r\n") { + return errors.New("promotion token is required") + } + if o.Timeout <= 0 || o.Timeout > 10*time.Minute { + return errors.New("timeout must be between zero and ten minutes") + } + if o.Interval <= 0 || o.Interval > o.Timeout { + return errors.New("interval must be positive and no longer than timeout") + } + return nil +} + +func validNamespacedName(value string) bool { + parts := strings.Split(value, "/") + return len(parts) == 2 && dnsLabel.MatchString(parts[0]) && dnsLabel.MatchString(parts[1]) +} + +func validURL(value string) error { + parsed, err := url.Parse(value) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("must be a credential-free HTTP(S) URL without query or fragment") + } + return nil +} + +// Run executes the fixed read-only verification order. It never applies, +// reconciles, bootstraps, or writes cluster state. +func (r Runner) Run(ctx context.Context, options Options) (Result, error) { + if err := options.Validate(); err != nil { + return Result{}, err + } + if r.Kubectl == nil || r.HTTP == nil { + return Result{}, errors.New("e2e runner dependencies are required") + } + result := Result{Passed: true} + result.add("flux_ready", r.wait(ctx, options, func(ctx context.Context) state { + for _, resource := range options.FluxKustomizations { + if !r.readyCondition(ctx, options, "kustomizations.kustomize.toolkit.fluxcd.io", resource) { + return pending + } + } + return ready + })) + result.add("external_secret_ready", r.wait(ctx, options, func(ctx context.Context) state { + if r.readyCondition(ctx, options, "externalsecrets.external-secrets.io", options.ExternalSecret) { + return ready + } + return pending + })) + result.add("pipeline_run_terminal", r.wait(ctx, options, func(ctx context.Context) state { + return r.pipelineState(ctx, options) + })) + result.add("preview_sentinel", r.wait(ctx, options, func(ctx context.Context) state { + return r.previewState(ctx, options) + })) + result.add("promotion_pr_open", r.wait(ctx, options, func(ctx context.Context) state { + return r.promotionState(ctx, options) + })) + return result, nil +} + +func (r *Result) add(name string, status state) { + check := Check{Name: name, Status: "pass", Detail: "ready"} + if status == failed { + check.Status, check.Detail, r.Passed = "fail", "failed", false + } + if status == timedOut { + check.Status, check.Detail, r.Passed = "fail", "timed_out", false + } + r.Checks = append(r.Checks, check) +} + +type state int + +const ( + pending state = iota + ready + failed + timedOut +) + +func (r Runner) wait(parent context.Context, options Options, probe func(context.Context) state) state { + ctx, cancel := context.WithTimeout(parent, options.Timeout) + defer cancel() + for { + if current := probe(ctx); current != pending { + return current + } + timer := time.NewTimer(options.Interval) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return timedOut + case <-timer.C: + } + } +} + +func (r Runner) readyCondition(ctx context.Context, options Options, kind, resource string) bool { + output, err := r.kubectl(ctx, options, kind, resource) + if err != nil { + return false + } + var value struct { + Status struct { + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"status"` + } + if json.Unmarshal(output, &value) != nil { + return false + } + for _, condition := range value.Status.Conditions { + if condition.Type == "Ready" && condition.Status == "True" { + return true + } + } + return false +} + +func (r Runner) pipelineState(ctx context.Context, options Options) state { + output, err := r.kubectl(ctx, options, "pipelineruns.tekton.dev", options.PipelineRun) + if err != nil { + return pending + } + var value struct { + Status struct { + Conditions []struct { + Type string `json:"type"` + Status string `json:"status"` + } `json:"conditions"` + } `json:"status"` + } + if json.Unmarshal(output, &value) != nil { + return pending + } + for _, condition := range value.Status.Conditions { + if condition.Type != "Succeeded" { + continue + } + switch condition.Status { + case "True": + return ready + case "False": + return failed + } + } + return pending +} + +func (r Runner) previewState(ctx context.Context, options Options) state { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, options.PreviewURL, nil) + if err != nil { + return failed + } + response, err := r.HTTP.Do(request) + if err != nil { + return pending + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return pending + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes)) + if err != nil { + return pending + } + if strings.Contains(string(body), options.PreviewSentinel) { + return ready + } + return pending +} + +func (r Runner) promotionState(ctx context.Context, options Options) state { + endpoint, err := url.Parse(options.PromotionPullsURL) + if err != nil { + return failed + } + query := url.Values{"state": {"open"}, "head": {options.PromotionOwner + ":" + options.PromotionHead}} + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return failed + } + request.Header.Set("Authorization", "token "+options.PromotionToken) + response, err := r.HTTP.Do(request) + if err != nil { + return pending + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return pending + } + var pulls []struct { + State string `json:"state"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes)).Decode(&pulls); err != nil { + return pending + } + if len(pulls) == 0 { + return pending + } + if len(pulls) != 1 || pulls[0].State != "open" { + return failed + } + return ready +} + +func (r Runner) kubectl(ctx context.Context, options Options, kind, resource string) ([]byte, error) { + namespace, name, _ := strings.Cut(resource, "/") + args := []string{"--kubeconfig=" + options.Kubeconfig} + if options.Context != "" { + args = append(args, "--context="+options.Context) + } + args = append(args, "--namespace="+namespace, "get", kind, name, "-o=json") + return r.Kubectl.Output(ctx, "kubectl", args...) +} + +func DefaultRunner() Runner { + return Runner{Kubectl: execCommand{}, HTTP: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} diff --git a/internal/e2e/e2e_test.go b/internal/e2e/e2e_test.go new file mode 100644 index 0000000..a529761 --- /dev/null +++ b/internal/e2e/e2e_test.go @@ -0,0 +1,137 @@ +package e2e + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type fakeCommand struct { + output func(string, []string) ([]byte, error) + calls [][]string +} + +func (f *fakeCommand) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.calls = append(f.calls, append([]string{name}, args...)) + return f.output(name, args) +} + +type fakeHTTP struct { + do func(*http.Request) (*http.Response, error) +} + +func (f fakeHTTP) Do(request *http.Request) (*http.Response, error) { return f.do(request) } + +func response(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)} +} + +func testOptions() Options { + return Options{ + Kubeconfig: "/run/secrets/kubeconfig", + FluxKustomizations: []string{"flux-system/tekton"}, + ExternalSecret: "tekton-pipelines/forgejo-webhook", + PipelineRun: "tekton-pipelines/delivery-1", + PreviewURL: "https://preview.example.test/", + PreviewSentinel: "maidn-e2e-ok", + PromotionPullsURL: "https://git.example.test/api/v1/repos/Maidn/manifests/pulls", + PromotionOwner: "Maidn", + PromotionHead: "maidn/promotion-app-0123456789abcdef0123456789abcdef01234567", + PromotionToken: "test-token", + Timeout: time.Second, + Interval: time.Millisecond, + } +} + +func TestRunUsesReadOnlyBoundariesAndRedactsResponses(t *testing.T) { + kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") { + return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil + } + return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]},"data":"secret-value"}`), nil + }} + http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Path, "/pulls") { + if request.Header.Get("Authorization") != "token test-token" { + t.Fatal("promotion request did not use the supplied token") + } + if got := request.URL.Query().Get("head"); got != "Maidn:maidn/promotion-app-0123456789abcdef0123456789abcdef01234567" { + t.Fatalf("promotion head = %q", got) + } + return response(http.StatusOK, `[{"state":"open","body":"secret-value"}]`), nil + } + return response(http.StatusOK, "maidn-e2e-ok secret-value"), nil + }} + + result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions()) + if err != nil || !result.Passed || len(result.Checks) != 5 { + t.Fatalf("Run() = %#v, %v", result, err) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "secret-value") || strings.Contains(string(encoded), "test-token") { + t.Fatalf("result exposed response data: %s", encoded) + } + for _, call := range kubectl.calls { + joined := strings.Join(call, " ") + if !strings.Contains(joined, " get ") || strings.Contains(joined, "apply") || strings.Contains(joined, "reconcile") { + t.Fatalf("unexpected kubectl invocation: %q", joined) + } + } +} + +func TestRunReportsTerminalPipelineFailureWithoutWaiting(t *testing.T) { + kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") { + return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"False"}]}}`), nil + } + return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil + }} + http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Path, "/pulls") { + return response(http.StatusOK, `[{"state":"open"}]`), nil + } + return response(http.StatusOK, "maidn-e2e-ok"), nil + }} + + result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), testOptions()) + if err != nil || result.Passed || result.Checks[2].Detail != "failed" { + t.Fatalf("Run() = %#v, %v", result, err) + } +} + +func TestRunTimesOutWhenAReadinessConditionNeverArrives(t *testing.T) { + kubectl := &fakeCommand{output: func(_ string, args []string) ([]byte, error) { + if strings.Contains(strings.Join(args, " "), "externalsecrets.external-secrets.io") { + return []byte(`{"status":{"conditions":[{"type":"Ready","status":"False"}]}}`), nil + } + if strings.Contains(strings.Join(args, " "), "pipelineruns.tekton.dev") { + return []byte(`{"status":{"conditions":[{"type":"Succeeded","status":"True"}]}}`), nil + } + return []byte(`{"status":{"conditions":[{"type":"Ready","status":"True"}]}}`), nil + }} + http := fakeHTTP{do: func(request *http.Request) (*http.Response, error) { + if strings.Contains(request.URL.Path, "/pulls") { + return response(http.StatusOK, `[{"state":"open"}]`), nil + } + return response(http.StatusOK, "maidn-e2e-ok"), nil + }} + options := testOptions() + options.Timeout, options.Interval = 5*time.Millisecond, time.Millisecond + result, err := (Runner{Kubectl: kubectl, HTTP: http}).Run(context.Background(), options) + if err != nil || result.Passed || result.Checks[1].Detail != "timed_out" { + t.Fatalf("Run() = %#v, %v", result, err) + } +} + +func TestReadTokenRejectsAmbiguousReferences(t *testing.T) { + if _, err := ReadToken("PROMOTION_TOKEN", "token.txt"); err == nil { + t.Fatal("ReadToken accepted two token references") + } +} -- 2.43.7