From 710ad86f9368ca4a7f37fef28ba0d35405842b60 Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 12:48:03 +0800 Subject: [PATCH 1/6] feat(builder): add dynamic mirror candidate fetcher with multi-source fallback --- builder/mirror/fetcher.go | 124 +++++++++++++++++++++++++++++++ builder/mirror/fetcher_test.go | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 builder/mirror/fetcher.go create mode 100644 builder/mirror/fetcher_test.go diff --git a/builder/mirror/fetcher.go b/builder/mirror/fetcher.go new file mode 100644 index 000000000..f2324d840 --- /dev/null +++ b/builder/mirror/fetcher.go @@ -0,0 +1,124 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// Package mirror maintains a dynamically refreshed list of docker.io registry +// mirrors. Candidates come from a remote JSON source, are filtered by a live +// /v2/ probe and exposed to the build paths via Manager. +package mirror + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +// sourceSchemaVersion is the only mirrors.json schema version this builder +// understands. Any other version is treated as a fetch failure so the last +// known good list keeps being used. +const sourceSchemaVersion = 1 + +// maxSourceBodySize bounds the JSON source payload to protect against a +// misconfigured URL pointing at a huge file. +const maxSourceBodySize = 1 << 20 // 1 MiB + +type sourceDocument struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Mirrors []sourceMirror `json:"mirrors"` +} + +type sourceMirror struct { + URL string `json:"url"` + Note string `json:"note"` +} + +// FetchCandidates downloads the mirror candidate list, trying each source URL +// in order until one yields a valid document. The returned URLs keep their +// scheme (http:// entries stay plain HTTP), are trimmed and deduplicated in +// document order. An error is returned only when every source fails. +func FetchCandidates(ctx context.Context, sourceURLs []string, timeout time.Duration) ([]string, error) { + if len(sourceURLs) == 0 { + return nil, fmt.Errorf("no mirror source url configured") + } + client := &http.Client{Timeout: timeout} + var lastErr error + for _, sourceURL := range sourceURLs { + candidates, err := fetchOneSource(ctx, client, sourceURL) + if err != nil { + logrus.Warnf("fetch mirror candidates from %s failure: %v", sourceURL, err) + lastErr = err + continue + } + return candidates, nil + } + return nil, fmt.Errorf("all mirror sources failed: %w", lastErr) +} + +func fetchOneSource(ctx context.Context, client *http.Client, sourceURL string) ([]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request source: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxSourceBodySize)) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + var doc sourceDocument + if err := json.Unmarshal(body, &doc); err != nil { + return nil, fmt.Errorf("parse mirrors json: %w", err) + } + if doc.Version != sourceSchemaVersion { + return nil, fmt.Errorf("unsupported mirrors schema version %d", doc.Version) + } + candidates := dedupeMirrorURLs(doc.Mirrors) + if len(candidates) == 0 { + return nil, fmt.Errorf("mirrors json contains no usable url") + } + return candidates, nil +} + +func dedupeMirrorURLs(mirrors []sourceMirror) []string { + seen := make(map[string]struct{}, len(mirrors)) + result := make([]string, 0, len(mirrors)) + for _, m := range mirrors { + u := strings.TrimSpace(m.URL) + if u == "" { + continue + } + if _, ok := seen[u]; ok { + continue + } + seen[u] = struct{}{} + result = append(result, u) + } + return result +} diff --git a/builder/mirror/fetcher_test.go b/builder/mirror/fetcher_test.go new file mode 100644 index 000000000..a0dc6d1fe --- /dev/null +++ b/builder/mirror/fetcher_test.go @@ -0,0 +1,132 @@ +package mirror + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// capability_id: rainbond.builder.dynamic-mirror-fetch +func TestFetchCandidates(t *testing.T) { + validJSON := `{ + "version": 1, + "updated_at": "2026-06-12T00:00:00Z", + "mirrors": [ + {"url": "https://docker.1ms.run", "note": "1ms"}, + {"url": "https://docker.m.daocloud.io"}, + {"url": "https://docker.1ms.run", "note": "dup"}, + {"url": " "}, + {"url": "http://insecure.example.com"} + ] + }` + + tests := []struct { + name string + body string + status int + want []string + wantErr bool + }{ + { + name: "valid source dedups and drops empty urls", + body: validJSON, + status: http.StatusOK, + want: []string{"https://docker.1ms.run", "https://docker.m.daocloud.io", "http://insecure.example.com"}, + }, + { + name: "invalid json is an error", + body: "not-json", + status: http.StatusOK, + wantErr: true, + }, + { + name: "unsupported version is an error", + body: `{"version": 2, "mirrors": [{"url": "https://a.example.com"}]}`, + status: http.StatusOK, + wantErr: true, + }, + { + name: "empty mirror list is an error", + body: `{"version": 1, "mirrors": []}`, + status: http.StatusOK, + wantErr: true, + }, + { + name: "http error status is an error", + body: validJSON, + status: http.StatusBadGateway, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + + got, err := FetchCandidates(context.Background(), []string{srv.URL}, 2*time.Second) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %v", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertStringSlice(t, got, tt.want) + }) + } +} + +// capability_id: rainbond.builder.dynamic-mirror-fetch-fallback +func TestFetchCandidatesFallsBackToNextURL(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer bad.Close() + good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version": 1, "mirrors": [{"url": "https://docker.1ms.run"}]}`)) + })) + defer good.Close() + + got, err := FetchCandidates(context.Background(), []string{bad.URL, good.URL}, 2*time.Second) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertStringSlice(t, got, []string{"https://docker.1ms.run"}) +} + +func TestFetchCandidatesAllSourcesFail(t *testing.T) { + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer bad.Close() + + if _, err := FetchCandidates(context.Background(), []string{bad.URL, "http://127.0.0.1:1"}, time.Second); err == nil { + t.Fatal("expected error when every source fails") + } +} + +func TestFetchCandidatesNoSources(t *testing.T) { + if _, err := FetchCandidates(context.Background(), nil, time.Second); err == nil { + t.Fatal("expected error for empty source list") + } +} + +func assertStringSlice(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} From eda472cea2e71be32be7ec4017e0fe98a9b3313d Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 12:48:03 +0800 Subject: [PATCH 2/6] feat(builder): add concurrent registry mirror liveness prober --- builder/mirror/prober.go | 97 +++++++++++++++++++++++++++++++++++ builder/mirror/prober_test.go | 49 ++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 builder/mirror/prober.go create mode 100644 builder/mirror/prober_test.go diff --git a/builder/mirror/prober.go b/builder/mirror/prober.go new file mode 100644 index 000000000..2756444b3 --- /dev/null +++ b/builder/mirror/prober.go @@ -0,0 +1,97 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package mirror + +import ( + "context" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +// probeResult records one alive mirror and how fast its /v2/ endpoint answered. +type probeResult struct { + url string + latency time.Duration +} + +// Probe checks every candidate's /v2/ registry endpoint concurrently and +// returns only the alive ones, sorted by ascending latency. A mirror is alive +// when /v2/ answers 200 or 401 (an auth challenge still proves a working +// registry frontend). Candidates keep their scheme; entries without one are +// probed via https. +func Probe(ctx context.Context, candidates []string, timeout time.Duration) []string { + if len(candidates) == 0 { + return nil + } + client := &http.Client{Timeout: timeout} + results := make([]probeResult, 0, len(candidates)) + var mu sync.Mutex + var wg sync.WaitGroup + for _, candidate := range candidates { + candidate := candidate + wg.Add(1) + go func() { + defer wg.Done() + latency, alive := probeOne(ctx, client, candidate) + if !alive { + return + } + mu.Lock() + results = append(results, probeResult{url: candidate, latency: latency}) + mu.Unlock() + }() + } + wg.Wait() + sort.Slice(results, func(i, j int) bool { return results[i].latency < results[j].latency }) + alive := make([]string, 0, len(results)) + for _, r := range results { + alive = append(alive, r.url) + } + return alive +} + +func probeOne(ctx context.Context, client *http.Client, mirrorURL string) (time.Duration, bool) { + endpoint := mirrorURL + if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { + endpoint = "https://" + endpoint + } + endpoint = strings.TrimSuffix(endpoint, "/") + "/v2/" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + logrus.Debugf("probe mirror %s: build request failure: %v", mirrorURL, err) + return 0, false + } + start := time.Now() + resp, err := client.Do(req) + if err != nil { + logrus.Debugf("probe mirror %s failure: %v", mirrorURL, err) + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusUnauthorized { + logrus.Debugf("probe mirror %s: unexpected status %s", mirrorURL, resp.Status) + return 0, false + } + return time.Since(start), true +} diff --git a/builder/mirror/prober_test.go b/builder/mirror/prober_test.go new file mode 100644 index 000000000..2ab9e38c9 --- /dev/null +++ b/builder/mirror/prober_test.go @@ -0,0 +1,49 @@ +package mirror + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// capability_id: rainbond.builder.dynamic-mirror-probe +func TestProbeFiltersAndSortsByLatency(t *testing.T) { + slowOK := newRegistryStub(t, http.StatusOK, 300*time.Millisecond) + fastUnauthorized := newRegistryStub(t, http.StatusUnauthorized, 0) + dead := newRegistryStub(t, http.StatusInternalServerError, 0) + + got := Probe(context.Background(), []string{slowOK, fastUnauthorized, dead}, 2*time.Second) + + assertStringSlice(t, got, []string{fastUnauthorized, slowOK}) +} + +func TestProbeUnreachableHostDropped(t *testing.T) { + got := Probe(context.Background(), []string{"http://127.0.0.1:1"}, 500*time.Millisecond) + if len(got) != 0 { + t.Fatalf("expected no alive mirrors, got %v", got) + } +} + +func TestProbeEmptyInput(t *testing.T) { + if got := Probe(context.Background(), nil, time.Second); len(got) != 0 { + t.Fatalf("expected empty result, got %v", got) + } +} + +// newRegistryStub serves /v2/ with the given status after an artificial delay +// and returns the server base URL. +func newRegistryStub(t *testing.T, status int, delay time.Duration) string { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v2/" { + w.WriteHeader(http.StatusNotFound) + return + } + time.Sleep(delay) + w.WriteHeader(status) + })) + t.Cleanup(srv.Close) + return srv.URL +} From 0a44dd1d1eaf708cdade5a41fac7201d36a52b33 Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 12:48:03 +0800 Subject: [PATCH 3/6] feat(builder): add dynamic mirror manager with configmap persistence --- builder/mirror/config.go | 90 ++++++++++++++ builder/mirror/config_test.go | 58 +++++++++ builder/mirror/manager.go | 212 +++++++++++++++++++++++++++++++++ builder/mirror/manager_test.go | 145 ++++++++++++++++++++++ 4 files changed, 505 insertions(+) create mode 100644 builder/mirror/config.go create mode 100644 builder/mirror/config_test.go create mode 100644 builder/mirror/manager.go create mode 100644 builder/mirror/manager_test.go diff --git a/builder/mirror/config.go b/builder/mirror/config.go new file mode 100644 index 000000000..574ad38b0 --- /dev/null +++ b/builder/mirror/config.go @@ -0,0 +1,90 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package mirror + +import ( + "strconv" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +// defaultSourceURL is the goodrain-maintained mirrors.json served through the +// jsDelivr CDN, which is reachable from mainland-China clusters. +const defaultSourceURL = "https://cdn.jsdelivr.net/gh/goodrain/docker-mirrors@main/mirrors.json" + +const ( + defaultRefreshInterval = 6 * time.Hour + defaultMaxCount = 3 +) + +// Config controls the dynamic mirror manager. All fields come from builder +// environment variables; zero-configuration deployments get safe defaults. +type Config struct { + // Enabled gates the whole feature (DYNAMIC_REGISTRY_MIRRORS, default true). + Enabled bool + // SourceURLs are tried in order when fetching candidates (MIRROR_SOURCE_URLS). + SourceURLs []string + // RefreshInterval is the period between refresh runs (MIRROR_REFRESH_INTERVAL). + RefreshInterval time.Duration + // MaxCount caps how many alive mirrors are kept (MIRROR_MAX_COUNT). + MaxCount int +} + +// LoadConfig builds a Config from the given env lookup (usually os.Getenv). +// Invalid values fall back to defaults with a warning instead of failing the +// builder startup. +func LoadConfig(getenv func(string) string) Config { + cfg := Config{ + Enabled: true, + SourceURLs: []string{defaultSourceURL}, + RefreshInterval: defaultRefreshInterval, + MaxCount: defaultMaxCount, + } + if raw := getenv("DYNAMIC_REGISTRY_MIRRORS"); raw != "" { + cfg.Enabled = strings.EqualFold(raw, "true") + } + if raw := getenv("MIRROR_SOURCE_URLS"); raw != "" { + urls := make([]string, 0) + for _, u := range strings.Split(raw, ",") { + if u = strings.TrimSpace(u); u != "" { + urls = append(urls, u) + } + } + if len(urls) > 0 { + cfg.SourceURLs = urls + } + } + if raw := getenv("MIRROR_REFRESH_INTERVAL"); raw != "" { + if interval, err := time.ParseDuration(raw); err == nil && interval > 0 { + cfg.RefreshInterval = interval + } else { + logrus.Warnf("invalid MIRROR_REFRESH_INTERVAL %q, using default %v", raw, defaultRefreshInterval) + } + } + if raw := getenv("MIRROR_MAX_COUNT"); raw != "" { + if count, err := strconv.Atoi(raw); err == nil && count > 0 { + cfg.MaxCount = count + } else { + logrus.Warnf("invalid MIRROR_MAX_COUNT %q, using default %d", raw, defaultMaxCount) + } + } + return cfg +} diff --git a/builder/mirror/config_test.go b/builder/mirror/config_test.go new file mode 100644 index 000000000..a44d9c3dd --- /dev/null +++ b/builder/mirror/config_test.go @@ -0,0 +1,58 @@ +package mirror + +import ( + "testing" + "time" +) + +// capability_id: rainbond.builder.dynamic-mirror-config +func TestLoadConfigDefaults(t *testing.T) { + cfg := LoadConfig(func(string) string { return "" }) + + if !cfg.Enabled { + t.Fatal("dynamic mirrors should default to enabled") + } + assertStringSlice(t, cfg.SourceURLs, []string{defaultSourceURL}) + if cfg.RefreshInterval != 6*time.Hour { + t.Fatalf("default refresh interval = %v, want 6h", cfg.RefreshInterval) + } + if cfg.MaxCount != 3 { + t.Fatalf("default max count = %d, want 3", cfg.MaxCount) + } +} + +func TestLoadConfigOverrides(t *testing.T) { + env := map[string]string{ + "DYNAMIC_REGISTRY_MIRRORS": "false", + "MIRROR_SOURCE_URLS": "https://a.example.com/m.json, https://b.example.com/m.json,", + "MIRROR_REFRESH_INTERVAL": "30m", + "MIRROR_MAX_COUNT": "5", + } + cfg := LoadConfig(func(k string) string { return env[k] }) + + if cfg.Enabled { + t.Fatal("DYNAMIC_REGISTRY_MIRRORS=false should disable") + } + assertStringSlice(t, cfg.SourceURLs, []string{"https://a.example.com/m.json", "https://b.example.com/m.json"}) + if cfg.RefreshInterval != 30*time.Minute { + t.Fatalf("refresh interval = %v, want 30m", cfg.RefreshInterval) + } + if cfg.MaxCount != 5 { + t.Fatalf("max count = %d, want 5", cfg.MaxCount) + } +} + +func TestLoadConfigInvalidValuesFallBack(t *testing.T) { + env := map[string]string{ + "MIRROR_REFRESH_INTERVAL": "soon", + "MIRROR_MAX_COUNT": "-1", + } + cfg := LoadConfig(func(k string) string { return env[k] }) + + if cfg.RefreshInterval != 6*time.Hour { + t.Fatalf("invalid interval should fall back to 6h, got %v", cfg.RefreshInterval) + } + if cfg.MaxCount != 3 { + t.Fatalf("invalid max count should fall back to 3, got %d", cfg.MaxCount) + } +} diff --git a/builder/mirror/manager.go b/builder/mirror/manager.go new file mode 100644 index 000000000..42b37153f --- /dev/null +++ b/builder/mirror/manager.go @@ -0,0 +1,212 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package mirror + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + k8serror "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// configMapName persists the last known good mirror list so a builder restart +// does not depend on the remote source being reachable. +const configMapName = "rbd-dynamic-mirrors" + +const ( + fetchTimeout = 15 * time.Second + probeTimeout = 5 * time.Second +) + +// Manager holds the dynamically refreshed docker.io mirror list. All methods +// are safe for concurrent use; Mirrors is additionally nil-receiver safe so +// call sites do not need to care whether the feature was initialised. +type Manager struct { + cfg Config + kube kubernetes.Interface + namespace string + + mu sync.RWMutex + mirrors []string +} + +var ( + defaultManager *Manager + defaultMu sync.RWMutex +) + +// New creates a Manager. kube may be nil in unit tests that skip persistence. +func New(cfg Config, kube kubernetes.Interface, namespace string) *Manager { + return &Manager{cfg: cfg, kube: kube, namespace: namespace} +} + +// Init creates the default Manager from the environment and starts its refresh +// loop. It is a no-op when the feature is disabled. +func Init(ctx context.Context, getenv func(string) string, kube kubernetes.Interface, namespace string) { + cfg := LoadConfig(getenv) + if !cfg.Enabled { + logrus.Info("dynamic registry mirrors disabled") + return + } + m := New(cfg, kube, namespace) + defaultMu.Lock() + defaultManager = m + defaultMu.Unlock() + go m.Start(ctx) +} + +// Default returns the manager created by Init, or nil when the feature is +// disabled or not initialised. The returned value is safe to use either way. +func Default() *Manager { + defaultMu.RLock() + defer defaultMu.RUnlock() + return defaultManager +} + +// Mirrors returns a copy of the current mirror list, ordered by probe latency. +func (m *Manager) Mirrors() []string { + if m == nil { + return nil + } + m.mu.RLock() + defer m.mu.RUnlock() + if len(m.mirrors) == 0 { + return nil + } + result := make([]string, len(m.mirrors)) + copy(result, m.mirrors) + return result +} + +// Start restores the persisted list, refreshes immediately and then keeps +// refreshing on the configured interval until ctx is cancelled. +func (m *Manager) Start(ctx context.Context) { + if !m.cfg.Enabled { + return + } + m.restore(ctx) + if err := m.Refresh(ctx); err != nil { + logrus.Warnf("initial dynamic mirror refresh failure: %v", err) + } + ticker := time.NewTicker(m.cfg.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := m.Refresh(ctx); err != nil { + logrus.Warnf("dynamic mirror refresh failure: %v", err) + } + } + } +} + +// Refresh fetches candidates, probes them and stores the fastest MaxCount +// alive mirrors. A fetch failure returns an error and keeps the previous list; +// an empty probe result is not an error and clears the list, so dead mirrors +// never reach the build path. +func (m *Manager) Refresh(ctx context.Context) error { + if !m.cfg.Enabled { + return nil + } + candidates, err := FetchCandidates(ctx, m.cfg.SourceURLs, fetchTimeout) + if err != nil { + return fmt.Errorf("fetch mirror candidates: %w", err) + } + alive := Probe(ctx, candidates, probeTimeout) + if len(alive) > m.cfg.MaxCount { + alive = alive[:m.cfg.MaxCount] + } + m.setMirrors(ctx, alive) + logrus.Infof("dynamic registry mirrors refreshed: %d candidates, using %v", len(candidates), alive) + return nil +} + +func (m *Manager) setMirrors(ctx context.Context, mirrors []string) { + m.mu.Lock() + m.mirrors = mirrors + m.mu.Unlock() + if err := m.persist(ctx, mirrors); err != nil { + logrus.Warnf("persist dynamic mirrors failure: %v", err) + } +} + +// restore loads the last persisted list so mirrors are usable right after a +// builder restart, before the first remote fetch completes. +func (m *Manager) restore(ctx context.Context) { + if m.kube == nil { + return + } + cm, err := m.kube.CoreV1().ConfigMaps(m.namespace).Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + if !k8serror.IsNotFound(err) { + logrus.Warnf("restore dynamic mirrors failure: %v", err) + } + return + } + var mirrors []string + if err := json.Unmarshal([]byte(cm.Data["mirrors"]), &mirrors); err != nil { + logrus.Warnf("restore dynamic mirrors: invalid persisted payload: %v", err) + return + } + if len(mirrors) == 0 { + return + } + m.mu.Lock() + m.mirrors = mirrors + m.mu.Unlock() + logrus.Infof("dynamic registry mirrors restored from configmap: %v", mirrors) +} + +func (m *Manager) persist(ctx context.Context, mirrors []string) error { + if m.kube == nil { + return nil + } + payload, err := json.Marshal(mirrors) + if err != nil { + return fmt.Errorf("marshal mirrors: %w", err) + } + data := map[string]string{ + "mirrors": string(payload), + "updated_at": time.Now().UTC().Format(time.RFC3339), + } + cm, err := m.kube.CoreV1().ConfigMaps(m.namespace).Get(ctx, configMapName, metav1.GetOptions{}) + if k8serror.IsNotFound(err) { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: m.namespace}, + Data: data, + } + _, err = m.kube.CoreV1().ConfigMaps(m.namespace).Create(ctx, cm, metav1.CreateOptions{}) + return err + } + if err != nil { + return err + } + cm.Data = data + _, err = m.kube.CoreV1().ConfigMaps(m.namespace).Update(ctx, cm, metav1.UpdateOptions{}) + return err +} diff --git a/builder/mirror/manager_test.go b/builder/mirror/manager_test.go new file mode 100644 index 000000000..82359e503 --- /dev/null +++ b/builder/mirror/manager_test.go @@ -0,0 +1,145 @@ +package mirror + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func newSourceStub(t *testing.T, mirrors ...string) string { + t.Helper() + entries := "" + for i, m := range mirrors { + if i > 0 { + entries += "," + } + entries += fmt.Sprintf(`{"url": %q}`, m) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"version": 1, "mirrors": [%s]}`, entries) + })) + t.Cleanup(srv.Close) + return srv.URL +} + +func testConfig(sourceURL string) Config { + return Config{ + Enabled: true, + SourceURLs: []string{sourceURL}, + RefreshInterval: time.Hour, + MaxCount: 3, + } +} + +// capability_id: rainbond.builder.dynamic-mirror-refresh +func TestManagerRefreshUpdatesMirrorsAndConfigMap(t *testing.T) { + alive := newRegistryStub(t, http.StatusOK, 0) + dead := newRegistryStub(t, http.StatusInternalServerError, 0) + source := newSourceStub(t, alive, dead) + + kube := fake.NewSimpleClientset() + m := New(testConfig(source), kube, "rbd-system") + + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("refresh failure: %v", err) + } + assertStringSlice(t, m.Mirrors(), []string{alive}) + + cm, err := kube.CoreV1().ConfigMaps("rbd-system").Get(context.Background(), configMapName, metav1.GetOptions{}) + if err != nil { + t.Fatalf("configmap not persisted: %v", err) + } + if cm.Data["mirrors"] != fmt.Sprintf(`["%s"]`, alive) { + t.Fatalf("unexpected persisted mirrors: %q", cm.Data["mirrors"]) + } + if cm.Data["updated_at"] == "" { + t.Fatal("updated_at should be set") + } +} + +func TestManagerRefreshCapsAtMaxCount(t *testing.T) { + stubs := make([]string, 0, 4) + for i := 0; i < 4; i++ { + stubs = append(stubs, newRegistryStub(t, http.StatusOK, 0)) + } + source := newSourceStub(t, stubs...) + cfg := testConfig(source) + cfg.MaxCount = 2 + + m := New(cfg, fake.NewSimpleClientset(), "rbd-system") + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("refresh failure: %v", err) + } + if got := len(m.Mirrors()); got != 2 { + t.Fatalf("mirrors = %d, want capped at 2", got) + } +} + +func TestManagerRefreshFailureKeepsLastList(t *testing.T) { + alive := newRegistryStub(t, http.StatusOK, 0) + source := newSourceStub(t, alive) + + m := New(testConfig(source), fake.NewSimpleClientset(), "rbd-system") + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("first refresh failure: %v", err) + } + + m.cfg.SourceURLs = []string{"http://127.0.0.1:1"} + if err := m.Refresh(context.Background()); err == nil { + t.Fatal("expected refresh error when source unreachable") + } + assertStringSlice(t, m.Mirrors(), []string{alive}) +} + +func TestManagerRefreshAllDeadClearsList(t *testing.T) { + alive := newRegistryStub(t, http.StatusOK, 0) + dead := newRegistryStub(t, http.StatusInternalServerError, 0) + + m := New(testConfig(newSourceStub(t, alive)), fake.NewSimpleClientset(), "rbd-system") + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("first refresh failure: %v", err) + } + + m.cfg.SourceURLs = []string{newSourceStub(t, dead)} + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("refresh with dead mirrors should not error: %v", err) + } + if got := m.Mirrors(); len(got) != 0 { + t.Fatalf("dead mirrors must clear the list, got %v", got) + } +} + +// capability_id: rainbond.builder.dynamic-mirror-restore +func TestManagerRestoreFromConfigMap(t *testing.T) { + kube := fake.NewSimpleClientset() + m := New(testConfig("http://127.0.0.1:1"), kube, "rbd-system") + m.setMirrors(context.Background(), []string{"https://docker.1ms.run"}) + + restored := New(testConfig("http://127.0.0.1:1"), kube, "rbd-system") + restored.restore(context.Background()) + assertStringSlice(t, restored.Mirrors(), []string{"https://docker.1ms.run"}) +} + +func TestDisabledManagerReturnsNoMirrors(t *testing.T) { + cfg := testConfig("http://127.0.0.1:1") + cfg.Enabled = false + m := New(cfg, fake.NewSimpleClientset(), "rbd-system") + if err := m.Refresh(context.Background()); err != nil { + t.Fatalf("disabled refresh should be a no-op, got %v", err) + } + if got := m.Mirrors(); len(got) != 0 { + t.Fatalf("disabled manager must expose no mirrors, got %v", got) + } +} + +func TestDefaultManagerNilSafe(t *testing.T) { + if got := (*Manager)(nil).Mirrors(); got != nil { + t.Fatalf("nil manager should return nil, got %v", got) + } +} From 333992b96307bec001ac16473fe91615b915f0cc Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 12:51:21 +0800 Subject: [PATCH 4/6] feat(builder): wire dynamic mirrors into buildkit toml rendering and chaos startup --- builder/sources/image.go | 3 +- builder/sources/mirror_merge.go | 43 +++++++++++++++++++++++ builder/sources/mirror_merge_test.go | 52 ++++++++++++++++++++++++++++ pkg/component/core.go | 5 +++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 builder/sources/mirror_merge.go create mode 100644 builder/sources/mirror_merge_test.go diff --git a/builder/sources/image.go b/builder/sources/image.go index e4c47ca8c..48c10ebad 100644 --- a/builder/sources/image.go +++ b/builder/sources/image.go @@ -51,6 +51,7 @@ import ( "github.com/docker/docker/client" "github.com/eapache/channels" "github.com/goodrain/rainbond/builder" + "github.com/goodrain/rainbond/builder/mirror" jobc "github.com/goodrain/rainbond/builder/job" "github.com/goodrain/rainbond/builder/model" "github.com/goodrain/rainbond/db" @@ -1067,7 +1068,7 @@ func buildKitTomlContent(imageDomain string, mirrors []string) string { // in place when the rendered content differs from the stored one, so registry // mirror changes take effect on the next build without manual cleanup. func PrepareBuildKitTomlCM(ctx context.Context, kubeClient kubernetes.Interface, namespace, buildKitTomlCMName, imageDomain string) error { - configStr := buildKitTomlContent(imageDomain, builder.REGISTRYMIRRORS) + configStr := buildKitTomlContent(imageDomain, mergeMirrors(builder.REGISTRYMIRRORS, mirror.Default().Mirrors())) buildKitTomlCM, err := kubeClient.CoreV1().ConfigMaps(namespace).Get(ctx, buildKitTomlCMName, metav1.GetOptions{}) if err != nil && !k8serror.IsNotFound(err) { diff --git a/builder/sources/mirror_merge.go b/builder/sources/mirror_merge.go new file mode 100644 index 000000000..16d37690a --- /dev/null +++ b/builder/sources/mirror_merge.go @@ -0,0 +1,43 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package sources + +import "strings" + +// mergeMirrors combines the manually configured mirror list (REGISTRY_MIRRORS, +// always first so operator intent wins) with the dynamically discovered one. +// Entries are deduplicated by host, ignoring the scheme, so a manual http:// +// override is not shadowed by the same host from the dynamic list. +func mergeMirrors(manual, dynamic []string) []string { + seen := make(map[string]struct{}, len(manual)+len(dynamic)) + result := make([]string, 0, len(manual)+len(dynamic)) + for _, m := range append(append([]string{}, manual...), dynamic...) { + m = strings.TrimSpace(m) + if m == "" { + continue + } + host := strings.TrimPrefix(strings.TrimPrefix(m, "https://"), "http://") + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + result = append(result, m) + } + return result +} diff --git a/builder/sources/mirror_merge_test.go b/builder/sources/mirror_merge_test.go new file mode 100644 index 000000000..a436f56be --- /dev/null +++ b/builder/sources/mirror_merge_test.go @@ -0,0 +1,52 @@ +package sources + +import "testing" + +// capability_id: rainbond.builder.mirror-merge-manual-priority +func TestMergeMirrors(t *testing.T) { + tests := []struct { + name string + manual []string + dynamic []string + want []string + }{ + { + name: "both empty", + }, + { + name: "dynamic only", + dynamic: []string{"https://a.example.com", "https://b.example.com"}, + want: []string{"https://a.example.com", "https://b.example.com"}, + }, + { + name: "manual only", + manual: []string{"https://a.example.com"}, + want: []string{"https://a.example.com"}, + }, + { + name: "manual first then dynamic deduped by host", + manual: []string{"https://a.example.com", "http://c.example.com"}, + dynamic: []string{"https://b.example.com", "https://a.example.com"}, + want: []string{"https://a.example.com", "http://c.example.com", "https://b.example.com"}, + }, + { + name: "dedup ignores scheme difference", + manual: []string{"http://a.example.com"}, + dynamic: []string{"https://a.example.com", "a.example.com"}, + want: []string{"http://a.example.com"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mergeMirrors(tt.manual, tt.dynamic) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } +} diff --git a/pkg/component/core.go b/pkg/component/core.go index a2371d353..f59157a23 100644 --- a/pkg/component/core.go +++ b/pkg/component/core.go @@ -29,6 +29,7 @@ import ( "github.com/goodrain/rainbond/builder/clean" chaos_discover "github.com/goodrain/rainbond/builder/discover" "github.com/goodrain/rainbond/builder/exector" + "github.com/goodrain/rainbond/builder/mirror" exec_monitor "github.com/goodrain/rainbond/builder/monitor" "github.com/goodrain/rainbond/config/configs" "github.com/goodrain/rainbond/config/crd" @@ -60,6 +61,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" "net/http" + "os" "time" ) @@ -227,6 +229,9 @@ func WorkerInit() rainbond.FuncComponent { // ChaosInit - func ChaosInit() rainbond.FuncComponent { return func(ctx context.Context) error { + // 启动 docker.io 动态镜像代理:定期拉取候选源并探活,构建路径在渲染 + // buildkitd.toml 时读取(DYNAMIC_REGISTRY_MIRRORS=false 可关闭)。 + mirror.Init(ctx, os.Getenv, k8s.Default().Clientset, configs.Default().PublicConfig.RbdNamespace) errChan := make(chan error) exec, err := exector.NewManager() if err != nil { From 697fd9574180ed4d5e0b445b93a53e774e79c3e9 Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 12:54:30 +0800 Subject: [PATCH 5/6] feat(builder): route direct image pulls through dynamic docker.io mirrors --- builder/sources/image_containerd_client.go | 7 +- builder/sources/image_docker_client.go | 87 ++++++++++------ builder/sources/mirror_hosts.go | 99 ++++++++++++++++++ builder/sources/mirror_hosts_test.go | 113 +++++++++++++++++++++ 4 files changed, 275 insertions(+), 31 deletions(-) create mode 100644 builder/sources/mirror_hosts.go create mode 100644 builder/sources/mirror_hosts_test.go diff --git a/builder/sources/image_containerd_client.go b/builder/sources/image_containerd_client.go index c5f5482ee..b6ae16628 100644 --- a/builder/sources/image_containerd_client.go +++ b/builder/sources/image_containerd_client.go @@ -22,6 +22,7 @@ import ( "github.com/containerd/containerd/remotes/docker/config" dockercli "github.com/docker/docker/client" "github.com/goodrain/rainbond/builder" + "github.com/goodrain/rainbond/builder/mirror" "github.com/goodrain/rainbond/event" "github.com/goodrain/rainbond/util/criutil" "github.com/opencontainers/go-digest" @@ -132,9 +133,11 @@ func (c *containerdImageCliImpl) ImagePull(image string, username, password stri return username, password, nil } Tracker := docker.NewInMemoryTracker() + // docker.io 镜像优先尝试动态 mirror,全部失败自动回退上游 registry。 + dynamicMirrors := mirror.Default().Mirrors() options := docker.ResolverOptions{ Tracker: Tracker, - Hosts: config.ConfigureHosts(pctx, hostOpt), + Hosts: mirrorRegistryHosts(dynamicMirrors, config.ConfigureHosts(pctx, hostOpt)), } platformMC := platforms.Ordered([]ocispec.Platform{platforms.DefaultSpec()}...) @@ -153,7 +156,7 @@ func (c *containerdImageCliImpl) ImagePull(image string, username, password stri hostOpt.DefaultScheme = "http" options := docker.ResolverOptions{ Tracker: Tracker, - Hosts: config.ConfigureHosts(pctx, hostOpt), + Hosts: mirrorRegistryHosts(dynamicMirrors, config.ConfigureHosts(pctx, hostOpt)), } opts = []containerd.RemoteOpt{ containerd.WithImageHandler(h), diff --git a/builder/sources/image_docker_client.go b/builder/sources/image_docker_client.go index 66d17afea..9f8ea2d9a 100644 --- a/builder/sources/image_docker_client.go +++ b/builder/sources/image_docker_client.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker/api/types/filters" dockercli "github.com/docker/docker/client" "github.com/goodrain/rainbond/builder" + "github.com/goodrain/rainbond/builder/mirror" "github.com/goodrain/rainbond/event" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/sirupsen/logrus" @@ -100,41 +101,37 @@ func (d *dockerImageCliImpl) ImagePull(image string, username, password string, } ctx, cancel := context.WithTimeout(context.Background(), time.Minute*time.Duration(timeout)) defer cancel() - //TODO: 使用1.12版本api的bug "repository name must be canonical",使用rf.String()完整的镜像地址 - readcloser, err := d.client.ImagePull(ctx, rf.String(), pullipo) - if err != nil { - logrus.Debugf("image name: %s readcloser error: %v", image, err.Error()) - if strings.HasSuffix(err.Error(), "does not exist or no pull access") { + // docker.io 镜像依次尝试动态 mirror 改写后的引用,全部失败回退原始地址; + // 经 mirror 拉取成功后回打原始 tag,保证后续按原镜像名可见。 + pullRefs := mirrorPullRefs(rf.String(), mirror.Default().Mirrors()) + var pullErr error + for i, pullRef := range pullRefs { + if pullRef != rf.String() { + printLog(logger, "info", fmt.Sprintf("try pull image via mirror: %s", pullRef), map[string]string{"step": "pullimage"}) + } + pullErr = d.pullAndStreamProgress(ctx, pullRef, pullipo, logger) + if pullErr == nil { + if pullRef != rf.String() { + if err := d.client.ImageTag(ctx, pullRef, rf.String()); err != nil { + logrus.Errorf("tag mirror image %s to %s failure: %v", pullRef, rf.String(), err) + pullErr = err + continue + } + } + break + } + logrus.Warnf("pull image %s (attempt %d/%d) failure: %v", pullRef, i+1, len(pullRefs), pullErr) + } + if pullErr != nil { + logrus.Debugf("image name: %s readcloser error: %v", image, pullErr.Error()) + if strings.HasSuffix(pullErr.Error(), "does not exist or no pull access") { printLog(logger, "error", fmt.Sprintf("image: %s does not exist or is not available", image), map[string]string{"step": "pullimage", "status": "failure"}) return nil, fmt.Errorf("Image(%s) does not exist or no pull access", image) } // 增强错误处理,提供更详细的错误信息 - enhancedErr := d.enhanceImagePullError(err, image, logger) + enhancedErr := d.enhanceImagePullError(pullErr, image, logger) return nil, enhancedErr } - defer readcloser.Close() - dec := json.NewDecoder(readcloser) - for { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - var jm JSONMessage - if err := dec.Decode(&jm); err != nil { - if err == io.EOF { - break - } - logrus.Debugf("error decoding jm(JSONMessage): %v", err) - return nil, err - } - if jm.Error != nil { - logrus.Debugf("error pulling image: %v", jm.Error) - return nil, jm.Error - } - printLog(logger, "debug", jm.JSONString(), map[string]string{"step": "progress"}) - logrus.Debug(jm.JSONString()) - } printLog(logger, "debug", "Get the image information and its raw representation", map[string]string{"step": "progress"}) ins, _, err := d.client.ImageInspectWithRaw(ctx, image) if err != nil { @@ -159,6 +156,38 @@ func (d *dockerImageCliImpl) ImagePull(image string, username, password string, }, nil } +// pullAndStreamProgress pulls one reference and forwards the daemon progress +// stream to the build log, returning the first error the stream reports. +func (d *dockerImageCliImpl) pullAndStreamProgress(ctx context.Context, ref string, pullipo dtypes.ImagePullOptions, logger event.Logger) error { + readcloser, err := d.client.ImagePull(ctx, ref, pullipo) + if err != nil { + return err + } + defer readcloser.Close() + dec := json.NewDecoder(readcloser) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + var jm JSONMessage + if err := dec.Decode(&jm); err != nil { + if err == io.EOF { + return nil + } + logrus.Debugf("error decoding jm(JSONMessage): %v", err) + return err + } + if jm.Error != nil { + logrus.Debugf("error pulling image: %v", jm.Error) + return jm.Error + } + printLog(logger, "debug", jm.JSONString(), map[string]string{"step": "progress"}) + logrus.Debug(jm.JSONString()) + } +} + // enhanceImagePullError 增强镜像拉取错误信息,提供更详细的错误描述和解决建议 func (d *dockerImageCliImpl) enhanceImagePullError(err error, image string, logger event.Logger) error { errMsg := err.Error() diff --git a/builder/sources/mirror_hosts.go b/builder/sources/mirror_hosts.go new file mode 100644 index 000000000..801f094fb --- /dev/null +++ b/builder/sources/mirror_hosts.go @@ -0,0 +1,99 @@ +// RAINBOND, Application Management Platform +// Copyright (C) 2014-2026 Goodrain Co., Ltd. + +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. For any non-GPL usage of Rainbond, +// one or multiple Commercial Licenses authorized by Goodrain Co., Ltd. +// must be obtained first. + +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package sources + +import ( + "crypto/tls" + "net/http" + "strings" + + refdocker "github.com/containerd/containerd/reference/docker" + "github.com/containerd/containerd/remotes/docker" +) + +// mirrorRegistryHosts wraps a containerd RegistryHosts resolver so docker.io +// pulls try the configured mirrors first and fall back to the upstream +// registry. The containerd resolver walks the returned hosts in order, so a +// dead mirror only costs one failed attempt instead of failing the pull. +// Non docker.io registries always pass through untouched. +func mirrorRegistryHosts(mirrors []string, fallback docker.RegistryHosts) docker.RegistryHosts { + return func(host string) ([]docker.RegistryHost, error) { + base, err := fallback(host) + if err != nil { + return nil, err + } + if host != "docker.io" || len(mirrors) == 0 { + return base, nil + } + hosts := make([]docker.RegistryHost, 0, len(mirrors)+len(base)) + for _, m := range mirrors { + hosts = append(hosts, mirrorRegistryHost(m)) + } + return append(hosts, base...), nil + } +} + +func mirrorRegistryHost(mirrorURL string) docker.RegistryHost { + scheme := "https" + host := strings.TrimSpace(mirrorURL) + if strings.HasPrefix(host, "http://") { + scheme = "http" + host = strings.TrimPrefix(host, "http://") + } else { + host = strings.TrimPrefix(host, "https://") + } + host = strings.TrimSuffix(host, "/") + return docker.RegistryHost{ + Client: &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + }, + Authorizer: docker.NewDockerAuthorizer(), + Host: host, + Scheme: scheme, + Path: "/v2", + Capabilities: docker.HostCapabilityPull | docker.HostCapabilityResolve, + } +} + +// mirrorPullRefs returns the references a docker-daemon pull should try in +// order. For docker.io images each mirror host yields a rewritten reference +// (with the library/ namespace completion ParseDockerRef performs), and the +// normalized upstream reference closes the list as the final fallback. Images +// from any other registry return only the original reference. +func mirrorPullRefs(image string, mirrors []string) []string { + if len(mirrors) == 0 { + return []string{image} + } + named, err := refdocker.ParseDockerRef(image) + if err != nil || refdocker.Domain(named) != "docker.io" { + return []string{image} + } + canonical := named.String() + remainder := strings.TrimPrefix(canonical, "docker.io/") + refs := make([]string, 0, len(mirrors)+1) + for _, m := range mirrors { + host := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(m), "https://"), "http://") + host = strings.TrimSuffix(host, "/") + if host == "" { + continue + } + refs = append(refs, host+"/"+remainder) + } + return append(refs, canonical) +} diff --git a/builder/sources/mirror_hosts_test.go b/builder/sources/mirror_hosts_test.go new file mode 100644 index 000000000..34d114fb7 --- /dev/null +++ b/builder/sources/mirror_hosts_test.go @@ -0,0 +1,113 @@ +package sources + +import ( + "testing" + + "github.com/containerd/containerd/remotes/docker" +) + +func fakeFallback(host string) ([]docker.RegistryHost, error) { + return []docker.RegistryHost{{Host: "registry-1.docker.io", Scheme: "https", Path: "/v2"}}, nil +} + +// capability_id: rainbond.builder.mirror-containerd-hosts +func TestMirrorRegistryHosts(t *testing.T) { + t.Run("docker.io gets mirrors first then upstream", func(t *testing.T) { + hostsFn := mirrorRegistryHosts([]string{"https://docker.1ms.run", "http://insecure.example.com"}, fakeFallback) + hosts, err := hostsFn("docker.io") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hosts) != 3 { + t.Fatalf("expected 2 mirrors + upstream, got %d", len(hosts)) + } + if hosts[0].Host != "docker.1ms.run" || hosts[0].Scheme != "https" { + t.Fatalf("first host = %+v", hosts[0]) + } + if hosts[1].Host != "insecure.example.com" || hosts[1].Scheme != "http" { + t.Fatalf("second host = %+v", hosts[1]) + } + if hosts[2].Host != "registry-1.docker.io" { + t.Fatalf("upstream must stay as final fallback, got %+v", hosts[2]) + } + if hosts[0].Path != "/v2" { + t.Fatalf("mirror path = %q, want /v2", hosts[0].Path) + } + }) + + t.Run("other registries untouched", func(t *testing.T) { + hostsFn := mirrorRegistryHosts([]string{"https://docker.1ms.run"}, fakeFallback) + hosts, err := hostsFn("myharbor.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hosts) != 1 || hosts[0].Host != "registry-1.docker.io" { + t.Fatalf("non docker.io hosts must pass through, got %+v", hosts) + } + }) + + t.Run("no mirrors passes through", func(t *testing.T) { + hostsFn := mirrorRegistryHosts(nil, fakeFallback) + hosts, err := hostsFn("docker.io") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(hosts) != 1 { + t.Fatalf("expected fallback only, got %+v", hosts) + } + }) +} + +// capability_id: rainbond.builder.mirror-docker-ref-rewrite +func TestMirrorPullRefs(t *testing.T) { + mirrors := []string{"https://docker.1ms.run", "http://insecure.example.com"} + tests := []struct { + name string + image string + want []string + }{ + { + name: "short name gets library completion and mirror candidates", + image: "nginx", + want: []string{ + "docker.1ms.run/library/nginx:latest", + "insecure.example.com/library/nginx:latest", + "docker.io/library/nginx:latest", + }, + }, + { + name: "namespaced docker.io image", + image: "bitnami/redis:7.2", + want: []string{ + "docker.1ms.run/bitnami/redis:7.2", + "insecure.example.com/bitnami/redis:7.2", + "docker.io/bitnami/redis:7.2", + }, + }, + { + name: "private registry untouched", + image: "myharbor.example.com/app:v1", + want: []string{"myharbor.example.com/app:v1"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mirrorPullRefs(tt.image, mirrors) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + } + }) + } + + t.Run("no mirrors returns original only", func(t *testing.T) { + got := mirrorPullRefs("nginx", nil) + if len(got) != 1 || got[0] != "nginx" { + t.Fatalf("got %v, want [nginx]", got) + } + }) +} From e392b2400523ae843ccde54b60388161548baeb6 Mon Sep 17 00:00:00 2001 From: yangk Date: Fri, 12 Jun 2026 13:05:32 +0800 Subject: [PATCH 6/6] test: register dynamic registry mirror capabilities in test manifest --- test-manifest.json | 162 +++++++++++++++++++++++++++++++++++++++++++++ test-manifest.md | 99 +++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/test-manifest.json b/test-manifest.json index 53b6412dd..0668b6b72 100644 --- a/test-manifest.json +++ b/test-manifest.json @@ -452,6 +452,168 @@ "test_type": "regression", "status": "active" }, + { + "id": "rainbond.builder.dynamic-mirror-config", + "title": "Dynamic mirror config defaults and env overrides", + "title_zh": "Dynamic mirror config defaults and env overrides", + "interface_type": "workflow", + "interface": "builder/mirror.LoadConfig", + "code_paths": [ + "builder/mirror/config.go" + ], + "tests": [ + { + "path": "builder/mirror/config_test.go", + "selector": "TestLoadConfigDefaults" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.dynamic-mirror-fetch", + "title": "Fetch mirror candidates from remote JSON source with schema validation", + "title_zh": "Fetch mirror candidates from remote JSON source with schema validation", + "interface_type": "workflow", + "interface": "builder/mirror.FetchCandidates", + "code_paths": [ + "builder/mirror/fetcher.go" + ], + "tests": [ + { + "path": "builder/mirror/fetcher_test.go", + "selector": "TestFetchCandidates" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.dynamic-mirror-fetch-fallback", + "title": "Mirror source fetch falls back to next URL on failure", + "title_zh": "Mirror source fetch falls back to next URL on failure", + "interface_type": "workflow", + "interface": "builder/mirror.FetchCandidates", + "code_paths": [ + "builder/mirror/fetcher.go" + ], + "tests": [ + { + "path": "builder/mirror/fetcher_test.go", + "selector": "TestFetchCandidatesFallsBackToNextURL" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.dynamic-mirror-probe", + "title": "Probe filters dead mirrors and sorts alive ones by latency", + "title_zh": "Probe filters dead mirrors and sorts alive ones by latency", + "interface_type": "workflow", + "interface": "builder/mirror.Probe", + "code_paths": [ + "builder/mirror/prober.go" + ], + "tests": [ + { + "path": "builder/mirror/prober_test.go", + "selector": "TestProbeFiltersAndSortsByLatency" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.dynamic-mirror-refresh", + "title": "Mirror manager refresh updates list and persists configmap", + "title_zh": "Mirror manager refresh updates list and persists configmap", + "interface_type": "workflow", + "interface": "builder/mirror.Manager.Refresh", + "code_paths": [ + "builder/mirror/manager.go" + ], + "tests": [ + { + "path": "builder/mirror/manager_test.go", + "selector": "TestManagerRefreshUpdatesMirrorsAndConfigMap" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.dynamic-mirror-restore", + "title": "Mirror manager restores last good list from configmap", + "title_zh": "Mirror manager restores last good list from configmap", + "interface_type": "workflow", + "interface": "builder/mirror.Manager.restore", + "code_paths": [ + "builder/mirror/manager.go" + ], + "tests": [ + { + "path": "builder/mirror/manager_test.go", + "selector": "TestManagerRestoreFromConfigMap" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.mirror-containerd-hosts", + "title": "containerd pulls try docker.io mirrors first with upstream fallback", + "title_zh": "containerd pulls try docker.io mirrors first with upstream fallback", + "interface_type": "workflow", + "interface": "builder/sources.mirrorRegistryHosts", + "code_paths": [ + "builder/sources/mirror_hosts.go" + ], + "tests": [ + { + "path": "builder/sources/mirror_hosts_test.go", + "selector": "TestMirrorRegistryHosts" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.mirror-docker-ref-rewrite", + "title": "docker daemon pulls rewrite docker.io refs to mirrors with fallback order", + "title_zh": "docker daemon pulls rewrite docker.io refs to mirrors with fallback order", + "interface_type": "workflow", + "interface": "builder/sources.mirrorPullRefs", + "code_paths": [ + "builder/sources/mirror_hosts.go" + ], + "tests": [ + { + "path": "builder/sources/mirror_hosts_test.go", + "selector": "TestMirrorPullRefs" + } + ], + "test_type": "unit", + "status": "active" + }, + { + "id": "rainbond.builder.mirror-merge-manual-priority", + "title": "Manual REGISTRY_MIRRORS take priority over dynamic mirrors with host dedup", + "title_zh": "Manual REGISTRY_MIRRORS take priority over dynamic mirrors with host dedup", + "interface_type": "workflow", + "interface": "builder/sources.mergeMirrors", + "code_paths": [ + "builder/sources/mirror_merge.go" + ], + "tests": [ + { + "path": "builder/sources/mirror_merge_test.go", + "selector": "TestMergeMirrors" + } + ], + "test_type": "unit", + "status": "active" + }, { "id": "rainbond.builder.registered-worker-dispatch", "title": "Dispatch registered worker tasks without unknown warnings", diff --git a/test-manifest.md b/test-manifest.md index bf7dad9ad..6527203d6 100644 --- a/test-manifest.md +++ b/test-manifest.md @@ -29,6 +29,15 @@ | rainbond.app-restore.unzip-all-data | 在恢复时解压完整备份数据包 | active | regression | builder/exector.BackupAPPRestore | builder/exector/groupapp_restore_test.go::TestUnzipAllDataFile | | rainbond.application.check-port-k8s-service-name-duplicate | 校验应用端口 Kubernetes Service 名称重复 | active | regression | api/handler.ApplicationAction.checkPorts | api/handler/application_handler_test.go::TestApplicationActionCheckPortsRejectsDuplicateK8sServiceName | | rainbond.build.select-builder-by-language | 按源码语言和构建类型选择构建器 | active | regression | builder/build.GetBuildByType | builder/build/build_type_matrix_test.go::TestGetBuildByType_SourceBuildLanguageMatrix | +| rainbond.builder.dynamic-mirror-config | Dynamic mirror config defaults and env overrides | active | unit | builder/mirror.LoadConfig | builder/mirror/config_test.go::TestLoadConfigDefaults | +| rainbond.builder.dynamic-mirror-fetch | Fetch mirror candidates from remote JSON source with schema validation | active | unit | builder/mirror.FetchCandidates | builder/mirror/fetcher_test.go::TestFetchCandidates | +| rainbond.builder.dynamic-mirror-fetch-fallback | Mirror source fetch falls back to next URL on failure | active | unit | builder/mirror.FetchCandidates | builder/mirror/fetcher_test.go::TestFetchCandidatesFallsBackToNextURL | +| rainbond.builder.dynamic-mirror-probe | Probe filters dead mirrors and sorts alive ones by latency | active | unit | builder/mirror.Probe | builder/mirror/prober_test.go::TestProbeFiltersAndSortsByLatency | +| rainbond.builder.dynamic-mirror-refresh | Mirror manager refresh updates list and persists configmap | active | unit | builder/mirror.Manager.Refresh | builder/mirror/manager_test.go::TestManagerRefreshUpdatesMirrorsAndConfigMap | +| rainbond.builder.dynamic-mirror-restore | Mirror manager restores last good list from configmap | active | unit | builder/mirror.Manager.restore | builder/mirror/manager_test.go::TestManagerRestoreFromConfigMap | +| rainbond.builder.mirror-containerd-hosts | containerd pulls try docker.io mirrors first with upstream fallback | active | unit | builder/sources.mirrorRegistryHosts | builder/sources/mirror_hosts_test.go::TestMirrorRegistryHosts | +| rainbond.builder.mirror-docker-ref-rewrite | docker daemon pulls rewrite docker.io refs to mirrors with fallback order | active | unit | builder/sources.mirrorPullRefs | builder/sources/mirror_hosts_test.go::TestMirrorPullRefs | +| rainbond.builder.mirror-merge-manual-priority | Manual REGISTRY_MIRRORS take priority over dynamic mirrors with host dedup | active | unit | builder/sources.mergeMirrors | builder/sources/mirror_merge_test.go::TestMergeMirrors | | rainbond.builder.registered-worker-dispatch | 已注册 worker 分发时不再误报未知任务 | active | regression | builder/exector.exectorManager.RunTask | builder/exector/exector_test.go::TestRunTaskDoesNotWarnForRegisteredWorker | | rainbond.cloud-storage.alioss-error-map | 将 AliOSS 服务错误转换为统一存储 SDK 错误 | active | regression | builder/cloudos.svcErrToS3SDKError | builder/cloudos/alioss_test.go::TestSvcErrToS3SDKError | | rainbond.cloud-storage.driver-factory | 将云存储配置分发到正确的驱动实现 | active | regression | builder/cloudos.New | builder/cloudos/cloudos_test.go::TestNewDispatchesProviderDrivers | @@ -700,6 +709,96 @@ - 代码路径: `builder/build/build.go` - 测试路径: `builder/build/build_type_matrix_test.go::TestGetBuildByType_SourceBuildLanguageMatrix` +### Dynamic mirror config defaults and env overrides + +- Capability ID: `rainbond.builder.dynamic-mirror-config` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.LoadConfig` +- 代码路径: `builder/mirror/config.go` +- 测试路径: `builder/mirror/config_test.go::TestLoadConfigDefaults` + +### Fetch mirror candidates from remote JSON source with schema validation + +- Capability ID: `rainbond.builder.dynamic-mirror-fetch` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.FetchCandidates` +- 代码路径: `builder/mirror/fetcher.go` +- 测试路径: `builder/mirror/fetcher_test.go::TestFetchCandidates` + +### Mirror source fetch falls back to next URL on failure + +- Capability ID: `rainbond.builder.dynamic-mirror-fetch-fallback` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.FetchCandidates` +- 代码路径: `builder/mirror/fetcher.go` +- 测试路径: `builder/mirror/fetcher_test.go::TestFetchCandidatesFallsBackToNextURL` + +### Probe filters dead mirrors and sorts alive ones by latency + +- Capability ID: `rainbond.builder.dynamic-mirror-probe` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.Probe` +- 代码路径: `builder/mirror/prober.go` +- 测试路径: `builder/mirror/prober_test.go::TestProbeFiltersAndSortsByLatency` + +### Mirror manager refresh updates list and persists configmap + +- Capability ID: `rainbond.builder.dynamic-mirror-refresh` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.Manager.Refresh` +- 代码路径: `builder/mirror/manager.go` +- 测试路径: `builder/mirror/manager_test.go::TestManagerRefreshUpdatesMirrorsAndConfigMap` + +### Mirror manager restores last good list from configmap + +- Capability ID: `rainbond.builder.dynamic-mirror-restore` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/mirror.Manager.restore` +- 代码路径: `builder/mirror/manager.go` +- 测试路径: `builder/mirror/manager_test.go::TestManagerRestoreFromConfigMap` + +### containerd pulls try docker.io mirrors first with upstream fallback + +- Capability ID: `rainbond.builder.mirror-containerd-hosts` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/sources.mirrorRegistryHosts` +- 代码路径: `builder/sources/mirror_hosts.go` +- 测试路径: `builder/sources/mirror_hosts_test.go::TestMirrorRegistryHosts` + +### docker daemon pulls rewrite docker.io refs to mirrors with fallback order + +- Capability ID: `rainbond.builder.mirror-docker-ref-rewrite` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/sources.mirrorPullRefs` +- 代码路径: `builder/sources/mirror_hosts.go` +- 测试路径: `builder/sources/mirror_hosts_test.go::TestMirrorPullRefs` + +### Manual REGISTRY_MIRRORS take priority over dynamic mirrors with host dedup + +- Capability ID: `rainbond.builder.mirror-merge-manual-priority` +- 状态: `active` +- 测试类型: `unit` +- 接口类型: `workflow` +- 业务入口: `builder/sources.mergeMirrors` +- 代码路径: `builder/sources/mirror_merge.go` +- 测试路径: `builder/sources/mirror_merge_test.go::TestMergeMirrors` + ### 已注册 worker 分发时不再误报未知任务 - Capability ID: `rainbond.builder.registered-worker-dispatch`