package real import ( "archive/tar" "compress/gzip" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/repository" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" ) // OCIClient 真实的 OCI 客户端实现(使用 ORAS) type OCIClient struct { httpClient *http.Client } type harborProject struct { Name string `json:"name"` } type harborRepository struct { Name string `json:"name"` ArtifactCount int `json:"artifact_count"` } type harborTag struct { Name string `json:"name"` PushTime string `json:"push_time"` } type harborArtifact struct { Digest string `json:"digest"` MediaType string `json:"media_type"` ArtifactType string `json:"artifact_type"` Size int64 `json:"size"` PushTime string `json:"push_time"` Tags []harborTag `json:"tags"` Annotations map[string]string `json:"annotations"` } // NewOCIClient 创建真实的 OCI 客户端 func NewOCIClient() repository.OCIClient { return &OCIClient{ httpClient: &http.Client{}, } } // getRegistry 创建 ORAS Registry 客户端 func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error) { // 解析 Registry URL registryURL := strings.TrimPrefix(reg.URL, "https://") registryURL = strings.TrimPrefix(registryURL, "http://") registry, err := remote.NewRegistry(registryURL) if err != nil { return nil, fmt.Errorf("failed to create registry client: %w", err) } // 设置认证 if reg.Username != "" && reg.Password != "" { registry.Client = &auth.Client{ Client: c.httpClient, Credential: auth.StaticCredential(registryURL, auth.Credential{ Username: reg.Username, Password: reg.Password, }), } } // 设置 PlainHTTP(如果是 insecure) registry.PlainHTTP = reg.Insecure return registry, nil } // ListRepositories 列出 Registry 中的 repositories. // Harbor registry 优先使用 Harbor v2.0 API,避免 robot 账号依赖 /v2/_catalog 全局权限。 func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) { repositories, harborErr := c.listHarborRepositories(ctx, registry, artifactType) if harborErr == nil { return repositories, nil } repositories, catalogErr := c.listOCIRepositories(ctx, registry) if catalogErr != nil { return nil, fmt.Errorf("failed to list repositories via Harbor API: %v; OCI catalog fallback also failed: %w", harborErr, catalogErr) } if strings.EqualFold(strings.TrimSpace(artifactType), "chart") { chartRepos := make([]string, 0) for _, repo := range repositories { artifacts, err := c.ListArtifacts(ctx, registry, repo, "chart") if err == nil && len(artifacts) > 0 { chartRepos = append(chartRepos, repo) } } return chartRepos, nil } return repositories, nil } func (c *OCIClient) listOCIRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) { reg, err := c.getRegistry(registry) if err != nil { return nil, err } repositories := make([]string, 0) err = reg.Repositories(ctx, "", func(repos []string) error { repositories = append(repositories, repos...) return nil }) if err != nil { return nil, fmt.Errorf("failed to list repositories: %w", err) } return repositories, nil } func (c *OCIClient) listHarborRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) { projects, err := c.harborListProjects(ctx, registry) if err != nil { return nil, err } repositorySet := make(map[string]struct{}) chartOnly := strings.EqualFold(strings.TrimSpace(artifactType), "chart") || strings.TrimSpace(artifactType) == "" for _, project := range projects { projectName := strings.TrimSpace(project.Name) if projectName == "" { continue } repositories, err := c.harborListProjectRepositories(ctx, registry, projectName) if err != nil { return nil, err } for _, harborRepo := range repositories { repoName := normalizeHarborRepositoryName(projectName, harborRepo.Name) if repoName == "" { continue } if chartOnly { artifacts, err := c.listHarborArtifacts(ctx, registry, repoName, "chart") if err != nil || len(artifacts) == 0 { continue } } repositorySet[repoName] = struct{}{} } } repositories := make([]string, 0, len(repositorySet)) for repo := range repositorySet { repositories = append(repositories, repo) } sort.Strings(repositories) return repositories, nil } func (c *OCIClient) harborListProjects(ctx context.Context, registry *entity.Registry) ([]harborProject, error) { var projects []harborProject if err := c.harborGetPaged(ctx, registry, "/api/v2.0/projects", url.Values{"member": []string{"true"}}, &projects); err != nil { return nil, err } return projects, nil } func (c *OCIClient) harborListProjectRepositories(ctx context.Context, registry *entity.Registry, projectName string) ([]harborRepository, error) { var repositories []harborRepository path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories" if err := c.harborGetPaged(ctx, registry, path, nil, &repositories); err != nil { return nil, err } return repositories, nil } func (c *OCIClient) listHarborArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) { projectName, repoName, ok := splitHarborRepository(repository) if !ok { return nil, fmt.Errorf("repository %q is not a Harbor project repository path", repository) } var harborArtifacts []harborArtifact path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories/" + url.PathEscape(repoName) + "/artifacts" query := url.Values{ "with_tag": []string{"true"}, "with_label": []string{"false"}, } if err := c.harborGetPaged(ctx, registry, path, query, &harborArtifacts); err != nil { return nil, err } artifacts := make([]*entity.Artifact, 0) for _, harborArtifact := range harborArtifacts { tags := harborArtifact.Tags if len(tags) == 0 { continue } for _, tag := range tags { if strings.TrimSpace(tag.Name) == "" { continue } artifact := &entity.Artifact{ Repository: repository, Tag: tag.Name, Digest: harborArtifact.Digest, MediaType: harborArtifact.MediaType, ConfigType: harborArtifact.ArtifactType, Size: harborArtifact.Size, Annotations: harborArtifact.Annotations, CreatedAt: parseHarborTime(firstNonEmpty(tag.PushTime, harborArtifact.PushTime)), } if artifact.Annotations == nil { artifact.Annotations = make(map[string]string) } artifact.DetermineType() if isHarborChartArtifact(harborArtifact) { artifact.Type = entity.ArtifactTypeChart } if c.shouldIncludeArtifact(artifact, mediaTypeFilter) { artifacts = append(artifacts, artifact) } } } return artifacts, nil } func (c *OCIClient) harborGetPaged(ctx context.Context, registry *entity.Registry, path string, query url.Values, target interface{}) error { const pageSize = 100 accumulated := make([]json.RawMessage, 0) for page := 1; ; page++ { pageQuery := cloneValues(query) pageQuery.Set("page", fmt.Sprintf("%d", page)) pageQuery.Set("page_size", fmt.Sprintf("%d", pageSize)) body, total, err := c.harborGet(ctx, registry, path, pageQuery) if err != nil { return err } var pageItems []json.RawMessage if err := json.Unmarshal(body, &pageItems); err != nil { return fmt.Errorf("failed to decode Harbor response for %s: %w", path, err) } accumulated = append(accumulated, pageItems...) if len(pageItems) < pageSize || (total >= 0 && len(accumulated) >= total) { break } } combined, err := json.Marshal(accumulated) if err != nil { return fmt.Errorf("failed to combine Harbor pages: %w", err) } if err := json.Unmarshal(combined, target); err != nil { return fmt.Errorf("failed to decode Harbor pages: %w", err) } return nil } func (c *OCIClient) harborGet(ctx context.Context, registry *entity.Registry, path string, query url.Values) ([]byte, int, error) { baseURL, err := harborBaseURL(registry) if err != nil { return nil, -1, err } requestURL := strings.TrimRight(baseURL, "/") + path if len(query) > 0 { requestURL += "?" + query.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return nil, -1, err } req.Header.Set("Accept", "application/json") if registry.Username != "" || registry.Password != "" { req.SetBasicAuth(registry.Username, registry.Password) } resp, err := c.httpClient.Do(req) if err != nil { return nil, -1, fmt.Errorf("Harbor API request failed: %w", err) } defer resp.Body.Close() body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) if readErr != nil { return nil, -1, fmt.Errorf("failed to read Harbor API response: %w", readErr) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, -1, fmt.Errorf("Harbor API %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body))) } total := -1 if value := strings.TrimSpace(resp.Header.Get("X-Total-Count")); value != "" { if parsed, err := strconv.Atoi(value); err == nil { total = parsed } } return body, total, nil } func harborBaseURL(registry *entity.Registry) (string, error) { rawURL := strings.TrimSpace(registry.URL) if rawURL == "" { return "", fmt.Errorf("registry URL is empty") } if !strings.Contains(rawURL, "://") { rawURL = "https://" + rawURL } parsed, err := url.Parse(rawURL) if err != nil { return "", fmt.Errorf("invalid registry URL %q: %w", registry.URL, err) } if parsed.Scheme == "" || parsed.Host == "" { return "", fmt.Errorf("invalid registry URL %q", registry.URL) } return parsed.Scheme + "://" + parsed.Host, nil } func splitHarborRepository(repository string) (string, string, bool) { projectName, repoName, ok := strings.Cut(strings.Trim(repository, "/"), "/") if !ok || projectName == "" || repoName == "" { return "", "", false } return projectName, repoName, true } func normalizeHarborRepositoryName(projectName, repositoryName string) string { repositoryName = strings.Trim(repositoryName, "/") if repositoryName == "" { return "" } if strings.HasPrefix(repositoryName, projectName+"/") { return repositoryName } return projectName + "/" + repositoryName } func isHarborChartArtifact(artifact harborArtifact) bool { typeInfo := strings.ToLower(strings.TrimSpace(artifact.ArtifactType + " " + artifact.MediaType)) return strings.Contains(typeInfo, "chart") || strings.Contains(typeInfo, "helm") } func cloneValues(values url.Values) url.Values { cloned := make(url.Values) for key, items := range values { cloned[key] = append([]string(nil), items...) } return cloned } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func parseHarborTime(value string) time.Time { value = strings.TrimSpace(value) if value == "" { return time.Time{} } for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} { if parsed, err := time.Parse(layout, value); err == nil { return parsed } } return time.Time{} } // ListArtifacts 列出指定 repository 的所有 artifacts // mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤 func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) { if artifacts, err := c.listHarborArtifacts(ctx, registry, repository, mediaTypeFilter); err == nil { return artifacts, nil } reg, err := c.getRegistry(registry) if err != nil { return nil, err } repo, err := reg.Repository(ctx, repository) if err != nil { return nil, fmt.Errorf("failed to get repository: %w", err) } artifacts := make([]*entity.Artifact, 0) err = repo.Tags(ctx, "", func(tags []string) error { for _, tag := range tags { // 获取 manifest 以获取更多信息 desc, err := repo.Resolve(ctx, tag) if err != nil { // 跳过无法解析的 tag continue } artifact := &entity.Artifact{ Repository: repository, Tag: tag, Digest: desc.Digest.String(), MediaType: desc.MediaType, Size: desc.Size, } // 尝试获取 config.mediaType 以更准确判断类型 if manifestBytes, err := repo.Fetch(ctx, desc); err == nil { defer manifestBytes.Close() if manifestData, err := io.ReadAll(manifestBytes); err == nil { var manifest map[string]interface{} if err := json.Unmarshal(manifestData, &manifest); err == nil { // 获取 config.mediaType if config, ok := manifest["config"].(map[string]interface{}); ok { if configMediaType, ok := config["mediaType"].(string); ok { artifact.ConfigType = configMediaType } } } } } // 使用智能类型判断(综合多种信息) artifact.DetermineType() // 应用 mediaType 过滤 if c.shouldIncludeArtifact(artifact, mediaTypeFilter) { artifacts = append(artifacts, artifact) } } return nil }) if err != nil { return nil, fmt.Errorf("failed to list artifacts: %w", err) } return artifacts, nil } // shouldIncludeArtifact 判断是否应该包含该 artifact func (c *OCIClient) shouldIncludeArtifact(artifact *entity.Artifact, filter string) bool { // 默认或 "all" 返回所有 if filter == "" || filter == "all" { return true } filter = strings.ToLower(strings.TrimSpace(filter)) switch filter { case "chart": // 只返回 Helm Charts return artifact.Type == entity.ArtifactTypeChart case "image": // 返回 Docker 或 OCI images return artifact.Type == entity.ArtifactTypeImage case "other": // 返回其他类型 return artifact.Type == entity.ArtifactTypeOther default: // 未知的 filter,返回所有 return true } } // GetArtifact 获取指定 artifact 的详细信息 func (c *OCIClient) GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error) { reg, err := c.getRegistry(registry) if err != nil { return nil, err } repo, err := reg.Repository(ctx, repository) if err != nil { return nil, fmt.Errorf("failed to get repository: %w", err) } // 解析 reference desc, err := repo.Resolve(ctx, reference) if err != nil { return nil, fmt.Errorf("failed to resolve artifact: %w", err) } // 获取 manifest manifestBytes, err := repo.Fetch(ctx, desc) if err != nil { return nil, fmt.Errorf("failed to fetch manifest: %w", err) } defer manifestBytes.Close() manifestData, err := io.ReadAll(manifestBytes) if err != nil { return nil, fmt.Errorf("failed to read manifest: %w", err) } // 解析 manifest 获取配置信息 var manifest map[string]interface{} if err := json.Unmarshal(manifestData, &manifest); err != nil { return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) } artifact := &entity.Artifact{ Repository: repository, Tag: reference, Digest: desc.Digest.String(), MediaType: desc.MediaType, Size: desc.Size, Annotations: make(map[string]string), } // 获取 config.mediaType 和 annotations if config, ok := manifest["config"].(map[string]interface{}); ok { // 获取 config.mediaType(用于准确的类型判断) if configMediaType, ok := config["mediaType"].(string); ok { artifact.ConfigType = configMediaType } // 获取 annotations if annotations, ok := config["annotations"].(map[string]interface{}); ok { for k, v := range annotations { if str, ok := v.(string); ok { artifact.Annotations[k] = str } } } } // 使用智能类型判断(综合 ConfigType, Annotations, Repository 名称等) artifact.DetermineType() return artifact, nil } // GetValuesSchema 获取 Helm Chart 的 values schema func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) { reg, err := c.getRegistry(registry) if err != nil { return "", err } repo, err := reg.Repository(ctx, repository) if err != nil { return "", fmt.Errorf("failed to get repository: %w", err) } // 解析 reference (tag 或 digest) desc, err := repo.Resolve(ctx, reference) if err != nil { return "", fmt.Errorf("failed to resolve artifact: %w", err) } manifestReader, err := repo.Fetch(ctx, desc) if err != nil { return "", fmt.Errorf("failed to fetch manifest: %w", err) } defer manifestReader.Close() manifestBytes, err := io.ReadAll(manifestReader) if err != nil { return "", fmt.Errorf("failed to read manifest: %w", err) } var manifest ocispec.Manifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return "", fmt.Errorf("failed to unmarshal manifest: %w", err) } // 优先查找是否存在独立的 values schema layer(一些 registry 会将 values.schema.json 作为单独的 layer 存储) var valuesSchemaLayer *ocispec.Descriptor for i := range manifest.Layers { layer := manifest.Layers[i] mediaType := strings.ToLower(layer.MediaType) if strings.Contains(mediaType, "helm.values.schema") || strings.Contains(mediaType, "values.schema") { valuesSchemaLayer = &manifest.Layers[i] break } } // 如果存在独立的 values schema layer,直接返回 if valuesSchemaLayer != nil { reader, err := repo.Fetch(ctx, *valuesSchemaLayer) if err != nil { return "", fmt.Errorf("failed to fetch values schema layer: %w", err) } defer reader.Close() data, err := io.ReadAll(reader) if err != nil { return "", fmt.Errorf("failed to read values schema layer: %w", err) } if len(data) == 0 { return "", entity.ErrValuesSchemaNotFound } return string(data), nil } // 回退:查找 Helm Chart layer(tar+gzip 包含 chart 内容)并从中读取 values.schema.json var chartLayer *ocispec.Descriptor for i := range manifest.Layers { layer := manifest.Layers[i] if strings.Contains(layer.MediaType, "cncf.helm.chart") || strings.Contains(layer.MediaType, "helm.chart.content") { chartLayer = &manifest.Layers[i] break } } if chartLayer == nil { return "", entity.ErrValuesSchemaNotFound } if chartLayer.Digest == "" { return "", fmt.Errorf("chart layer digest is empty") } if _, err := digest.Parse(string(chartLayer.Digest)); err != nil { return "", fmt.Errorf("invalid chart layer digest: %w", err) } layerReader, err := repo.Fetch(ctx, *chartLayer) if err != nil { return "", fmt.Errorf("failed to fetch chart layer: %w", err) } defer layerReader.Close() gzipReader, err := gzip.NewReader(layerReader) if err != nil { return "", fmt.Errorf("failed to create gzip reader: %w", err) } defer gzipReader.Close() tarReader := tar.NewReader(gzipReader) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return "", fmt.Errorf("failed to read chart archive: %w", err) } if header.Typeflag != tar.TypeReg { continue } if strings.HasSuffix(header.Name, "values.schema.json") { data, err := io.ReadAll(tarReader) if err != nil { return "", fmt.Errorf("failed to read values.schema.json: %w", err) } if len(data) == 0 { return "", entity.ErrValuesSchemaNotFound } return string(data), nil } } return "", entity.ErrValuesSchemaNotFound } // GetValuesYAML 获取 Helm Chart 包内原始 values.yaml func (c *OCIClient) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) { data, err := c.readChartFile(ctx, registry, repository, reference, "values.yaml") if err != nil { return "", err } if strings.TrimSpace(data) == "" { return "", entity.ErrArtifactNotFound } return data, nil } func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry, repository, reference, filename string) (string, error) { reg, err := c.getRegistry(registry) if err != nil { return "", err } repo, err := reg.Repository(ctx, repository) if err != nil { return "", fmt.Errorf("failed to get repository: %w", err) } desc, err := repo.Resolve(ctx, reference) if err != nil { return "", fmt.Errorf("failed to resolve artifact: %w", err) } manifestReader, err := repo.Fetch(ctx, desc) if err != nil { return "", fmt.Errorf("failed to fetch manifest: %w", err) } defer manifestReader.Close() manifestBytes, err := io.ReadAll(manifestReader) if err != nil { return "", fmt.Errorf("failed to read manifest: %w", err) } var manifest ocispec.Manifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return "", fmt.Errorf("failed to unmarshal manifest: %w", err) } var chartLayer *ocispec.Descriptor for i := range manifest.Layers { layer := manifest.Layers[i] if strings.Contains(layer.MediaType, "cncf.helm.chart") || strings.Contains(layer.MediaType, "helm.chart.content") { chartLayer = &manifest.Layers[i] break } } if chartLayer == nil { return "", fmt.Errorf("helm chart layer not found in manifest") } if chartLayer.Digest == "" { return "", fmt.Errorf("chart layer digest is empty") } if _, err := digest.Parse(string(chartLayer.Digest)); err != nil { return "", fmt.Errorf("invalid chart layer digest: %w", err) } layerReader, err := repo.Fetch(ctx, *chartLayer) if err != nil { return "", fmt.Errorf("failed to fetch chart layer: %w", err) } defer layerReader.Close() gzipReader, err := gzip.NewReader(layerReader) if err != nil { return "", fmt.Errorf("failed to create gzip reader: %w", err) } defer gzipReader.Close() tarReader := tar.NewReader(gzipReader) bestDepth := int(^uint(0) >> 1) var bestData []byte for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return "", fmt.Errorf("failed to read chart archive: %w", err) } if header.Typeflag != tar.TypeReg { continue } if strings.HasSuffix(header.Name, filename) { data, err := io.ReadAll(tarReader) if err != nil { return "", fmt.Errorf("failed to read %s: %w", filename, err) } depth := strings.Count(strings.Trim(header.Name, "/"), "/") if depth < bestDepth { bestDepth = depth bestData = data } } } if len(bestData) > 0 { return string(bestData), nil } return "", fmt.Errorf("%s not found in chart", filename) } // PullArtifact 下载 artifact 到本地 func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error { reg, err := c.getRegistry(registry) if err != nil { return err } repo, err := reg.Repository(ctx, repository) if err != nil { return fmt.Errorf("failed to get repository: %w", err) } // 解析 reference desc, err := repo.Resolve(ctx, reference) if err != nil { return fmt.Errorf("failed to resolve artifact: %w", err) } // 获取 manifest 内容 manifestReader, err := repo.Fetch(ctx, desc) if err != nil { return fmt.Errorf("failed to fetch manifest: %w", err) } defer manifestReader.Close() manifestBytes, err := io.ReadAll(manifestReader) if err != nil { return fmt.Errorf("failed to read manifest: %w", err) } var manifest ocispec.Manifest if err := json.Unmarshal(manifestBytes, &manifest); err != nil { return fmt.Errorf("failed to unmarshal manifest: %w", err) } var chartLayer *ocispec.Descriptor for i := range manifest.Layers { layer := manifest.Layers[i] if strings.Contains(layer.MediaType, "cncf.helm.chart") || strings.Contains(layer.MediaType, "helm.chart.content") { chartLayer = &layer break } } if chartLayer == nil { return fmt.Errorf("helm chart layer not found in manifest") } content, err := repo.Fetch(ctx, *chartLayer) if err != nil { return fmt.Errorf("failed to fetch chart layer: %w", err) } defer content.Close() // 确保目标目录存在 if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { return fmt.Errorf("failed to create destination directory: %w", err) } // 写入文件 file, err := os.Create(destPath) if err != nil { return fmt.Errorf("failed to create file: %w", err) } defer file.Close() if _, err := io.Copy(file, content); err != nil { return fmt.Errorf("failed to write artifact: %w", err) } return nil } // PushArtifact 推送 artifact 到 Registry func (c *OCIClient) PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error { // 这是一个简化实现 // 实际应该实现完整的 OCI artifact push 流程 return fmt.Errorf("push artifact not fully implemented yet") } // CheckHealth 检查 Registry 健康状态 func (c *OCIClient) CheckHealth(ctx context.Context, registry *entity.Registry) error { reg, err := c.getRegistry(registry) if err != nil { return err } // 尝试 ping registry err = reg.Ping(ctx) if err != nil { return fmt.Errorf("registry health check failed: %w", err) } return nil }