blob: 5b9a41b7131cbcf205285979f774aa1dbdc4f35f [file] [log] [blame]
giob7df27f2026-07-28 10:36:17 +04001package e2e
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "time"
16)
17
18const (
19 maxOryArchiveDownloadSize int64 = 512 << 20
20
giof317b892026-07-28 17:53:51 +040021 kratosVersion = "v26.2.0"
22 hydraVersion = "v26.2.0"
giob7df27f2026-07-28 10:36:17 +040023 playwrightVersion = "v0.6100.0"
24 playwrightCLIVersion = "1.61.1"
25 chromiumRevision = "1228"
26 chromiumVersion = "149.0.7827.55"
27 ffmpegRevision = "1011"
28)
29
30type releaseArtifact struct {
31 Service string
32 Version string
33 Archive string
34 SHA256 string
35 URL string
36}
37
38type platformArtifacts struct {
39 Kratos releaseArtifact
40 Hydra releaseArtifact
41}
42
43// Hashes are from the official release checksum manifests:
giof317b892026-07-28 17:53:51 +040044// https://github.com/ory/kratos/releases/download/v26.2.0/checksums.txt
45// https://github.com/ory/hydra/releases/download/v26.2.0/checksums.txt
giob7df27f2026-07-28 10:36:17 +040046var artifactPlatforms = map[string]platformArtifacts{
giof317b892026-07-28 17:53:51 +040047 "linux/amd64": platform("kratos_26.2.0-linux_sqlite_64bit.tar.gz", "c80113f2c861b2fbd80290697d1380000111e297716018617423cca7f2e7e668", "hydra_26.2.0-linux_sqlite_64bit.tar.gz", "28baf98c6d5da617c905d5ca77826fe863db9f9edab8f72664c69a56bde56b5a"),
48 "linux/arm64": platform("kratos_26.2.0-linux_sqlite_arm64.tar.gz", "fd2cedebc4034ad1aef726f64665052c809fb05f86968e9c8a36372d7578e865", "hydra_26.2.0-linux_sqlite_arm64.tar.gz", "18e73ac632d2081b76fbceb03130803a63bdb9b8e2ecc3f06f4a6e6820cabe26"),
49 "darwin/amd64": platform("kratos_26.2.0-macOS_sqlite_64bit.tar.gz", "0ac76d0ad85fe452e711a2aafe56b49b0f5d1a57200f4464dcb48e7267c94062", "hydra_26.2.0-macOS_sqlite_64bit.tar.gz", "ede453fa15c82cfb23e61b138a0b4d9558127a26ebf51eead97d0c58a7eff422"),
50 "darwin/arm64": platform("kratos_26.2.0-macOS_sqlite_arm64.tar.gz", "e198d84bde6e4b7c68714cdad00c8800edd88c2311adde8b08ea5469cea263ec", "hydra_26.2.0-macOS_sqlite_arm64.tar.gz", "d45f26be758196e66e98dff06a9eaefcd21d719123f74f43a0f1fabcfb71da60"),
giob7df27f2026-07-28 10:36:17 +040051}
52
53func platform(kratosArchive, kratosHash, hydraArchive, hydraHash string) platformArtifacts {
54 return platformArtifacts{
55 Kratos: releaseArtifact{"kratos", kratosVersion, kratosArchive, kratosHash, "https://github.com/ory/kratos/releases/download/" + kratosVersion + "/" + kratosArchive},
56 Hydra: releaseArtifact{"hydra", hydraVersion, hydraArchive, hydraHash, "https://github.com/ory/hydra/releases/download/" + hydraVersion + "/" + hydraArchive},
57 }
58}
59
60func artifactsFor(goos, goarch string) (platformArtifacts, error) {
61 p, ok := artifactPlatforms[goos+"/"+goarch]
62 if !ok {
63 return platformArtifacts{}, fmt.Errorf("unsupported E2E platform %s/%s (supported: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64)", goos, goarch)
64 }
65 return p, nil
66}
67
68func repositoryDir() (string, error) {
69 _, source, _, ok := runtime.Caller(0)
70 if !ok {
71 return "", errors.New("locate e2e source file")
72 }
73 dir := filepath.Dir(filepath.Dir(source))
74 if _, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil {
75 return "", fmt.Errorf("locate auth-ui repository: %w", err)
76 }
77 return dir, nil
78}
79
80func cacheRoot(repo string) string {
81 if root := os.Getenv("AUTH_UI_E2E_CACHE_DIR"); root != "" {
82 return root
83 }
84 return filepath.Join(repo, "e2e", "cache")
85}
86
87func archiveCachePath(root string, artifact releaseArtifact) string {
88 return filepath.Join(root, "ory", artifact.Service, artifact.Version, artifact.Archive)
89}
90
91func ensureArchive(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool) (string, error) {
92 return ensureArchiveWithLimit(ctx, client, root, artifact, offline, maxOryArchiveDownloadSize)
93}
94
95func ensureArchiveWithLimit(ctx context.Context, client *http.Client, root string, artifact releaseArtifact, offline bool, maxSize int64) (string, error) {
96 path := archiveCachePath(root, artifact)
97 valid, actual, err := validSHA256(path, artifact.SHA256)
98 if err != nil && !errors.Is(err, os.ErrNotExist) {
99 return "", err
100 }
101 if valid {
102 return path, nil
103 }
104 if offline {
105 return "", fmt.Errorf("offline E2E cache is missing or invalid: %s (expected sha256 %s, got %s); populate it with an online E2E run", path, artifact.SHA256, printableHash(actual))
106 }
107 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
108 return "", err
109 }
110 unlock, err := acquireCacheLock(ctx, path+".lock")
111 if err != nil {
112 return "", fmt.Errorf("lock archive cache %s: %w", path, err)
113 }
114 defer unlock()
115
116 // Another harness process may have published a valid archive while this
117 // caller waited for the lock. Revalidate before removing the invalid path.
118 valid, actual, err = validSHA256(path, artifact.SHA256)
119 if err != nil && !errors.Is(err, os.ErrNotExist) {
120 return "", err
121 }
122 if valid {
123 return path, nil
124 }
125 if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
126 return "", fmt.Errorf("remove invalid cache file: %w", err)
127 }
128
129 req, err := http.NewRequestWithContext(ctx, http.MethodGet, artifact.URL, nil)
130 if err != nil {
131 return "", err
132 }
133 resp, err := client.Do(req)
134 if err != nil {
135 return "", fmt.Errorf("download %s: %w", artifact.Archive, err)
136 }
137 defer resp.Body.Close()
138 if resp.StatusCode != http.StatusOK {
139 _, _ = io.CopyN(io.Discard, resp.Body, 4096)
140 return "", fmt.Errorf("download %s: unexpected HTTP status %s", artifact.Archive, resp.Status)
141 }
142 if resp.ContentLength > maxSize {
143 return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes (content length %d)", artifact.Archive, maxSize, resp.ContentLength)
144 }
145
146 tmp, err := os.CreateTemp(filepath.Dir(path), "."+artifact.Archive+"-*")
147 if err != nil {
148 return "", err
149 }
150 tmpName := tmp.Name()
151 published := false
152 defer func() {
153 _ = tmp.Close()
154 if !published {
155 _ = os.Remove(tmpName)
156 }
157 }()
158 h := sha256.New()
159 written, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(resp.Body, maxSize+1))
160 if err != nil {
161 return "", fmt.Errorf("download %s body: %w", artifact.Archive, err)
162 }
163 if written > maxSize {
164 return "", fmt.Errorf("download %s exceeds maximum archive size %d bytes", artifact.Archive, maxSize)
165 }
166 actual = hex.EncodeToString(h.Sum(nil))
167 if !strings.EqualFold(actual, artifact.SHA256) {
168 return "", fmt.Errorf("download %s checksum mismatch: expected %s, got %s", artifact.Archive, artifact.SHA256, actual)
169 }
170 if err := tmp.Sync(); err != nil {
171 return "", err
172 }
173 if err := tmp.Close(); err != nil {
174 return "", err
175 }
176 if err := os.Rename(tmpName, path); err != nil {
177 // A concurrent writer may have published the same valid archive.
178 if valid, _, checkErr := validSHA256(path, artifact.SHA256); checkErr == nil && valid {
179 return path, nil
180 }
181 return "", fmt.Errorf("publish %s: %w", path, err)
182 }
183 published = true
184 if valid, actual, err := validSHA256(path, artifact.SHA256); err != nil || !valid {
185 _ = os.Remove(path)
186 return "", fmt.Errorf("validate published archive %s: expected %s, got %s: %v", path, artifact.SHA256, actual, err)
187 }
188 return path, nil
189}
190
191func validSHA256(path, expected string) (bool, string, error) {
192 f, err := os.Open(path)
193 if err != nil {
194 return false, "", err
195 }
196 defer f.Close()
197 h := sha256.New()
198 if _, err := io.Copy(h, f); err != nil {
199 return false, "", err
200 }
201 actual := hex.EncodeToString(h.Sum(nil))
202 return strings.EqualFold(actual, expected), actual, nil
203}
204
205func printableHash(hash string) string {
206 if hash == "" {
207 return "missing"
208 }
209 return hash
210}
211
212func downloadClient() *http.Client { return &http.Client{Timeout: 2 * time.Minute} }