feat: add portable delivery E2E runner #45

Merged
eding merged 1 commit from feat/oci-e2e-runner into main 2026-09-13 00:29:18 +02:00
10 changed files with 656 additions and 9 deletions

16
.dockerignore Normal file
View file

@ -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

15
Dockerfile Normal file
View file

@ -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"]

View file

@ -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 <private-config> --organization <new-org> --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 <private-config> --from <app-checkout>` 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.

77
cmd/e2e.go Normal file
View file

@ -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
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
@ -10,12 +11,15 @@ import (
var rootCmd = &cobra.Command{
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)
}
}

31
docs/e2e.md Normal file
View file

@ -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/<run-name> \
--preview-url https://<preview-host>/ \
--preview-sentinel <non-secret-sentinel> \
--promotion-pulls-url https://<forgejo>/api/v1/repos/<owner>/<manifests>/pulls \
--promotion-owner <owner> \
--promotion-head maidn/promotion-<app>-<sha> \
--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.

View file

@ -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) {

View file

@ -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")
}

360
internal/e2e/e2e.go Normal file
View file

@ -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 }}}
}

137
internal/e2e/e2e_test.go Normal file
View file

@ -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")
}
}