Merge pull request 'feat: add portable delivery E2E runner' (#45) from feat/oci-e2e-runner into main
Reviewed-on: #45
This commit is contained in:
commit
d6333d9775
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
15
Dockerfile
Normal 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"]
|
||||
|
|
@ -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
77
cmd/e2e.go
Normal 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
|
||||
}
|
||||
10
cmd/root.go
10
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
docs/e2e.md
Normal file
31
docs/e2e.md
Normal 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.
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
360
internal/e2e/e2e.go
Normal 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
137
internal/e2e/e2e_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue