Skip to content

Commit 251e16d

Browse files
committed
tools/capver: regenerate from docker tags
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
1 parent 3f0bfe2 commit 251e16d

1 file changed

Lines changed: 203 additions & 81 deletions

File tree

tools/capver/main.go

Lines changed: 203 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package main
33
//go:generate go run main.go
44

55
import (
6+
"context"
67
"encoding/json"
8+
"errors"
79
"fmt"
810
"go/format"
911
"io"
@@ -21,64 +23,211 @@ import (
2123
)
2224

2325
const (
24-
releasesURL = "https://api.github.com/repos/tailscale/tailscale/releases"
25-
rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go"
26-
outputFile = "../../hscontrol/capver/capver_generated.go"
27-
testFile = "../../hscontrol/capver/capver_test_data.go"
28-
minVersionParts = 2
29-
fallbackCapVer = 90
30-
maxTestCases = 4
31-
// TODO(https://github.com/tailscale/tailscale/issues/12849): Restore to 10 when v1.92 is released.
32-
supportedMajorMinorVersions = 9
26+
ghcrTokenURL = "https://ghcr.io/token?service=ghcr.io&scope=repository:tailscale/tailscale:pull" //nolint:gosec
27+
ghcrTagsURL = "https://ghcr.io/v2/tailscale/tailscale/tags/list?n=10000"
28+
rawFileURL = "https://github.com/tailscale/tailscale/raw/refs/tags/%s/tailcfg/tailcfg.go"
29+
outputFile = "../../hscontrol/capver/capver_generated.go"
30+
testFile = "../../hscontrol/capver/capver_test_data.go"
31+
fallbackCapVer = 90
32+
maxTestCases = 4
33+
supportedMajorMinorVersions = 10
3334
filePermissions = 0o600
35+
semverMatchGroups = 4
36+
latest3Count = 3
37+
latest2Count = 2
3438
)
3539

36-
type Release struct {
37-
Name string `json:"name"`
40+
var errUnexpectedStatusCode = errors.New("unexpected status code")
41+
42+
// GHCRTokenResponse represents the response from GHCR token endpoint.
43+
type GHCRTokenResponse struct {
44+
Token string `json:"token"`
45+
}
46+
47+
// GHCRTagsResponse represents the response from GHCR tags list endpoint.
48+
type GHCRTagsResponse struct {
49+
Name string `json:"name"`
50+
Tags []string `json:"tags"`
3851
}
3952

40-
func getCapabilityVersions() (map[string]tailcfg.CapabilityVersion, error) {
41-
// Fetch the releases
42-
resp, err := http.Get(releasesURL)
53+
// getGHCRToken fetches an anonymous token from GHCR for accessing public container images.
54+
func getGHCRToken(ctx context.Context) (string, error) {
55+
client := &http.Client{}
56+
57+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghcrTokenURL, nil)
58+
if err != nil {
59+
return "", fmt.Errorf("error creating token request: %w", err)
60+
}
61+
62+
resp, err := client.Do(req)
4363
if err != nil {
44-
return nil, fmt.Errorf("error fetching releases: %w", err)
64+
return "", fmt.Errorf("error fetching GHCR token: %w", err)
4565
}
4666
defer resp.Body.Close()
4767

68+
if resp.StatusCode != http.StatusOK {
69+
return "", fmt.Errorf("%w: %d", errUnexpectedStatusCode, resp.StatusCode)
70+
}
71+
4872
body, err := io.ReadAll(resp.Body)
4973
if err != nil {
50-
return nil, fmt.Errorf("error reading response body: %w", err)
74+
return "", fmt.Errorf("error reading token response: %w", err)
75+
}
76+
77+
var tokenResp GHCRTokenResponse
78+
79+
err = json.Unmarshal(body, &tokenResp)
80+
if err != nil {
81+
return "", fmt.Errorf("error parsing token response: %w", err)
82+
}
83+
84+
return tokenResp.Token, nil
85+
}
86+
87+
// getGHCRTags fetches all available tags from GHCR for tailscale/tailscale.
88+
func getGHCRTags(ctx context.Context) ([]string, error) {
89+
token, err := getGHCRToken(ctx)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to get GHCR token: %w", err)
5192
}
5293

53-
var releases []Release
94+
client := &http.Client{}
5495

55-
err = json.Unmarshal(body, &releases)
96+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghcrTagsURL, nil)
5697
if err != nil {
57-
return nil, fmt.Errorf("error unmarshalling JSON: %w", err)
98+
return nil, fmt.Errorf("error creating tags request: %w", err)
5899
}
59100

101+
req.Header.Set("Authorization", "Bearer "+token)
102+
103+
resp, err := client.Do(req)
104+
if err != nil {
105+
return nil, fmt.Errorf("error fetching tags: %w", err)
106+
}
107+
defer resp.Body.Close()
108+
109+
if resp.StatusCode != http.StatusOK {
110+
return nil, fmt.Errorf("%w: %d", errUnexpectedStatusCode, resp.StatusCode)
111+
}
112+
113+
body, err := io.ReadAll(resp.Body)
114+
if err != nil {
115+
return nil, fmt.Errorf("error reading tags response: %w", err)
116+
}
117+
118+
var tagsResp GHCRTagsResponse
119+
120+
err = json.Unmarshal(body, &tagsResp)
121+
if err != nil {
122+
return nil, fmt.Errorf("error parsing tags response: %w", err)
123+
}
124+
125+
return tagsResp.Tags, nil
126+
}
127+
128+
// semverRegex matches semantic version tags like v1.90.0 or v1.90.1.
129+
var semverRegex = regexp.MustCompile(`^v(\d+)\.(\d+)\.(\d+)$`)
130+
131+
// parseSemver extracts major, minor, patch from a semver tag.
132+
// Returns -1 for all values if not a valid semver.
133+
func parseSemver(tag string) (int, int, int) {
134+
matches := semverRegex.FindStringSubmatch(tag)
135+
if len(matches) != semverMatchGroups {
136+
return -1, -1, -1
137+
}
138+
139+
major, _ := strconv.Atoi(matches[1])
140+
minor, _ := strconv.Atoi(matches[2])
141+
patch, _ := strconv.Atoi(matches[3])
142+
143+
return major, minor, patch
144+
}
145+
146+
// getMinorVersionsFromTags processes container tags and returns a map of minor versions
147+
// to the first available patch version for each minor.
148+
// For example: {"v1.90": "v1.90.0", "v1.92": "v1.92.0"}.
149+
func getMinorVersionsFromTags(tags []string) map[string]string {
150+
// Map minor version (e.g., "v1.90") to lowest patch version available
151+
minorToLowestPatch := make(map[string]struct {
152+
patch int
153+
fullVer string
154+
})
155+
156+
for _, tag := range tags {
157+
major, minor, patch := parseSemver(tag)
158+
if major < 0 {
159+
continue // Not a semver tag
160+
}
161+
162+
minorKey := fmt.Sprintf("v%d.%d", major, minor)
163+
164+
existing, exists := minorToLowestPatch[minorKey]
165+
if !exists || patch < existing.patch {
166+
minorToLowestPatch[minorKey] = struct {
167+
patch int
168+
fullVer string
169+
}{
170+
patch: patch,
171+
fullVer: tag,
172+
}
173+
}
174+
}
175+
176+
// Convert to simple map
177+
result := make(map[string]string)
178+
for minorVer, info := range minorToLowestPatch {
179+
result[minorVer] = info.fullVer
180+
}
181+
182+
return result
183+
}
184+
185+
// getCapabilityVersions fetches container tags from GHCR, identifies minor versions,
186+
// and fetches the capability version for each from the Tailscale source.
187+
func getCapabilityVersions(ctx context.Context) (map[string]tailcfg.CapabilityVersion, error) {
188+
// Fetch container tags from GHCR
189+
tags, err := getGHCRTags(ctx)
190+
if err != nil {
191+
return nil, fmt.Errorf("failed to get container tags: %w", err)
192+
}
193+
194+
log.Printf("Found %d container tags", len(tags))
195+
196+
// Get minor versions with their representative patch versions
197+
minorVersions := getMinorVersionsFromTags(tags)
198+
log.Printf("Found %d minor versions", len(minorVersions))
199+
60200
// Regular expression to find the CurrentCapabilityVersion line
61201
re := regexp.MustCompile(`const CurrentCapabilityVersion CapabilityVersion = (\d+)`)
62202

63203
versions := make(map[string]tailcfg.CapabilityVersion)
204+
client := &http.Client{}
64205

65-
for _, release := range releases {
66-
version := strings.TrimSpace(release.Name)
67-
if !strings.HasPrefix(version, "v") {
68-
version = "v" + version
69-
}
206+
for minorVer, patchVer := range minorVersions {
207+
// Fetch the raw Go file for the patch version
208+
rawURL := fmt.Sprintf(rawFileURL, patchVer)
70209

71-
// Fetch the raw Go file
72-
rawURL := fmt.Sprintf(rawFileURL, version)
210+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) //nolint:gosec
211+
if err != nil {
212+
log.Printf("Warning: failed to create request for %s: %v", patchVer, err)
213+
continue
214+
}
73215

74-
resp, err := http.Get(rawURL)
216+
resp, err := client.Do(req)
75217
if err != nil {
218+
log.Printf("Warning: failed to fetch %s: %v", patchVer, err)
76219
continue
77220
}
78221
defer resp.Body.Close()
79222

223+
if resp.StatusCode != http.StatusOK {
224+
log.Printf("Warning: got status %d for %s", resp.StatusCode, patchVer)
225+
continue
226+
}
227+
80228
body, err := io.ReadAll(resp.Body)
81229
if err != nil {
230+
log.Printf("Warning: failed to read response for %s: %v", patchVer, err)
82231
continue
83232
}
84233

@@ -87,46 +236,29 @@ func getCapabilityVersions() (map[string]tailcfg.CapabilityVersion, error) {
87236
if len(matches) > 1 {
88237
capabilityVersionStr := matches[1]
89238
capabilityVersion, _ := strconv.Atoi(capabilityVersionStr)
90-
versions[version] = tailcfg.CapabilityVersion(capabilityVersion)
239+
versions[minorVer] = tailcfg.CapabilityVersion(capabilityVersion)
240+
log.Printf(" %s (from %s): capVer %d", minorVer, patchVer, capabilityVersion)
91241
}
92242
}
93243

94244
return versions, nil
95245
}
96246

97247
func calculateMinSupportedCapabilityVersion(versions map[string]tailcfg.CapabilityVersion) tailcfg.CapabilityVersion {
98-
// Get unique major.minor versions
99-
majorMinorToCapVer := make(map[string]tailcfg.CapabilityVersion)
100-
101-
for version, capVer := range versions {
102-
// Remove 'v' prefix and split by '.'
103-
cleanVersion := strings.TrimPrefix(version, "v")
104-
105-
parts := strings.Split(cleanVersion, ".")
106-
if len(parts) >= minVersionParts {
107-
majorMinor := parts[0] + "." + parts[1]
108-
// Keep the earliest (lowest) capver for each major.minor
109-
if existing, exists := majorMinorToCapVer[majorMinor]; !exists || capVer < existing {
110-
majorMinorToCapVer[majorMinor] = capVer
111-
}
112-
}
113-
}
114-
115-
// Sort major.minor versions
116-
majorMinors := xmaps.Keys(majorMinorToCapVer)
117-
sort.Strings(majorMinors)
248+
// Since we now store minor versions directly, just sort and take the oldest of the latest N
249+
minorVersions := xmaps.Keys(versions)
250+
sort.Strings(minorVersions)
118251

119-
// Take the latest 10 versions
120-
supportedCount := min(len(majorMinors), supportedMajorMinorVersions)
252+
supportedCount := min(len(minorVersions), supportedMajorMinorVersions)
121253

122254
if supportedCount == 0 {
123255
return fallbackCapVer
124256
}
125257

126258
// The minimum supported version is the oldest of the latest 10
127-
oldestSupportedMajorMinor := majorMinors[len(majorMinors)-supportedCount]
259+
oldestSupportedMinor := minorVersions[len(minorVersions)-supportedCount]
128260

129-
return majorMinorToCapVer[oldestSupportedMajorMinor]
261+
return versions[oldestSupportedMinor]
130262
}
131263

132264
func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion, minSupportedCapVer tailcfg.CapabilityVersion) error {
@@ -156,8 +288,8 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion
156288
capabilityVersion := versions[v]
157289

158290
// If it is already set, skip and continue,
159-
// we only want the first tailscale vsion per
160-
// capability vsion.
291+
// we only want the first tailscale version per
292+
// capability version.
161293
if _, ok := capVarToTailscaleVer[capabilityVersion]; ok {
162294
continue
163295
}
@@ -199,31 +331,16 @@ func writeCapabilityVersionsToFile(versions map[string]tailcfg.CapabilityVersion
199331
}
200332

201333
func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupportedCapVer tailcfg.CapabilityVersion) error {
202-
// Get unique major.minor versions for test generation
203-
majorMinorToCapVer := make(map[string]tailcfg.CapabilityVersion)
334+
// Sort minor versions
335+
minorVersions := xmaps.Keys(versions)
336+
sort.Strings(minorVersions)
204337

205-
for version, capVer := range versions {
206-
cleanVersion := strings.TrimPrefix(version, "v")
338+
// Take latest N
339+
supportedCount := min(len(minorVersions), supportedMajorMinorVersions)
207340

208-
parts := strings.Split(cleanVersion, ".")
209-
if len(parts) >= minVersionParts {
210-
majorMinor := parts[0] + "." + parts[1]
211-
if existing, exists := majorMinorToCapVer[majorMinor]; !exists || capVer < existing {
212-
majorMinorToCapVer[majorMinor] = capVer
213-
}
214-
}
215-
}
216-
217-
// Sort major.minor versions
218-
majorMinors := xmaps.Keys(majorMinorToCapVer)
219-
sort.Strings(majorMinors)
220-
221-
// Take latest 10
222-
supportedCount := min(len(majorMinors), supportedMajorMinorVersions)
223-
224-
latest10 := majorMinors[len(majorMinors)-supportedCount:]
225-
latest3 := majorMinors[len(majorMinors)-3:]
226-
latest2 := majorMinors[len(majorMinors)-2:]
341+
latest10 := minorVersions[len(minorVersions)-supportedCount:]
342+
latest3 := minorVersions[len(minorVersions)-min(latest3Count, len(minorVersions)):]
343+
latest2 := minorVersions[len(minorVersions)-min(latest2Count, len(minorVersions)):]
227344

228345
// Generate test data file content
229346
var content strings.Builder
@@ -242,7 +359,7 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
242359
content.WriteString("\t{3, false, []string{")
243360

244361
for i, version := range latest3 {
245-
content.WriteString(fmt.Sprintf("\"v%s\"", version))
362+
content.WriteString(fmt.Sprintf("\"%s\"", version))
246363

247364
if i < len(latest3)-1 {
248365
content.WriteString(", ")
@@ -255,7 +372,9 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
255372
content.WriteString("\t{2, true, []string{")
256373

257374
for i, version := range latest2 {
258-
content.WriteString(fmt.Sprintf("\"%s\"", version))
375+
// Strip v prefix for this test case
376+
verNoV := strings.TrimPrefix(version, "v")
377+
content.WriteString(fmt.Sprintf("\"%s\"", verNoV))
259378

260379
if i < len(latest2)-1 {
261380
content.WriteString(", ")
@@ -268,7 +387,8 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
268387
content.WriteString(fmt.Sprintf("\t{%d, true, []string{\n", supportedMajorMinorVersions))
269388

270389
for _, version := range latest10 {
271-
content.WriteString(fmt.Sprintf("\t\t\"%s\",\n", version))
390+
verNoV := strings.TrimPrefix(version, "v")
391+
content.WriteString(fmt.Sprintf("\t\t\"%s\",\n", verNoV))
272392
}
273393

274394
content.WriteString("\t}},\n")
@@ -338,7 +458,9 @@ func writeTestDataFile(versions map[string]tailcfg.CapabilityVersion, minSupport
338458
}
339459

340460
func main() {
341-
versions, err := getCapabilityVersions()
461+
ctx := context.Background()
462+
463+
versions, err := getCapabilityVersions(ctx)
342464
if err != nil {
343465
log.Println("Error:", err)
344466
return

0 commit comments

Comments
 (0)