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/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)
+ }
+ }
+}
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)
+ }
+}
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
+}
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/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)
+ }
+ })
+}
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 {
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`