diff --git a/AGENTS.md b/AGENTS.md index 98369f5..d575895 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ Recognized types: - `custom_files` Notes: -- `registry` is used as a target for image push operations. +- `registry` can be used as a **sync source** (enumerating repositories/tags and pulling manifests+blobs from a registry) and/or as a **push target** for `airgap registry push`. - `custom_files` is accepted as config type but not wired as a sync provider yet. ## Code Change Expectations diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d14152..de2ae59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project are documented in this file. +## 0.4.0 - 2026-02-26 + +### Added + +- **Registry as sync source**: the `registry` provider type can now act as a sync source, enumerating repository tags via the Docker Registry V2 API and downloading manifests/blobs locally. Configure with `repositories`, `tags` (glob filters), and `output_dir` fields. +- Registry provider UI form updated with sync source sections (repositories, tag filters, output directory) alongside existing push target fields. +- Sync history table on provider detail page showing the last 10 sync runs with duration, status, and transfer stats. +- Dashboard empty state with "Get Started" prompt when no providers are configured. +- Dynamic version display in sidebar (uses build-time `version` variable instead of hardcoded value). + ## 0.3.5 - 2026-02-24 ### Changed diff --git a/cmd/airgap/root.go b/cmd/airgap/root.go index f966e94..02b25e8 100644 --- a/cmd/airgap/root.go +++ b/cmd/airgap/root.go @@ -15,6 +15,7 @@ import ( "github.com/BadgerOps/airgap/internal/provider/containerimages" "github.com/BadgerOps/airgap/internal/provider/epel" "github.com/BadgerOps/airgap/internal/provider/ocp" + registryprovider "github.com/BadgerOps/airgap/internal/provider/registry" "github.com/BadgerOps/airgap/internal/store" "github.com/spf13/cobra" ) @@ -159,7 +160,9 @@ func createProvider(typeName, dataDir string, log *slog.Logger) (provider.Provid return ocp.NewRHCOSProvider(dataDir, log), nil case "container_images": return containerimages.NewProvider(dataDir, log), nil - case "registry", "custom_files": + case "registry": + return registryprovider.NewProvider(dataDir, log), nil + case "custom_files": return nil, fmt.Errorf("provider type %q is not yet implemented", typeName) default: return nil, fmt.Errorf("unknown provider type: %q", typeName) diff --git a/cmd/airgap/serve.go b/cmd/airgap/serve.go index 5e35594..dd8a02f 100644 --- a/cmd/airgap/serve.go +++ b/cmd/airgap/serve.go @@ -59,6 +59,7 @@ func serveRun(cmd *cobra.Command, args []string) error { // Create the HTTP server srv := server.NewServer(globalEngine, globalRegistry, globalStore, globalCfg, logger) + srv.SetVersion(version) // Channel to listen for errors from server errChan := make(chan error, 1) diff --git a/internal/config/config.go b/internal/config/config.go index 7f71cbd..8e87df6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,17 +99,24 @@ type ContainerImagesProviderConfig struct { OutputDir string `yaml:"output_dir"` } -// RegistryProviderConfig is the typed config for mirror-registry +// RegistryProviderConfig is the typed config for mirror-registry. +// When Repositories is non-empty the provider acts as a sync source, +// enumerating tags via the Docker Registry V2 API and downloading +// manifests/blobs locally. When Repositories is empty it is a push-only +// target used by `airgap registry push`. type RegistryProviderConfig struct { - Enabled bool `yaml:"enabled"` - MirrorRegistryBinary string `yaml:"mirror_registry_binary"` - QuayRoot string `yaml:"quay_root"` - Endpoint string `yaml:"endpoint"` - RepositoryPrefix string `yaml:"repository_prefix"` - Username string `yaml:"username"` - Password string `yaml:"password"` - InsecureSkipTLS bool `yaml:"insecure_skip_tls"` - SkopeoBinary string `yaml:"skopeo_binary"` + Enabled bool `yaml:"enabled"` + MirrorRegistryBinary string `yaml:"mirror_registry_binary"` + QuayRoot string `yaml:"quay_root"` + Endpoint string `yaml:"endpoint"` + RepositoryPrefix string `yaml:"repository_prefix"` + Username string `yaml:"username"` + Password string `yaml:"password"` + InsecureSkipTLS bool `yaml:"insecure_skip_tls"` + SkopeoBinary string `yaml:"skopeo_binary"` + Repositories []string `yaml:"repositories"` + Tags []string `yaml:"tags"` + OutputDir string `yaml:"output_dir"` } // CustomFilesProviderConfig is the typed config for custom file sources diff --git a/internal/provider/registry/provider.go b/internal/provider/registry/provider.go new file mode 100644 index 0000000..267b6eb --- /dev/null +++ b/internal/provider/registry/provider.go @@ -0,0 +1,860 @@ +package registry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/BadgerOps/airgap/internal/config" + "github.com/BadgerOps/airgap/internal/provider" + "github.com/BadgerOps/airgap/internal/safety" +) + +const ( + maxManifestBytes int64 = 16 * 1024 * 1024 + maxTokenBodyBytes int64 = 1 * 1024 * 1024 + maxTagListBytes int64 = 4 * 1024 * 1024 + defaultOutputDir = "registry-images" +) + +var ( + manifestAcceptHeader = strings.Join([]string{ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.v1+json", + }, ", ") + authParamRegexp = regexp.MustCompile(`([a-zA-Z_]+)="([^"]*)"`) + slugRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) +) + +// Provider syncs container images from a remote registry by enumerating +// repository tags via the Docker Registry V2 API, then downloading manifests +// and blobs locally. +type Provider struct { + name string + cfg *config.RegistryProviderConfig + dataDir string + logger *slog.Logger + http *http.Client + tokenByKey map[string]string +} + +// NewProvider creates a new registry sync source provider. +func NewProvider(dataDir string, logger *slog.Logger) *Provider { + if logger == nil { + logger = slog.Default() + } + return &Provider{ + name: "registry", + dataDir: dataDir, + logger: logger, + http: safety.NewHTTPClient(90 * time.Second), + tokenByKey: make(map[string]string), + } +} + +func (p *Provider) Name() string { return p.name } +func (p *Provider) SetName(n string) { p.name = n } +func (p *Provider) Type() string { return "registry" } + +func (p *Provider) Configure(rawCfg provider.ProviderConfig) error { + cfg, err := config.ParseProviderConfig[config.RegistryProviderConfig](rawCfg) + if err != nil { + return fmt.Errorf("parsing registry config: %w", err) + } + if cfg.Endpoint == "" { + return fmt.Errorf("registry endpoint is required") + } + if len(cfg.Repositories) == 0 { + return fmt.Errorf("at least one repository is required for registry sync source") + } + if cfg.OutputDir == "" { + cfg.OutputDir = defaultOutputDir + } + if _, err := safety.CleanRelativePath(cfg.OutputDir); err != nil { + return fmt.Errorf("invalid output_dir: %w", err) + } + + // Normalize/deduplicate repositories + seen := make(map[string]struct{}) + repos := make([]string, 0, len(cfg.Repositories)) + for _, r := range cfg.Repositories { + r = strings.TrimSpace(r) + if r == "" { + continue + } + r = strings.Trim(r, "/") + if _, ok := seen[r]; ok { + continue + } + seen[r] = struct{}{} + repos = append(repos, r) + } + cfg.Repositories = repos + + p.cfg = cfg + p.logger.Debug("configured registry sync source", + slog.String("endpoint", cfg.Endpoint), + slog.Int("repositories", len(cfg.Repositories)), + slog.Int("tag_filters", len(cfg.Tags)), + slog.String("output_dir", cfg.OutputDir), + ) + return nil +} + +// Plan enumerates tags for each configured repository, then builds a download +// plan of manifests and blobs for matching images. +func (p *Provider) Plan(ctx context.Context) (*provider.SyncPlan, error) { + if p.cfg == nil { + return nil, fmt.Errorf("provider not configured") + } + + plan := &provider.SyncPlan{ + Provider: p.Name(), + Actions: []provider.SyncAction{}, + Timestamp: time.Now(), + } + + endpointHost := normalizeEndpointHost(p.cfg.Endpoint) + + actionsByPath := make(map[string]provider.SyncAction) + for _, repo := range p.cfg.Repositories { + tags, err := p.listTags(ctx, endpointHost, repo) + if err != nil { + p.logger.Error("failed to list tags", "repo", repo, "error", err) + continue + } + + matched := filterTags(tags, p.cfg.Tags) + p.logger.Debug("discovered tags", "repo", repo, "total", len(tags), "matched", len(matched)) + + for _, tag := range matched { + ref := imageReference{ + Registry: p.cfg.Endpoint, + EndpointHost: endpointHost, + Repository: repo, + Reference: tag, + } + imageActions, err := p.planImage(ctx, ref) + if err != nil { + p.logger.Error("failed to plan image", "repo", repo, "tag", tag, "error", err) + continue + } + for _, action := range imageActions { + if _, ok := actionsByPath[action.Path]; !ok { + actionsByPath[action.Path] = action + } + } + } + } + + keys := make([]string, 0, len(actionsByPath)) + for path := range actionsByPath { + keys = append(keys, path) + } + sort.Strings(keys) + + for _, path := range keys { + action := actionsByPath[path] + plan.Actions = append(plan.Actions, action) + plan.TotalFiles++ + if action.Action == provider.ActionDownload || action.Action == provider.ActionUpdate { + plan.TotalSize += action.Size + } + } + + return plan, nil +} + +func (p *Provider) Sync(_ context.Context, plan *provider.SyncPlan, opts provider.SyncOptions) (*provider.SyncReport, error) { + report := &provider.SyncReport{ + Provider: p.Name(), + StartTime: time.Now(), + Failed: []provider.FailedFile{}, + } + if opts.DryRun { + report.EndTime = time.Now() + return report, nil + } + for _, action := range plan.Actions { + switch action.Action { + case provider.ActionSkip: + report.Skipped++ + case provider.ActionDownload, provider.ActionUpdate: + report.Downloaded++ + report.BytesTransferred += action.Size + case provider.ActionDelete: + report.Deleted++ + } + } + report.EndTime = time.Now() + return report, nil +} + +func (p *Provider) Validate(ctx context.Context) (*provider.ValidationReport, error) { + if p.cfg == nil { + return nil, fmt.Errorf("provider not configured") + } + + report := &provider.ValidationReport{ + Provider: p.Name(), + InvalidFiles: []provider.ValidationResult{}, + Timestamp: time.Now(), + } + + providerRoot, err := safety.SafeJoinUnder(p.dataDir, p.Name()) + if err != nil { + return nil, fmt.Errorf("invalid provider root: %w", err) + } + validateRoot, err := safety.SafeJoinUnder(providerRoot, p.cfg.OutputDir) + if err != nil { + return nil, fmt.Errorf("invalid validate root: %w", err) + } + if _, err := os.Stat(validateRoot); os.IsNotExist(err) { + return report, nil + } + + err = filepath.Walk(validateRoot, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil || info.IsDir() { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + report.TotalFiles++ + relPath, relErr := filepath.Rel(providerRoot, path) + if relErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: path, LocalPath: path, Actual: "error: " + relErr.Error(), Size: info.Size(), + }) + return nil + } + relPath = filepath.ToSlash(relPath) + + expectedDigest, ok := expectedDigestFromPath(relPath) + if !ok || !strings.HasPrefix(expectedDigest, "sha256:") { + report.ValidFiles++ + return nil + } + + actual, hashErr := checksumLocalFile(path) + if hashErr != nil { + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, LocalPath: path, + Expected: strings.TrimPrefix(expectedDigest, "sha256:"), + Actual: "error: " + hashErr.Error(), Size: info.Size(), + }) + return nil + } + + expectedHash := strings.TrimPrefix(expectedDigest, "sha256:") + if actual == expectedHash { + report.ValidFiles++ + return nil + } + + report.InvalidFiles = append(report.InvalidFiles, provider.ValidationResult{ + Path: relPath, LocalPath: path, + Expected: expectedHash, Actual: actual, Size: info.Size(), + }) + return nil + }) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, fmt.Errorf("validation failed: %w", err) + } + + return report, nil +} + +// --- Registry V2 API helpers --- + +// listTags fetches the tag list for a repository from the V2 API. +func (p *Provider) listTags(ctx context.Context, endpointHost, repo string) ([]string, error) { + tagsURL := fmt.Sprintf("https://%s/v2/%s/tags/list", endpointHost, strings.Trim(repo, "/")) + scope := fmt.Sprintf("repository:%s:pull", repo) + + body, _, _, err := p.registryGET(ctx, tagsURL, "application/json", scope) + if err != nil { + return nil, fmt.Errorf("listing tags for %s: %w", repo, err) + } + + var resp struct { + Tags []string `json:"tags"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("parsing tag list for %s: %w", repo, err) + } + sort.Strings(resp.Tags) + return resp.Tags, nil +} + +// filterTags returns tags matching any of the given glob patterns. +// If patterns is empty, all tags are returned. +func filterTags(tags, patterns []string) []string { + if len(patterns) == 0 { + return tags + } + var matched []string + for _, tag := range tags { + for _, pat := range patterns { + ok, _ := filepath.Match(pat, tag) + if ok || pat == tag { + matched = append(matched, tag) + break + } + } + } + return matched +} + +// --- Image planning (mirrors containerimages logic) --- + +type imageReference struct { + Registry string + EndpointHost string + Repository string + Reference string + IsDigest bool +} + +type descriptor struct { + MediaType string `json:"mediaType"` + Digest string `json:"digest"` + Size int64 `json:"size"` +} + +type imageIndexManifest struct { + MediaType string `json:"mediaType"` + SchemaVersion int `json:"schemaVersion"` + Manifests []descriptor `json:"manifests"` +} + +type imageManifest struct { + MediaType string `json:"mediaType"` + SchemaVersion int `json:"schemaVersion"` + Config descriptor `json:"config"` + Layers []descriptor `json:"layers"` +} + +func (p *Provider) planImage(ctx context.Context, ref imageReference) ([]provider.SyncAction, error) { + rootDesc, rootBody, authHeader, err := p.fetchManifest(ctx, ref, ref.Reference) + if err != nil { + return nil, err + } + if rootDesc.Digest == "" { + return nil, fmt.Errorf("manifest digest missing for %s/%s:%s", ref.Registry, ref.Repository, ref.Reference) + } + + type queueItem struct { + desc descriptor + body []byte + authHeader string + } + + queue := []queueItem{{desc: rootDesc, body: rootBody, authHeader: authHeader}} + seenManifest := make(map[string]struct{}) + seenBlob := make(map[string]struct{}) + var actions []provider.SyncAction + + imageID := imagePathID(ref) + + for len(queue) > 0 { + item := queue[0] + queue = queue[1:] + + if _, ok := seenManifest[item.desc.Digest]; ok { + continue + } + seenManifest[item.desc.Digest] = struct{}{} + + action, err := p.newManifestAction(ref, imageID, item.desc, item.authHeader) + if err != nil { + return nil, err + } + actions = append(actions, action) + + childManifests, childBlobs, err := parseManifestDependencies(item.desc.MediaType, item.body) + if err != nil { + p.logger.Warn("manifest parsing failed", "digest", item.desc.Digest, "error", err) + continue + } + + for _, child := range childManifests { + if child.Digest == "" { + continue + } + if _, ok := seenManifest[child.Digest]; ok { + continue + } + childDesc, childBody, childAuth, err := p.fetchManifest(ctx, ref, child.Digest) + if err != nil { + return nil, fmt.Errorf("fetching child manifest %s: %w", child.Digest, err) + } + queue = append(queue, queueItem{desc: childDesc, body: childBody, authHeader: childAuth}) + } + + for _, blob := range childBlobs { + if blob.Digest == "" { + continue + } + if _, ok := seenBlob[blob.Digest]; ok { + continue + } + seenBlob[blob.Digest] = struct{}{} + blobAction, err := p.newBlobAction(ref, imageID, blob, item.authHeader) + if err != nil { + return nil, err + } + actions = append(actions, blobAction) + } + } + + return actions, nil +} + +func parseManifestDependencies(mediaType string, body []byte) ([]descriptor, []descriptor, error) { + kind := manifestKind(mediaType) + switch kind { + case "index": + var idx imageIndexManifest + if err := json.Unmarshal(body, &idx); err != nil { + return nil, nil, err + } + return idx.Manifests, nil, nil + case "manifest": + var mf imageManifest + if err := json.Unmarshal(body, &mf); err != nil { + return nil, nil, err + } + blobs := make([]descriptor, 0, len(mf.Layers)+1) + if mf.Config.Digest != "" { + blobs = append(blobs, mf.Config) + } + blobs = append(blobs, mf.Layers...) + return nil, blobs, nil + default: + var idx imageIndexManifest + if err := json.Unmarshal(body, &idx); err == nil && len(idx.Manifests) > 0 { + return idx.Manifests, nil, nil + } + var mf imageManifest + if err := json.Unmarshal(body, &mf); err == nil && (mf.Config.Digest != "" || len(mf.Layers) > 0) { + blobs := make([]descriptor, 0, len(mf.Layers)+1) + if mf.Config.Digest != "" { + blobs = append(blobs, mf.Config) + } + blobs = append(blobs, mf.Layers...) + return nil, blobs, nil + } + return nil, nil, fmt.Errorf("unrecognized manifest media type %q", mediaType) + } +} + +func manifestKind(mediaType string) string { + mt := strings.ToLower(strings.TrimSpace(strings.Split(mediaType, ";")[0])) + switch mt { + case "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json": + return "index" + case "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.docker.distribution.manifest.v1+json": + return "manifest" + default: + return "" + } +} + +// --- Registry HTTP helpers --- + +func (p *Provider) fetchManifest(ctx context.Context, ref imageReference, manifestRef string) (descriptor, []byte, string, error) { + scope := fmt.Sprintf("repository:%s:pull", ref.Repository) + manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", + ref.EndpointHost, strings.Trim(ref.Repository, "/"), manifestRef) + body, headers, authHeader, err := p.registryGET(ctx, manifestURL, manifestAcceptHeader, scope) + if err != nil { + return descriptor{}, nil, "", err + } + + contentType := strings.TrimSpace(strings.Split(headers.Get("Content-Type"), ";")[0]) + digest := strings.TrimSpace(headers.Get("Docker-Content-Digest")) + if digest == "" { + sum := sha256.Sum256(body) + digest = "sha256:" + hex.EncodeToString(sum[:]) + } + if _, _, err := parseDigest(digest); err != nil { + return descriptor{}, nil, "", fmt.Errorf("invalid digest %q: %w", digest, err) + } + + return descriptor{ + MediaType: contentType, + Digest: digest, + Size: int64(len(body)), + }, body, authHeader, nil +} + +func (p *Provider) registryGET(ctx context.Context, endpoint, accept, scope string) ([]byte, http.Header, string, error) { + tokenKey := endpoint + "|" + scope + var authHeader string + if token := p.tokenByKey[tokenKey]; token != "" { + authHeader = "Bearer " + token + } + // If basic auth is configured, prefer it over cached bearer tokens + if p.cfg != nil && p.cfg.Username != "" && authHeader == "" { + authHeader = "" + } + + for attempt := 0; attempt < 2; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, nil, "", fmt.Errorf("creating request: %w", err) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } else if p.cfg != nil && p.cfg.Username != "" { + req.SetBasicAuth(p.cfg.Username, p.cfg.Password) + } + + resp, err := p.http.Do(req) + if err != nil { + return nil, nil, "", fmt.Errorf("executing request: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { + challenge := resp.Header.Get("WWW-Authenticate") + if closeErr := resp.Body.Close(); closeErr != nil { + p.logger.Warn("failed to close unauthorized response body", "error", closeErr) + } + token, err := p.fetchBearerToken(ctx, challenge, scope) + if err != nil { + return nil, nil, "", fmt.Errorf("fetching bearer token: %w", err) + } + authHeader = "Bearer " + token + p.tokenByKey[tokenKey] = token + continue + } + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if closeErr := resp.Body.Close(); closeErr != nil { + p.logger.Warn("failed to close error response body", "error", closeErr) + } + return nil, nil, "", fmt.Errorf("registry returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + data, err := safety.ReadAllWithLimit(resp.Body, maxManifestBytes) + if closeErr := resp.Body.Close(); closeErr != nil { + p.logger.Warn("failed to close response body", "error", closeErr) + } + if err != nil { + return nil, nil, "", fmt.Errorf("reading response body: %w", err) + } + return data, resp.Header.Clone(), authHeader, nil + } + + return nil, nil, "", fmt.Errorf("registry authentication failed") +} + +func (p *Provider) fetchBearerToken(ctx context.Context, challenge, scope string) (string, error) { + if challenge == "" { + return "", fmt.Errorf("missing WWW-Authenticate challenge") + } + if !strings.HasPrefix(strings.ToLower(challenge), "bearer ") { + return "", fmt.Errorf("unsupported auth challenge: %q", challenge) + } + + params := parseAuthParams(challenge) + realm := params["realm"] + if realm == "" { + return "", fmt.Errorf("bearer challenge missing realm") + } + + values := url.Values{} + if service := params["service"]; service != "" { + values.Set("service", service) + } + tokenScope := params["scope"] + if tokenScope == "" { + tokenScope = scope + } + if tokenScope != "" { + values.Set("scope", tokenScope) + } + + tokenURL := realm + if encoded := values.Encode(); encoded != "" { + if strings.Contains(tokenURL, "?") { + tokenURL += "&" + encoded + } else { + tokenURL += "?" + encoded + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err != nil { + return "", fmt.Errorf("creating token request: %w", err) + } + // Pass basic auth to token endpoint if configured (for private registries) + if p.cfg != nil && p.cfg.Username != "" { + req.SetBasicAuth(p.cfg.Username, p.cfg.Password) + } + + resp, err := p.http.Do(req) + if err != nil { + return "", fmt.Errorf("executing token request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", fmt.Errorf("token endpoint returned %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + + data, err := safety.ReadAllWithLimit(resp.Body, maxTokenBodyBytes) + if err != nil { + return "", fmt.Errorf("reading token response: %w", err) + } + var tokenResp struct { + Token string `json:"token"` + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(data, &tokenResp); err != nil { + return "", fmt.Errorf("parsing token response: %w", err) + } + token := tokenResp.Token + if token == "" { + token = tokenResp.AccessToken + } + if token == "" { + return "", fmt.Errorf("token response did not include token") + } + return token, nil +} + +func parseAuthParams(challenge string) map[string]string { + result := make(map[string]string) + trimmed := strings.TrimSpace(challenge) + if strings.HasPrefix(strings.ToLower(trimmed), "bearer ") { + trimmed = strings.TrimSpace(trimmed[len("bearer "):]) + } + matches := authParamRegexp.FindAllStringSubmatch(trimmed, -1) + for _, m := range matches { + if len(m) == 3 { + result[strings.ToLower(m[1])] = m[2] + } + } + return result +} + +// --- Action builders --- + +func (p *Provider) newManifestAction(ref imageReference, imageID string, desc descriptor, authHeader string) (provider.SyncAction, error) { + algo, hash, err := parseDigest(desc.Digest) + if err != nil { + return provider.SyncAction{}, fmt.Errorf("invalid manifest digest %q: %w", desc.Digest, err) + } + relPath := filepath.ToSlash(filepath.Join(p.cfg.OutputDir, imageID, "manifests", algo, hash+".json")) + headers := make(map[string]string) + if authHeader != "" { + headers["Authorization"] = authHeader + } + headers["Accept"] = manifestAcceptHeader + manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", + ref.EndpointHost, strings.Trim(ref.Repository, "/"), desc.Digest) + expectedChecksum := expectedChecksumForDigest(algo, hash) + return p.buildAction(relPath, manifestURL, expectedChecksum, desc.Size, headers), nil +} + +func (p *Provider) newBlobAction(ref imageReference, imageID string, desc descriptor, authHeader string) (provider.SyncAction, error) { + algo, hash, err := parseDigest(desc.Digest) + if err != nil { + return provider.SyncAction{}, fmt.Errorf("invalid blob digest %q: %w", desc.Digest, err) + } + relPath := filepath.ToSlash(filepath.Join(p.cfg.OutputDir, imageID, "blobs", algo, hash)) + headers := make(map[string]string) + if authHeader != "" { + headers["Authorization"] = authHeader + } + blobURL := fmt.Sprintf("https://%s/v2/%s/blobs/%s", + ref.EndpointHost, strings.Trim(ref.Repository, "/"), desc.Digest) + expectedChecksum := expectedChecksumForDigest(algo, hash) + return p.buildAction(relPath, blobURL, expectedChecksum, desc.Size, headers), nil +} + +func (p *Provider) buildAction(relPath, sourceURL, expectedChecksum string, expectedSize int64, headers map[string]string) provider.SyncAction { + providerRoot := filepath.Join(p.dataDir, p.Name()) + localPath, err := safety.SafeJoinUnder(providerRoot, relPath) + if err != nil { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionDownload, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "invalid local path, redownload", Headers: headers, + } + } + + info, statErr := os.Stat(localPath) + if os.IsNotExist(statErr) { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionDownload, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "new artifact", Headers: headers, + } + } + if statErr != nil { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionUpdate, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "cannot stat local artifact", Headers: headers, + } + } + + if expectedChecksum != "" { + actual, err := checksumLocalFile(localPath) + if err != nil { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionUpdate, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "checksum failed", Headers: headers, + } + } + if actual == expectedChecksum { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionSkip, Size: info.Size(), + Checksum: expectedChecksum, URL: sourceURL, Reason: "checksum matches", Headers: headers, + } + } + return provider.SyncAction{ + Path: relPath, Action: provider.ActionUpdate, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "checksum mismatch", Headers: headers, + } + } + + if expectedSize > 0 && info.Size() == expectedSize { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionSkip, Size: info.Size(), + Checksum: expectedChecksum, URL: sourceURL, Reason: "size matches", Headers: headers, + } + } + if expectedSize > 0 && info.Size() != expectedSize { + return provider.SyncAction{ + Path: relPath, Action: provider.ActionUpdate, Size: expectedSize, + Checksum: expectedChecksum, URL: sourceURL, Reason: "size mismatch", Headers: headers, + } + } + + return provider.SyncAction{ + Path: relPath, Action: provider.ActionSkip, Size: info.Size(), + Checksum: expectedChecksum, URL: sourceURL, Reason: "file exists", Headers: headers, + } +} + +// --- Utility helpers --- + +func normalizeEndpointHost(endpoint string) string { + e := strings.TrimSpace(endpoint) + e = strings.TrimPrefix(e, "https://") + e = strings.TrimPrefix(e, "http://") + e = strings.TrimRight(e, "/") + if e == "docker.io" || e == "index.docker.io" { + return "registry-1.docker.io" + } + return e +} + +func imagePathID(ref imageReference) string { + label := ref.Registry + "/" + ref.Repository + if ref.IsDigest { + label += "@" + ref.Reference + } else { + label += ":" + ref.Reference + } + slug := slugRegexp.ReplaceAllString(label, "_") + slug = strings.Trim(slug, "._-") + if slug == "" { + return "image" + } + return slug +} + +func expectedChecksumForDigest(algo, hexPart string) string { + if strings.EqualFold(algo, "sha256") { + return strings.ToLower(hexPart) + } + return "" +} + +func parseDigest(digest string) (string, string, error) { + parts := strings.SplitN(strings.TrimSpace(digest), ":", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("expected format algorithm:hex") + } + algo := strings.ToLower(strings.TrimSpace(parts[0])) + hexPart := strings.ToLower(strings.TrimSpace(parts[1])) + if algo == "" || hexPart == "" { + return "", "", fmt.Errorf("empty digest component") + } + for _, c := range algo { + ok := (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '+' || c == '-' + if !ok { + return "", "", fmt.Errorf("invalid digest algorithm %q", algo) + } + } + for _, c := range hexPart { + isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') + if !isHex { + return "", "", fmt.Errorf("invalid digest hex") + } + } + return algo, hexPart, nil +} + +func checksumLocalFile(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer func() { _ = f.Close() }() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func expectedDigestFromPath(relPath string) (string, bool) { + parts := strings.Split(filepath.ToSlash(relPath), "/") + for i := 0; i+2 < len(parts); i++ { + if parts[i] != "blobs" && parts[i] != "manifests" { + continue + } + algo := strings.ToLower(parts[i+1]) + hashPart := strings.TrimSuffix(strings.ToLower(parts[i+2]), ".json") + if algo == "" || hashPart == "" { + continue + } + if _, _, err := parseDigest(algo + ":" + hashPart); err != nil { + continue + } + return algo + ":" + hashPart, true + } + return "", false +} diff --git a/internal/provider/registry/provider_test.go b/internal/provider/registry/provider_test.go new file mode 100644 index 0000000..fb9e7cd --- /dev/null +++ b/internal/provider/registry/provider_test.go @@ -0,0 +1,470 @@ +package registry + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BadgerOps/airgap/internal/provider" +) + +func TestConfigure_MissingEndpoint(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + err := p.Configure(provider.ProviderConfig{ + "repositories": []interface{}{"library/alpine"}, + }) + if err == nil || !strings.Contains(err.Error(), "endpoint") { + t.Fatalf("expected endpoint error, got %v", err) + } +} + +func TestConfigure_MissingRepositories(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + }) + if err == nil || !strings.Contains(err.Error(), "repository") { + t.Fatalf("expected repository error, got %v", err) + } +} + +func TestConfigure_Success(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo", "org/repo2"}, + "tags": []interface{}{"v1.*", "latest"}, + "output_dir": "my-images", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.cfg.Endpoint != "quay.io" { + t.Errorf("expected endpoint quay.io, got %s", p.cfg.Endpoint) + } + if len(p.cfg.Repositories) != 2 { + t.Errorf("expected 2 repositories, got %d", len(p.cfg.Repositories)) + } + if p.cfg.OutputDir != "my-images" { + t.Errorf("expected output_dir my-images, got %s", p.cfg.OutputDir) + } +} + +func TestConfigure_DefaultOutputDir(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.cfg.OutputDir != "registry-images" { + t.Errorf("expected default output_dir registry-images, got %s", p.cfg.OutputDir) + } +} + +func TestConfigure_DeduplicatesRepositories(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo", "org/repo", "org/other"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(p.cfg.Repositories) != 2 { + t.Errorf("expected 2 unique repositories, got %d: %v", len(p.cfg.Repositories), p.cfg.Repositories) + } +} + +func TestFilterTags_NoPatterns(t *testing.T) { + tags := []string{"v1.0", "v1.1", "latest", "nightly"} + result := filterTags(tags, nil) + if len(result) != len(tags) { + t.Errorf("expected all %d tags, got %d", len(tags), len(result)) + } +} + +func TestFilterTags_WithPatterns(t *testing.T) { + tags := []string{"v1.0", "v1.1", "v2.0", "latest", "nightly"} + result := filterTags(tags, []string{"v1.*", "latest"}) + expected := map[string]bool{"v1.0": true, "v1.1": true, "latest": true} + if len(result) != len(expected) { + t.Errorf("expected %d tags, got %d: %v", len(expected), len(result), result) + } + for _, tag := range result { + if !expected[tag] { + t.Errorf("unexpected tag %q in result", tag) + } + } +} + +func TestFilterTags_ExactMatch(t *testing.T) { + tags := []string{"4.16.35", "4.17.0"} + result := filterTags(tags, []string{"4.16.35"}) + if len(result) != 1 || result[0] != "4.16.35" { + t.Errorf("expected [4.16.35], got %v", result) + } +} + +func TestNormalizeEndpointHost(t *testing.T) { + tests := []struct { + in, want string + }{ + {"quay.io", "quay.io"}, + {"https://quay.io", "quay.io"}, + {"http://quay.io/", "quay.io"}, + {"docker.io", "registry-1.docker.io"}, + {"index.docker.io", "registry-1.docker.io"}, + {"myregistry.example.com:5000", "myregistry.example.com:5000"}, + } + for _, tt := range tests { + got := normalizeEndpointHost(tt.in) + if got != tt.want { + t.Errorf("normalizeEndpointHost(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestImagePathID(t *testing.T) { + ref := imageReference{ + Registry: "quay.io", + Repository: "org/repo", + Reference: "v1.0", + } + id := imagePathID(ref) + if id == "" { + t.Error("expected non-empty image path ID") + } + if strings.Contains(id, "/") { + t.Errorf("image path ID should not contain slashes: %q", id) + } + if !strings.Contains(id, "quay.io") || !strings.Contains(id, "org") { + t.Errorf("expected image path ID to contain registry/org info: %q", id) + } +} + +func TestParseDigest(t *testing.T) { + algo, hash, err := parseDigest("sha256:abcdef0123456789") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if algo != "sha256" || hash != "abcdef0123456789" { + t.Errorf("got algo=%q hash=%q", algo, hash) + } + + _, _, err = parseDigest("invalid") + if err == nil { + t.Error("expected error for invalid digest") + } + + _, _, err = parseDigest("sha256:") + if err == nil { + t.Error("expected error for empty hex") + } +} + +func TestNameAndType(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + if p.Name() != "registry" { + t.Errorf("expected name 'registry', got %q", p.Name()) + } + if p.Type() != "registry" { + t.Errorf("expected type 'registry', got %q", p.Type()) + } + p.SetName("my-registry") + if p.Name() != "my-registry" { + t.Errorf("expected name 'my-registry' after SetName, got %q", p.Name()) + } +} + +func TestPlan_WithMockRegistry(t *testing.T) { + // Build a minimal manifest + configBlob := []byte(`{"architecture":"amd64"}`) + configDigest := "sha256:" + sha256Hex(configBlob) + + layerBlob := []byte("fake layer data") + layerDigest := "sha256:" + sha256Hex(layerBlob) + + manifest := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "config": map[string]interface{}{ + "mediaType": "application/vnd.oci.image.config.v1+json", + "digest": configDigest, + "size": len(configBlob), + }, + "layers": []map[string]interface{}{ + { + "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", + "digest": layerDigest, + "size": len(layerBlob), + }, + }, + } + manifestBytes, _ := json.Marshal(manifest) + manifestDigest := "sha256:" + sha256Hex(manifestBytes) + + mux := http.NewServeMux() + + // Tags list endpoint + mux.HandleFunc("/v2/org/repo/tags/list", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "name": "org/repo", + "tags": []string{"v1.0", "v2.0"}, + }) + }) + + // Manifest endpoint (both by tag and by digest) + mux.HandleFunc("/v2/org/repo/manifests/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Header().Set("Docker-Content-Digest", manifestDigest) + _, _ = w.Write(manifestBytes) + }) + + server := httptest.NewTLSServer(mux) + defer server.Close() + + // Extract host from server URL + host := strings.TrimPrefix(server.URL, "https://") + + dataDir := t.TempDir() + p := NewProvider(dataDir, nil) + p.name = "test-registry" + p.http = server.Client() + + err := p.Configure(provider.ProviderConfig{ + "endpoint": host, + "repositories": []interface{}{"org/repo"}, + "tags": []interface{}{"v1.*"}, + "output_dir": "images", + }) + if err != nil { + t.Fatalf("configure error: %v", err) + } + + plan, err := p.Plan(context.Background()) + if err != nil { + t.Fatalf("plan error: %v", err) + } + + if plan.Provider != "test-registry" { + t.Errorf("expected provider 'test-registry', got %q", plan.Provider) + } + + // Should have: 1 manifest + 1 config blob + 1 layer blob = 3 actions + // (only v1.0 matches the filter, v2.0 is excluded) + if len(plan.Actions) != 3 { + t.Errorf("expected 3 actions (manifest + config + layer), got %d", len(plan.Actions)) + for _, a := range plan.Actions { + t.Logf(" action: %s %s (reason: %s)", a.Action, a.Path, a.Reason) + } + } + + // Verify all actions are downloads (nothing local yet) + for _, a := range plan.Actions { + if a.Action != provider.ActionDownload { + t.Errorf("expected download action, got %s for %s", a.Action, a.Path) + } + } +} + +func TestValidate_EmptyDir(t *testing.T) { + dataDir := t.TempDir() + p := NewProvider(dataDir, nil) + p.SetName("test-reg") + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo"}, + }) + if err != nil { + t.Fatalf("configure error: %v", err) + } + + report, err := p.Validate(context.Background()) + if err != nil { + t.Fatalf("validate error: %v", err) + } + if report.TotalFiles != 0 { + t.Errorf("expected 0 files in empty dir, got %d", report.TotalFiles) + } +} + +func TestValidate_ValidFile(t *testing.T) { + dataDir := t.TempDir() + p := NewProvider(dataDir, nil) + p.SetName("test-reg") + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo"}, + "output_dir": "images", + }) + if err != nil { + t.Fatalf("configure error: %v", err) + } + + // Create a file with valid checksum embedded in path + content := []byte("test manifest content") + hash := sha256Hex(content) + manifestDir := filepath.Join(dataDir, "test-reg", "images", "test-image", "manifests", "sha256") + if err := os.MkdirAll(manifestDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestDir, hash+".json"), content, 0o644); err != nil { + t.Fatal(err) + } + + report, err := p.Validate(context.Background()) + if err != nil { + t.Fatalf("validate error: %v", err) + } + if report.TotalFiles != 1 { + t.Errorf("expected 1 file, got %d", report.TotalFiles) + } + if report.ValidFiles != 1 { + t.Errorf("expected 1 valid file, got %d", report.ValidFiles) + } + if len(report.InvalidFiles) != 0 { + t.Errorf("expected 0 invalid files, got %d", len(report.InvalidFiles)) + } +} + +func TestValidate_InvalidFile(t *testing.T) { + dataDir := t.TempDir() + p := NewProvider(dataDir, nil) + p.SetName("test-reg") + err := p.Configure(provider.ProviderConfig{ + "endpoint": "quay.io", + "repositories": []interface{}{"org/repo"}, + "output_dir": "images", + }) + if err != nil { + t.Fatal(err) + } + + // Create a file with wrong checksum + content := []byte("corrupted data") + wrongHash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + manifestDir := filepath.Join(dataDir, "test-reg", "images", "test-image", "manifests", "sha256") + if err := os.MkdirAll(manifestDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestDir, wrongHash+".json"), content, 0o644); err != nil { + t.Fatal(err) + } + + report, err := p.Validate(context.Background()) + if err != nil { + t.Fatalf("validate error: %v", err) + } + if report.TotalFiles != 1 { + t.Errorf("expected 1 file, got %d", report.TotalFiles) + } + if len(report.InvalidFiles) != 1 { + t.Errorf("expected 1 invalid file, got %d", len(report.InvalidFiles)) + } +} + +func TestSync_DryRun(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + plan := &provider.SyncPlan{ + Provider: "test", + Actions: []provider.SyncAction{ + {Path: "a", Action: provider.ActionDownload, Size: 100}, + {Path: "b", Action: provider.ActionSkip, Size: 50}, + }, + } + report, err := p.Sync(context.Background(), plan, provider.SyncOptions{DryRun: true}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if report.Downloaded != 0 { + t.Errorf("dry run should have 0 downloaded, got %d", report.Downloaded) + } +} + +func TestSync_CountsActions(t *testing.T) { + p := NewProvider(t.TempDir(), nil) + plan := &provider.SyncPlan{ + Provider: "test", + Actions: []provider.SyncAction{ + {Path: "a", Action: provider.ActionDownload, Size: 100}, + {Path: "b", Action: provider.ActionSkip, Size: 50}, + {Path: "c", Action: provider.ActionUpdate, Size: 200}, + {Path: "d", Action: provider.ActionDelete}, + }, + } + report, err := p.Sync(context.Background(), plan, provider.SyncOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if report.Downloaded != 2 { + t.Errorf("expected 2 downloaded, got %d", report.Downloaded) + } + if report.Skipped != 1 { + t.Errorf("expected 1 skipped, got %d", report.Skipped) + } + if report.Deleted != 1 { + t.Errorf("expected 1 deleted, got %d", report.Deleted) + } + if report.BytesTransferred != 300 { + t.Errorf("expected 300 bytes, got %d", report.BytesTransferred) + } +} + +func TestExpectedDigestFromPath(t *testing.T) { + tests := []struct { + path string + want string + ok bool + }{ + {"images/test/manifests/sha256/abc123.json", "sha256:abc123", true}, + {"images/test/blobs/sha256/def456", "sha256:def456", true}, + {"images/test/other/sha256/xyz", "", false}, + {"random/file.txt", "", false}, + } + for _, tt := range tests { + got, ok := expectedDigestFromPath(tt.path) + if ok != tt.ok || got != tt.want { + t.Errorf("expectedDigestFromPath(%q) = (%q, %v), want (%q, %v)", tt.path, got, ok, tt.want, tt.ok) + } + } +} + +func TestListTags_WithMockServer(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v2/org/repo/tags/list", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"name":"org/repo","tags":["v1.0","v2.0","latest"]}`) + }) + + server := httptest.NewTLSServer(mux) + defer server.Close() + + host := strings.TrimPrefix(server.URL, "https://") + p := NewProvider(t.TempDir(), nil) + p.http = server.Client() + + tags, err := p.listTags(context.Background(), host, "org/repo") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tags) != 3 { + t.Errorf("expected 3 tags, got %d: %v", len(tags), tags) + } +} + +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 20d3f40..7b22ade 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -108,6 +108,16 @@ func (s *Server) handleProviderDetail(w http.ResponseWriter, r *http.Request) { syncRunning := s.syncRunning s.syncMu.Unlock() + var syncRuns []store.SyncRun + if s.store != nil { + runs, err := s.store.ListSyncRuns(providerName, 10) + if err != nil { + s.logger.Warn("failed to load sync runs", "provider", providerName, "error", err) + } else { + syncRuns = runs + } + } + data := map[string]interface{}{ "Title": "Provider: " + providerName, "Provider": providerName, @@ -116,6 +126,7 @@ func (s *Server) handleProviderDetail(w http.ResponseWriter, r *http.Request) { "SyncRunning": syncRunning, "CanPushToRegistry": canPushToRegistry, "RegistryTargets": registryTargets, + "SyncRuns": syncRuns, } s.renderTemplate(w, "templates/provider_detail.html", data) diff --git a/internal/server/server.go b/internal/server/server.go index e0c2c7c..072ff5c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -35,6 +35,7 @@ type Server struct { ocpClients *ocp.ClientService httpServer *http.Server templates map[string]*template.Template + version string // Active sync state syncMu sync.Mutex @@ -42,6 +43,11 @@ type Server struct { syncRunning bool } +// SetVersion sets the version string displayed in the UI. +func (s *Server) SetVersion(v string) { + s.version = v +} + // NewServer creates a new Server instance. func NewServer( eng *engine.SyncManager, @@ -136,6 +142,10 @@ func (s *Server) renderTemplate(w http.ResponseWriter, page string, data interfa http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + // Inject version into template data if it's a map + if m, ok := data.(map[string]interface{}); ok && s.version != "" { + m["Version"] = s.version + } if err := t.ExecuteTemplate(w, "layout.html", data); err != nil { s.logger.Error("failed to render template", "page", page, "error", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/internal/server/templates.go b/internal/server/templates.go index 88e4785..e6841ef 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -11,10 +11,18 @@ func initializeTemplateFuncs() template.FuncMap { return template.FuncMap{ "formatBytes": formatBytes, "formatTime": formatTime, - "formatDuration": formatDuration, + "formatDuration": formatDurationBetween, } } +// formatDurationBetween computes the duration between two times and formats it. +func formatDurationBetween(start, end time.Time) string { + if start.IsZero() || end.IsZero() { + return "-" + } + return formatDuration(end.Sub(start)) +} + // formatBytes converts a byte count to a human-readable format. func formatBytes(bytes int64) string { const unit = 1024 diff --git a/internal/server/templates/dashboard.html b/internal/server/templates/dashboard.html index 1cfb5fd..d366724 100644 --- a/internal/server/templates/dashboard.html +++ b/internal/server/templates/dashboard.html @@ -101,6 +101,16 @@
Get started by adding your first content provider to begin syncing.
+ Go to Providers +Last {{len .SyncRuns}} sync operations for this provider.
+| Started | +Duration | +Status | +Downloaded | +Skipped | +Failed | +Size | +
|---|---|---|---|---|---|---|
| {{formatTime .StartTime}} | +{{if not (.EndTime.IsZero)}}{{formatDuration .StartTime .EndTime}}{{else}}—{{end}} | +{{.Status}} | +{{.FilesDownloaded}} | +{{.FilesSkipped}} | +{{if gt .FilesFailed 0}}{{.FilesFailed}}{{else}}0{{end}} | +{{formatBytes .BytesTransferred}} | +
Define a destination registry and credentials used by airgap registry push.
Configure a registry as a sync source (pull images) and/or as a push target for airgap registry push.
Enumerate tags for specified repositories and download manifests + blobs locally.
+ +Used by airgap registry push to copy local images to this registry.