-
Notifications
You must be signed in to change notification settings - Fork 124
fix(store): continue replicating remaining artifacts after per-entity failure #645
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1338b6d
8a264cd
97c3f37
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,8 +3,10 @@ | |
| import ( | ||
| "context" | ||
| "crypto/tls" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "sync" | ||
|
|
||
| "github.com/container-registry/harbor-satellite/internal/logger" | ||
| satTLS "github.com/container-registry/harbor-satellite/internal/satellite/tls" | ||
|
|
@@ -35,8 +37,8 @@ | |
| // Replicate copies images from the source registry to the destination registry. | ||
| // Before pulling, it checks which blobs already exist at the destination and | ||
| // only downloads missing layers from source, saving bandwidth on crash recovery. | ||
| // Entities are dispatched to a bounded pool of goroutines for concurrent replication. | ||
| func (r *RegistryStore) Replicate(ctx context.Context, replicationEntities []Artifact) error { | ||
| log := logger.FromContext(ctx) | ||
| pullAuth := authn.FromConfig(authn.AuthConfig{ | ||
| Username: r.source.Username, | ||
| Password: r.source.Password, | ||
|
|
@@ -63,79 +65,113 @@ | |
| } | ||
| } | ||
|
|
||
| const maxWorkers = 5 | ||
| workers := min(maxWorkers, len(replicationEntities)) | ||
| if workers == 0 { | ||
| return nil | ||
| } | ||
|
|
||
| entityCh := make(chan Artifact) | ||
| var ( | ||
| mu sync.Mutex | ||
| errs []error | ||
| wg sync.WaitGroup | ||
| ) | ||
|
|
||
| for range workers { | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| for entity := range entityCh { | ||
| if err := r.replicateEntity(ctx, entity, nameOpts, pullOpts, pushOpts); err != nil { | ||
| mu.Lock() | ||
| errs = append(errs, err) | ||
| mu.Unlock() | ||
| } | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| dispatch: | ||
| for _, entity := range replicationEntities { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this line should be fixed with queue. instead of a for loop and should be dispatched to goroutines.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call! Note: |
||
| // Check context cancellation before processing each image | ||
| select { | ||
| case <-ctx.Done(): | ||
| log.Warn().Err(ctx.Err()).Msg("Context cancelled, stopping replication") | ||
| return ctx.Err() | ||
| default: | ||
| break dispatch | ||
| case entityCh <- entity: | ||
| } | ||
| } | ||
| close(entityCh) | ||
| wg.Wait() | ||
|
|
||
| if err := entity.validate(); err != nil { | ||
| return err | ||
| } | ||
| if ctx.Err() != nil { | ||
| return ctx.Err() | ||
| } | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| srcRef := r.source.reference(entity, entity.sourceIdentifier()) | ||
| dstRef := r.destination.reference(entity, entity.destinationIdentifier()) | ||
| func (r *RegistryStore) replicateEntity(ctx context.Context, entity Artifact, nameOpts []name.Option, pullOpts, pushOpts []remote.Option) error { | ||
| log := logger.FromContext(ctx) | ||
|
|
||
| src, err := name.ParseReference(srcRef, nameOpts...) | ||
| if err != nil { | ||
| return fmt.Errorf("parse source ref %s: %w", srcRef, err) | ||
| } | ||
| if err := entity.validate(); err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Msg("Skipping entity: validation failed") | ||
| return fmt.Errorf("validate entity %s: %w", entity.Name, err) | ||
| } | ||
|
|
||
| dst, err := name.ParseReference(dstRef, nameOpts...) | ||
| if err != nil { | ||
| return fmt.Errorf("parse dest ref %s: %w", dstRef, err) | ||
| } | ||
| srcRef := r.source.reference(entity, entity.sourceIdentifier()) | ||
| dstRef := r.destination.reference(entity, entity.destinationIdentifier()) | ||
|
|
||
| // Lazy fetch: only the manifest is downloaded, no layer data yet | ||
| desc, err := remote.Get(src, pullOpts...) | ||
| if err != nil { | ||
| log.Error().Msgf("Failed to fetch image descriptor: %v", err) | ||
| return err | ||
| } | ||
| src, err := name.ParseReference(srcRef, nameOpts...) | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Str("ref", srcRef).Msg("Skipping entity: failed to parse source ref") | ||
| return fmt.Errorf("parse source ref %s: %w", srcRef, err) | ||
| } | ||
|
|
||
| img, err := desc.Image() | ||
| if err != nil { | ||
| log.Error().Msgf("Failed to resolve image: %v", err) | ||
| return err | ||
| } | ||
| dst, err := name.ParseReference(dstRef, nameOpts...) | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Str("ref", dstRef).Msg("Skipping entity: failed to parse dest ref") | ||
| return fmt.Errorf("parse dest ref %s: %w", dstRef, err) | ||
| } | ||
|
|
||
| // Lazy OCI conversion, no data materialized | ||
| ociImage := mutate.MediaType(img, types.OCIManifestSchema1) | ||
| desc, err := remote.Get(src, pullOpts...) | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Str("ref", srcRef).Msg("Skipping entity: failed to fetch image descriptor") | ||
| return fmt.Errorf("fetch image descriptor %s: %w", entity.Name, err) | ||
| } | ||
|
|
||
| // Check if image already exists at destination with same digest | ||
| srcDigest, err := ociImage.Digest() | ||
| if err != nil { | ||
| return fmt.Errorf("compute source digest: %w", err) | ||
| } | ||
| img, err := desc.Image() | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Msg("Skipping entity: failed to resolve image") | ||
| return fmt.Errorf("resolve image %s: %w", entity.Name, err) | ||
| } | ||
|
|
||
| dstDesc, dstErr := remote.Head(dst, pushOpts...) | ||
| if dstErr == nil && dstDesc.Digest == srcDigest { | ||
| log.Info().Msgf("Image %s already up-to-date at destination, skipping", entity.Name) | ||
| continue | ||
| } | ||
| ociImage := mutate.MediaType(img, types.OCIManifestSchema1) | ||
|
|
||
| // Log which layers need pulling vs already present | ||
| srcLayers, err := ociImage.Layers() | ||
| if err != nil { | ||
| return fmt.Errorf("get source layers: %w", err) | ||
| } | ||
| srcDigest, err := ociImage.Digest() | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Msg("Skipping entity: failed to compute source digest") | ||
| return fmt.Errorf("compute source digest %s: %w", entity.Name, err) | ||
| } | ||
|
|
||
| missing := r.countMissingLayers(dst, srcLayers, pushOpts) | ||
| log.Info().Msgf("Replicating image %s: %d/%d layers to pull", entity.Name, missing, len(srcLayers)) | ||
| dstDesc, dstErr := remote.Head(dst, pushOpts...) | ||
| if dstErr == nil && dstDesc.Digest == srcDigest { | ||
| log.Info().Msgf("Image %s already up-to-date at destination, skipping", entity.Name) | ||
| return nil | ||
| } | ||
|
|
||
| // remote.Write streams layers one-by-one. For each layer it HEAD-checks | ||
| // the destination first; only missing blobs are pulled from source. | ||
| // Manifest is pushed last. | ||
| if err := remote.Write(dst, ociImage, pushOpts...); err != nil { | ||
| log.Error().Msgf("Failed to replicate image: %v", err) | ||
| return err | ||
| } | ||
| log.Info().Msgf("Image %s replicated successfully", entity.Name) | ||
| srcLayers, err := ociImage.Layers() | ||
| if err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Msg("Skipping entity: failed to get source layers") | ||
| return fmt.Errorf("get source layers %s: %w", entity.Name, err) | ||
| } | ||
|
|
||
| missing := r.countMissingLayers(dst, srcLayers, pushOpts) | ||
| log.Info().Msgf("Replicating image %s: %d/%d layers to pull", entity.Name, missing, len(srcLayers)) | ||
|
|
||
| if err := remote.Write(dst, ociImage, pushOpts...); err != nil { | ||
| log.Warn().Err(err).Str("entity", entity.Name).Msg("Skipping entity: failed to replicate image") | ||
| return fmt.Errorf("replicate image %s: %w", entity.Name, err) | ||
| } | ||
| log.Info().Msgf("Image %s replicated successfully", entity.Name) | ||
| return nil | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Serialize artifacts that share a destination reference.
Artifact.validatepermits two artifacts with different source digests and the same destination tag.destinationIdentifierthen maps both artifacts to the samedstRef. These workers can callremote.Writeconcurrently, so the final tag depends on completion order instead of the input order from the previous sequential loop.Process each destination reference in input order, or reject conflicting destination references before worker dispatch.
🤖 Prompt for AI Agents