From a3558121f891cb093c3f59ceea2db68afe4da90a Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:15:24 +0000 Subject: [PATCH 01/11] Add local image tagging API POST /images/{name}/tag creates or updates a repository tag pointing at an existing ready image without pulling or reconverting it. Sources may be a tag or digest; targets must carry a tag. Cross-repository targets promote the digest into shared content first so every alias resolves to one rootfs copy, and deletion keeps content alive while any repository tag references it. The tag symlink is staged and renamed atomically, so retagging an existing name never leaves a dangling reference. --- cmd/api/api/images.go | 39 ++ cmd/api/api/images_test.go | 137 +++++ lib/images/errors.go | 1 + lib/images/manager.go | 56 ++ lib/images/tag_test.go | 188 +++++++ lib/oapi/oapi.go | 1076 +++++++++++++++++++++++------------- lib/scopes/scopes.go | 9 +- openapi.yaml | 60 ++ stainless.yaml | 2 + 9 files changed, 1187 insertions(+), 381 deletions(-) create mode 100644 lib/images/tag_test.go diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index dc003370d..e9f6defc8 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -94,6 +94,45 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil } +func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestObject) (oapi.TagImageResponseObject, error) { + if request.Body == nil { + return oapi.TagImage400JSONResponse{ + Code: "invalid_request", + Message: "request body is required", + }, nil + } + + img, err := s.ImageManager.TagImage(ctx, request.Name, request.Body.Target) + if err != nil { + log := logger.FromContext(ctx) + switch { + case errors.Is(err, images.ErrInvalidName): + return oapi.TagImage400JSONResponse{ + Code: "invalid_name", + Message: err.Error(), + }, nil + case errors.Is(err, images.ErrNotFound): + return oapi.TagImage404JSONResponse{ + Code: "not_found", + Message: "source image not found", + }, nil + case errors.Is(err, images.ErrImageNotReady): + return oapi.TagImage409JSONResponse{ + Code: "image_not_ready", + Message: err.Error(), + }, nil + default: + log.ErrorContext(ctx, "failed to tag image", "error", err, "source", request.Name, "target", request.Body.Target) + return oapi.TagImage500JSONResponse{ + Code: "internal_error", + Message: "failed to tag image", + }, nil + } + } + + return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil +} + // GetImage gets image details by name // Note: Resolution is handled by ResolveResource middleware func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) { diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index bb13a9c02..2a5d02367 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "testing" "time" @@ -37,6 +38,15 @@ func (m *captureCreateImageManager) CreateImage(_ context.Context, req images.Cr return &images.Image{Name: req.Name, Digest: "sha256:test", Status: images.StatusPending, CreatedAt: time.Now()}, nil } +type tagImageErrManager struct { + images.Manager + err error +} + +func (m tagImageErrManager) TagImage(context.Context, string, string) (*images.Image, error) { + return nil, m.err +} + func TestCreateImage_MapsBorrowedCredentials(t *testing.T) { t.Parallel() @@ -530,3 +540,130 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im require.NoError(t, err) require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644)) } + +func TestTagImage_ErrorStatusMapping(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + err error + wantType any + wantCode string + }{ + { + name: "invalid name -> 400", + err: fmt.Errorf("tag: %w", images.ErrInvalidName), + wantType: oapi.TagImage400JSONResponse{}, + wantCode: "invalid_name", + }, + { + name: "not found -> 404", + err: fmt.Errorf("tag: %w", images.ErrNotFound), + wantType: oapi.TagImage404JSONResponse{}, + wantCode: "not_found", + }, + { + name: "not ready -> 409", + err: fmt.Errorf("tag: %w", images.ErrImageNotReady), + wantType: oapi.TagImage409JSONResponse{}, + wantCode: "image_not_ready", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := &ApiService{ImageManager: tagImageErrManager{err: tc.err}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + Body: &oapi.TagImageRequest{Target: "docker.io/library/alpine:stable"}, + }) + require.NoError(t, err) + require.IsType(t, tc.wantType, resp) + require.Equal(t, tc.wantCode, tagImageErrorCode(resp)) + }) + } +} + +func TestTagImage_MissingBody(t *testing.T) { + t.Parallel() + + svc := &ApiService{ImageManager: tagImageErrManager{}} + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: "docker.io/library/alpine:latest", + }) + require.NoError(t, err) + require.IsType(t, oapi.TagImage400JSONResponse{}, resp) +} + +func tagImageErrorCode(resp oapi.TagImageResponseObject) string { + switch r := resp.(type) { + case oapi.TagImage400JSONResponse: + return r.Code + case oapi.TagImage404JSONResponse: + return r.Code + case oapi.TagImage409JSONResponse: + return r.Code + case oapi.TagImage500JSONResponse: + return r.Code + default: + return "" + } +} + +// seedReadyContentImage writes a ready image into the shared content layout +// plus a repository tag reference, without pulling from a registry. +func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) { + t.Helper() + + p := paths.New(svc.Config.DataDir) + contentDir := p.ImageContentDir(digestHex) + require.NoError(t, os.MkdirAll(contentDir, 0o755)) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs"), 0o644)) + + meta := struct { + Name string `json:"name"` + Digest string `json:"digest"` + Status string `json:"status"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt time.Time `json:"created_at"` + }{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: "ready", + SizeBytes: int64(len("rootfs")), + CreatedAt: time.Now().UTC(), + } + data, err := json.Marshal(meta) + require.NoError(t, err) + require.NoError(t, os.WriteFile(p.ImageContentMetadata(digestHex), data, 0o644)) + + linkPath := p.ImageRepositoryTagSymlink(repository, tag) + target, err := filepath.Rel(filepath.Dir(linkPath), contentDir) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + require.NoError(t, os.Symlink(target, linkPath)) +} + +func TestTagImage_Success(t *testing.T) { + t.Parallel() + svc := newTestService(t) + + const repository = "docker.io/library/alpine" + const digestHex = "ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34ab12cd34" + seedReadyContentImage(t, svc, repository, "latest", digestHex) + + resp, err := svc.TagImage(ctx(), oapi.TagImageRequestObject{ + Name: repository + ":latest", + Body: &oapi.TagImageRequest{Target: "registry.example/apps/alpine:v1"}, + }) + require.NoError(t, err) + okResp, ok := resp.(oapi.TagImage200JSONResponse) + require.True(t, ok, "expected 200 response, got %T", resp) + require.Equal(t, "registry.example/apps/alpine:v1", okResp.Name) + require.Equal(t, "sha256:"+digestHex, okResp.Digest) + + // The new tag resolves through GetImage. + img, err := svc.ImageManager.GetImage(ctx(), "registry.example/apps/alpine:v1") + require.NoError(t, err) + require.Equal(t, images.StatusReady, img.Status) +} diff --git a/lib/images/errors.go b/lib/images/errors.go index 6a63741f0..47be5032c 100644 --- a/lib/images/errors.go +++ b/lib/images/errors.go @@ -17,6 +17,7 @@ var ( ErrNotFound = errors.New("image not found") ErrInvalidName = errors.New("invalid image name") ErrInvalidPlatform = errors.New("invalid platform") + ErrImageNotReady = errors.New("image is not ready") // ErrPlatformNotAvailable means the requested platform is well-formed but the // image's manifest index does not publish a matching variant. Unlike // ErrInvalidPlatform (bad user syntax), the platform itself is valid; the diff --git a/lib/images/manager.go b/lib/images/manager.go index f89fb6678..7310cc0d9 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -46,6 +46,10 @@ type Manager interface { // Unlike CreateImage, it does not resolve from a remote registry. ImportLocalImage(ctx context.Context, repo, reference, digest string) (*Image, error) GetImage(ctx context.Context, name string) (*Image, error) + // TagImage creates or updates a local tag pointing at an existing ready + // image, without pulling or reconverting. Source and target may be in + // different repositories. + TagImage(ctx context.Context, source, target string) (*Image, error) DeleteImage(ctx context.Context, name string) error RecoverInterruptedBuilds() // TotalImageBytes returns the total size of all ready images on disk. @@ -798,6 +802,58 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } +// TagImage creates or updates a local tag pointing at an existing ready +// image. The source may be a tag or a digest; the target must carry a tag. +// No pull or conversion happens: the target becomes another reference to the +// same shared content. Source and target may live in different repositories; +// a cross-repository target first promotes the digest into shared content so +// both references resolve to one rootfs copy. +func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { + sourceRef, err := ParseNormalizedRef(source) + if err != nil { + return nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) + } + targetRef, err := ParseNormalizedRef(target) + if err != nil { + return nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) + } + if targetRef.IsDigest() { + return nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + } + + m.createMu.Lock() + defer m.createMu.Unlock() + + digestHex := sourceRef.DigestHex() + if !sourceRef.IsDigest() { + digestHex, err = resolveTag(m.paths, sourceRef.Repository(), sourceRef.Tag()) + if err != nil { + return nil, err + } + } + + meta, err := readMetadata(m.paths, sourceRef.Repository(), digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + + if sourceRef.Repository() != targetRef.Repository() { + if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { + return nil, fmt.Errorf("create image alias: %w", err) + } + } + if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) + } + + img := meta.toImage() + img.Name = targetRef.String() + return img, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go new file mode 100644 index 000000000..e44e32259 --- /dev/null +++ b/lib/images/tag_test.go @@ -0,0 +1,188 @@ +package images + +import ( + "context" + "os" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +// seedReadyContentImage writes a ready image directly into the shared content +// layout and tags it, without going through a pull. +func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { + t.Helper() + require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digestHex), &imageMetadata{ + Name: repository + ":" + tag, + Digest: "sha256:" + digestHex, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) +} + +func newTagTestManager(p *paths.Paths) *manager { + return &manager{paths: p} +} + +func TestTagImageSameRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + require.Equal(t, "sha256:"+digest, img.Digest) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, digest, resolved) + + // Both tags keep the shared content alive. + require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "content must survive while another tag references it") + + require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageFromDigestSource(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + seedReadyContentImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+"@sha256:"+digest, repository+":pinned") + require.NoError(t, err) + require.Equal(t, repository+":pinned", img.Name) + + resolved, err := resolveTag(p, repository, "pinned") + require.NoError(t, err) + require.Equal(t, digest, resolved) +} + +func TestTagImageCrossRepository(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + sourceRepo := "docker.io/library/alpine" + targetRepo := "registry.example/apps/alpine" + digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + + // Ready image still living in the legacy per-repository layout. + legacyDir := p.ImageDigestDir(sourceRepo, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(sourceRepo, digest), &imageMetadata{ + Name: sourceRepo + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + SizeBytes: 7, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(sourceRepo, digest), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, sourceRepo, "latest", digest)) + + img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") + require.NoError(t, err) + require.Equal(t, targetRepo+":v1", img.Name) + + // Promotion moved the digest into shared content and retired the legacy tree. + data, err := os.ReadFile(p.ImageContentPath(digest)) + require.NoError(t, err) + require.Equal(t, "rootfs!", string(data)) + _, err = os.Stat(legacyDir) + require.True(t, os.IsNotExist(err), "legacy tree should be retired after promotion") + + // Both repositories resolve to the same digest. + for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { + resolved, err := resolveTag(p, repo, tag) + require.NoError(t, err) + require.Equal(t, digest, resolved) + } + + // Deleting the source tag leaves the cross-repository alias intact. + require.NoError(t, m.DeleteImage(context.Background(), sourceRepo+":latest")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.NoError(t, err, "cross-repository tag must keep content alive") + + require.NoError(t, m.DeleteImage(context.Background(), targetRepo+":v1")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +} + +func TestTagImageRejectsNotReady(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" + + require.NoError(t, os.MkdirAll(p.ImageContentDir(digest), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusConverting, + CreatedAt: time.Now().UTC(), + })) + require.NoError(t, createTagSymlink(p, repository, "latest", digest)) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.ErrorIs(t, err, ErrImageNotReady) + + _, err = resolveTag(p, repository, "stable") + require.ErrorIs(t, err, ErrNotFound, "failed tag must not create a reference") +} + +func TestTagImageSourceNotFound(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + + _, err := m.TagImage(context.Background(), repository+":missing", repository+":stable") + require.ErrorIs(t, err, ErrNotFound) +} + +func TestTagImageRejectsDigestTarget(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + seedReadyContentImage(t, p, repository, "latest", digest) + + _, err := m.TagImage(context.Background(), repository+":latest", repository+"@sha256:"+digest) + require.ErrorIs(t, err, ErrInvalidName) +} + +func TestTagImageReplacesExistingTag(t *testing.T) { + p := paths.New(t.TempDir()) + m := newTagTestManager(p) + repository := "docker.io/library/alpine" + first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" + seedReadyContentImage(t, p, repository, "latest", first) + seedReadyContentImage(t, p, repository, "stable", second) + + // "stable" moves from the second digest to the first. + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + + resolved, err := resolveTag(p, repository, "stable") + require.NoError(t, err) + require.Equal(t, first, resolved) + + // The orphaned second digest is cleaned up by delete semantics, not by tag; + // it is still referenced by no tag after the repoint only if it had no other + // tag. Here "stable" was its only tag, so it remains on disk until deleted. + _, err = os.Stat(p.ImageContentDir(second)) + require.NoError(t, err) +} diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index ef827c489..bda3c0909 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -1841,6 +1841,12 @@ type StandbyInstanceRequest struct { CompressionDelay *string `json:"compression_delay,omitempty"` } +// TagImageRequest defines model for TagImageRequest. +type TagImageRequest struct { + // Target Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + Target string `json:"target"` +} + // Tags User-defined key-value tags. type Tags map[string]string @@ -2131,6 +2137,9 @@ type CreateDeviceJSONRequestBody = CreateDeviceRequest // CreateImageJSONRequestBody defines body for CreateImage for application/json ContentType. type CreateImageJSONRequestBody = CreateImageRequest +// TagImageJSONRequestBody defines body for TagImage for application/json ContentType. +type TagImageJSONRequestBody = TagImageRequest + // CreateIngressJSONRequestBody defines body for CreateIngress for application/json ContentType. type CreateIngressJSONRequestBody = CreateIngressRequest @@ -2315,6 +2324,11 @@ type ClientInterface interface { // GetImage request GetImage(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*http.Response, error) + // TagImageWithBody request with any body + TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListIngresses request ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -2755,6 +2769,30 @@ func (c *Client) GetImage(ctx context.Context, name string, reqEditors ...Reques return c.Client.Do(req) } +func (c *Client) TagImageWithBody(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequestWithBody(c.Server, name, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) TagImage(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTagImageRequest(c.Server, name, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListIngresses(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListIngressesRequest(c.Server, params) if err != nil { @@ -4203,6 +4241,53 @@ func NewGetImageRequest(server string, name string) (*http.Request, error) { return req, nil } +// NewTagImageRequest calls the generic TagImage builder with application/json body +func NewTagImageRequest(server string, name string, body TagImageJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTagImageRequestWithBody(server, name, "application/json", bodyReader) +} + +// NewTagImageRequestWithBody generates requests for TagImage with any type of body +func NewTagImageRequestWithBody(server string, name string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/images/%s/tag", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListIngressesRequest generates requests for ListIngresses func NewListIngressesRequest(server string, params *ListIngressesParams) (*http.Request, error) { var err error @@ -6166,6 +6251,11 @@ type ClientWithResponsesInterface interface { // GetImageWithResponse request GetImageWithResponse(ctx context.Context, name string, reqEditors ...RequestEditorFn) (*GetImageResponse, error) + // TagImageWithBodyWithResponse request with any body + TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + + TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) + // ListIngressesWithResponse request ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) @@ -6833,6 +6923,32 @@ func (r GetImageResponse) StatusCode() int { return 0 } +type TagImageResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *Image + JSON400 *Error + JSON404 *Error + JSON409 *Error + JSON500 *Error +} + +// Status returns HTTPResponse.Status +func (r TagImageResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TagImageResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type ListIngressesResponse struct { Body []byte HTTPResponse *http.Response @@ -8065,6 +8181,23 @@ func (c *ClientWithResponses) GetImageWithResponse(ctx context.Context, name str return ParseGetImageResponse(rsp) } +// TagImageWithBodyWithResponse request with arbitrary body returning *TagImageResponse +func (c *ClientWithResponses) TagImageWithBodyWithResponse(ctx context.Context, name string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImageWithBody(ctx, name, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + +func (c *ClientWithResponses) TagImageWithResponse(ctx context.Context, name string, body TagImageJSONRequestBody, reqEditors ...RequestEditorFn) (*TagImageResponse, error) { + rsp, err := c.TagImage(ctx, name, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTagImageResponse(rsp) +} + // ListIngressesWithResponse request returning *ListIngressesResponse func (c *ClientWithResponses) ListIngressesWithResponse(ctx context.Context, params *ListIngressesParams, reqEditors ...RequestEditorFn) (*ListIngressesResponse, error) { rsp, err := c.ListIngresses(ctx, params, reqEditors...) @@ -9463,6 +9596,60 @@ func ParseGetImageResponse(rsp *http.Response) (*GetImageResponse, error) { return response, nil } +// ParseTagImageResponse parses an HTTP response from a TagImageWithResponse call +func ParseTagImageResponse(rsp *http.Response) (*TagImageResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TagImageResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Image + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseListIngressesResponse parses an HTTP response from a ListIngressesWithResponse call func ParseListIngressesResponse(rsp *http.Response) (*ListIngressesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -11413,6 +11600,9 @@ type ServerInterface interface { // Get image details // (GET /images/{name}) GetImage(w http.ResponseWriter, r *http.Request, name string) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(w http.ResponseWriter, r *http.Request, name string) // List ingresses // (GET /ingresses) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) @@ -11668,6 +11858,12 @@ func (_ Unimplemented) GetImage(w http.ResponseWriter, r *http.Request, name str w.WriteHeader(http.StatusNotImplemented) } +// Create or update a local image tag +// (POST /images/{name}/tag) +func (_ Unimplemented) TagImage(w http.ResponseWriter, r *http.Request, name string) { + w.WriteHeader(http.StatusNotImplemented) +} + // List ingresses // (GET /ingresses) func (_ Unimplemented) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { @@ -12510,6 +12706,37 @@ func (siw *ServerInterfaceWrapper) GetImage(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// TagImage operation middleware +func (siw *ServerInterfaceWrapper) TagImage(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "name" ------------- + var name string + + err = runtime.BindStyledParameterWithOptions("simple", "name", chi.URLParam(r, "name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.TagImage(w, r, name) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListIngresses operation middleware func (siw *ServerInterfaceWrapper) ListIngresses(w http.ResponseWriter, r *http.Request) { @@ -14066,6 +14293,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/images/{name}", wrapper.GetImage) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/images/{name}/tag", wrapper.TagImage) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/ingresses", wrapper.ListIngresses) }) @@ -15076,6 +15306,60 @@ func (response GetImage500JSONResponse) VisitGetImageResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type TagImageRequestObject struct { + Name string `json:"name"` + Body *TagImageJSONRequestBody +} + +type TagImageResponseObject interface { + VisitTagImageResponse(w http.ResponseWriter) error +} + +type TagImage200JSONResponse Image + +func (response TagImage200JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage400JSONResponse Error + +func (response TagImage400JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage404JSONResponse Error + +func (response TagImage404JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage409JSONResponse Error + +func (response TagImage409JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + +type TagImage500JSONResponse Error + +func (response TagImage500JSONResponse) VisitTagImageResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type ListIngressesRequestObject struct { Params ListIngressesParams } @@ -16974,6 +17258,9 @@ type StrictServerInterface interface { // Get image details // (GET /images/{name}) GetImage(ctx context.Context, request GetImageRequestObject) (GetImageResponseObject, error) + // Create or update a local image tag + // (POST /images/{name}/tag) + TagImage(ctx context.Context, request TagImageRequestObject) (TagImageResponseObject, error) // List ingresses // (GET /ingresses) ListIngresses(ctx context.Context, request ListIngressesRequestObject) (ListIngressesResponseObject, error) @@ -17689,6 +17976,39 @@ func (sh *strictHandler) GetImage(w http.ResponseWriter, r *http.Request, name s } } +// TagImage operation middleware +func (sh *strictHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + var request TagImageRequestObject + + request.Name = name + + var body TagImageJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.TagImage(ctx, request.(TagImageRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "TagImage") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(TagImageResponseObject); ok { + if err := validResponse.VisitTagImageResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListIngresses operation middleware func (sh *strictHandler) ListIngresses(w http.ResponseWriter, r *http.Request, params ListIngressesParams) { var request ListIngressesRequestObject @@ -18850,385 +19170,387 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+y9+3LbOLY3+ioonf1V23skWb7l4q6u77jtdNq7444nTjLf3q0cGSIhCWMSYAOgbKUr", - "VeeveYCp84TzJKewAJAgBUq0E9vxdGqqph2RxHVhYV1/649OxNOMM8KU7Bz80ZHRjKQY/jxUCkez9zzJ", - "U/KG/J4TqfTPmeAZEYoSeCnlOVOjDKuZ/ldMZCRopihnnYPOGVYzdDUjgqA5tILkjOdJjMYEwXck7nQ7", - "5BqnWUI6B52tlKmtGCvc6XbUItM/SSUom3Y+dTuC4JizZGG6meA8UZ2DCU4k6da6PdVNIyyR/qQH3xTt", - "jTlPCGadT9Di7zkVJO4c/OZP40PxMh//nURKd36YK36uMIvHizOe0GixPNlXlOXX0BvCueIpVjRC0nyD", - "MvgIjbEkMeIM4UjROUGUjXnOYvT26AxFnDES6cbkkPGxJGJOYjQRPEVqRtCMSwXvKIGjS6TwOCH9Iet0", - "a/tBmH4Sr1+lv82ImhERGCyVyLaCJlwgNaMSUaafRqTvb5gSOVle2W6HxgkZKZoSnqvlhfqZX6GEsylM", - "y7WL0lwqNMNzgj4SwdHvOU7oZEHZtHmRxmTCBUE/LzKSYoayBEdEIqoQZYq72Zg1KmlsPw0RF50yLsgo", - "JlJRhnX7o4wLcyKqo38Nf+AEee/C0OB9pGZYOSpnXKFLQrLqRPEVvqwu4287O93ng8HgQ7dDFUnNscLX", - "NM3TzsGT/f3d/W4npcz8e7sYPWWKTInQw7e/YCHwwpuO5LmIyCiisVg1kyihhCl0dHL85pYT6GwP+vC/", - "rWedbmf7+U5/+8kz+Pf2k44/raWFr4780+qjd66wyuUyDzKnaWQJZeQRyfKsf83TMRGIT1CUC0GYShYI", - "jhSJWxBdZdqD0FZEnE3oNBfuCIaOXGU5Z1gizAzT6NX4RdlYq3MXaSYW8ys2EiTFlOk1XhrEG/cI6ROK", - "7CHSQ4o4U4IniWYKSpE0U9Kdoq5m4wzhLEtoBKyncqj20oHsdDssTxL9sDbCcrdJQqcUXmi1NFR6m+S+", - "RYojwhQRxQlvszQVttjUcbncwd0o+WKrLmc8iUc5UzRZ7vWd/tmuqd8TlWhGkhjxyaSL6ARhpFvRPxsa", - "ry77zmDnSW+w1xs8ebv99GDw/GCw/z+dbmfCRYpV56ATY0V6epfb7A0wbUlZFN4dVieRVF9IgkRmd4oL", - "q7KBYxLxlCDd9MqR733+yBuvm6NyU/WLyL5Y3qyBre7Xrox21J1gqQomBPtF1WKEA2N6S1MiFU4zzYf0", - "GLzFbOJCrsH6PriVX7nA25+1wIxcq5FdoeB8QvRBrjMS6RuRO25SCBi6PUveBcu6FxoXBEs9YC0m6cv0", - "t07OZJ7pq5vEoyzBSrerZSogg1FKpdSfFj/EVBo+0u04Ih8xrkYiZ8y8yIi64uLSf9O2MqJZp9uZYTma", - "T7O80111bVWJGrogCc4ktGd3XIyIEFx0jGi8GE24cJuk79xyCVc0tbRCsrhiAyvU6XYqC1CwczcXN+5i", - "V4ODg16AloTRKowaAJNZHrjf1vJwi6GtZuzmFjFCtNtmZD+WVQ4QUzxlXCoayVZsHoQHvb0pjwOs87ho", - "DtGYMEUnlAgrVxMk9P2QEuQaQboRRBnKZe0cFKL/iMy1rjaa741UlC0vSk2x8TfPk03KG9G7lYvtL07K", - "GiKtzj2oOM0xhTN5TObUXC1V2c1uzSgWdE5EgH0XAoBhheY9tKHPumYhjDOyWVkpNqcxxW3YQQxjGtEA", - "9ZwdnSDzGJ0co40Zua52svN0/KzT3CTDaYAWfs5TzHr6QOhhufbhXb/tV3tBFYWnaT6aCp5nyy2fvD49", - "fYfgIWIg4fotPtsJSapZREc4jgWRMjx/99Af22AwGBzgnYPBoD8IjXJOWMxF45Kax+El3R7EZEWTrZbU", - "tr+0pL++Pzk+OURHXGRcgM629uD4y+PPyyeb6q6E6P/HnCbxMtWP9c+kYZ2IkFQqza9+NK8hQYwqh65m", - "XBIU4WhG0NgoLaCeQ3ttSL7o2N1eoREcU5lx4PPo/WkpzYFqSK5JlKtKv9+jmGpdOLJClTe5FiOKuN4j", - "ff+GJAtYAWTf0aq2cqLTrYWASBC8pjv9RqvOls9/bshrlMqm1t0rms2nNEmoJBFnsfT7oEw92WuejHeK", - "zbW51NUL/TNKiZR4StAGmKVA3TAcXktbE0wTEm+2k7CbJvN3PvbutcqZAzLo4XG0vbMbZGgpnpJRTKfW", - "rlinQf27lo91OwrB2+GJgITRbh7QpSCT5f5+gvsEOhFkQgTRFP+Z3WWCzwnDVqX6D+i3839tlQbXLWtt", - "3YLFPCtf/9Tt/J6TnIwyLqkZ4RKbsE80GcFSI/giPGZ4tGqvPYqSCovV5wPe+AInsRQ2166NNf1oeQtP", - "137yVr9TZ+jAjgoBx+MCjXz7hZa0AiILZ8o+qJmA+RQllBk1SPNvsxcg7C0y8kPCp5udL7YOxfIvH349", - "7lswL/NDQ2v6WbfQChI+9VdzRrBQY1JZzIZ71TZUjq5x+c8qx6d2gWJJRqs5yBlljMRgc7cH27ypZeug", - "7gOn6JKq0VzfwKEzB8P6hSpk32hsKuHR5YQmZDTDcmaNlHFMjcH1rDKTgAhZcWZgMBK4BkG0AaX6/OfD", - "nf0nyHYQWENr/dUvLM/E+1o3b8ULhcUYJ0mQNprJ7eZ39DKFhCmgNPg23T0FBTrCNJyuY3fTKu+5nJm/", - "gHfrUcHdp9mAJq9E/71EuN3OdU+32ptjAauum/eG9VfXk/fbj2Wn3q9nRf/ej2/sULyffnKj8n47Kgfo", - "VsWoSUETOEw6LFVyylSPMtgCLYBby5O5Okozq7VkIL2NhYQHN/tnCVZHSyIV2njz09Hu7u7zzfbCFZWX", - "I0k/ktF0vFJutoO2grL+DOnP9FU5pVM8Xigi++gIM8YVGhMUzTCbkhjhidJf2bFWFPD9wTonTKOQREST", - "iKQ+7uHnz66vsXr+hF7J5x/TsZj+fTfo/wTrouY3zRY4bHdTv4oEZoFNvN1FneJrrfkbKgk5P0+NuwoV", - "L9k9kPUh9FGp0E/otV5zhbYrKx1c3LD2V7iwGGe9nNHfc9jtLMGLZR1QEZz2gCJCy2v4hjlAcv3xkfr8", - "mG/cTK8wVfrkFD5TO+Uu4kms750JFVJ12nvDbiQYEPGlRKTKKQtLTMsUUV/Axku9GGfjWUnohESLKCHL", - "zN2x70zk1twaE33nwJ+FAbEtG3dDqXBi9+NZ0UXl5+Oyv8rvL0znn7qdI5zhMU2o48xVPm198iNr+Fu3", - "S35jx+bTN/bLT93OhGCVCxJYy3OID0DGTtxLyJwkyL6tifcAHZ6dIJmLCbjMgVrNu4hcZ1wSOWSCTLGI", - "EyKB1K9mNJoV5soxji4lwqwMC0CHyRVeSJQJIglTB0M2LCzkctjpoqFRvuzfhkrM3yY0xL3EpoJI+w9y", - "TaJhp6vbSvhUDjt99DOXqqfVZStLHaChtcfIYQdtWJNahqVUM8Hz6WzIUszwlKSEqS6CIA0wA2vOlCw2", - "EWYxGnYEl0Qp3CNpngDv140dZllChuycJjTiDKU4en1uv72iaobemI+8yxNmrC/rLsqs6VnilAwZNmZQ", - "vZMLlAk+JijBOYtmRCLCJlxEZLOPzojouSV2e4uwIHozjBEX5UwfEYLLzZC/fUCEKWEjHdozFz2Tm9Cf", - "XvpCiZY3+fLEfPGp8I7c5ONf7SeaVdkZB3UUsSit6R49mxuxMPW766jiCegOGawo7CrVb10xVNmySYKn", - "QCreITLEKIcs4mkKip7tBUkyJwInxQ7pO46DPUHfDfZGGrIo4Xncmy0yIuZUctHVNwSJIBhBdNHvJM27", - "0Kn+q5fSSPB5ijgbskST8RZO4yd7m310iBIt98TF/KkhbktiwAnAHkD1aIcd7Gzyw46ZGJVIX/x99IIp", - "QQ3NIWkIbmxu0hpxtd08j1kt3W2wPzdp7Nx8seThMD9bgu4usViPbkoCLMjY46KhO2sFA16Wwd26rnZG", - "2fEVuxVhZneqQp0HiCqEs4xgIRFlQ1ZSE4uBSu1HmSCwHJIqu3UpUWgjxQrYi5phNWTAIb6TqLL7m30E", - "cqOJDys5Ehh6BUkWZkBkyNyYr2iSgOEQQWCD4XDmoIFlzIuggMOEWcH7ktLbZZ1f1i9amUDliJi+//ri", - "9B2SC6lIisaUYbHYRC4iTfNaQ5rLjrmwyGh3DzR3ENWqs445RD3pbxFnVQGyfl7XuhCsCFrSxToK+5mH", - "QiyxiGahMDqp0NHZO6QfU0UiTcSV8WKRPgm6krhsaE73ikGINetdaQ6Yzto5c32kYMTrJntS3CNhEalw", - "xy97u8CaU/pxSUIizatMPI3R3wiyzmXEU2Dptc20LLRpiVzjcl3vEm1wuaUnvOndLPpMi5z10WuGQIxA", - "FSliyGxQZZTkMZHI4+eoNN86+aISBlWQ/ZD96//9/4wsEaM5xYW0gd5ToXKc0I9Gi50InBLN9KpXWjQj", - "0eWQ1cUQpFuVHGF3qRTLTKV/oQg6nSnE+FV/yE7MmIoBb0g+UVdYkDzTaibq9eyoe1bO2jRBXNAQVbUQ", - "zd9qe+Mtzg1jCSvuvGJDu8sUto5Ufy3FliqtTrEiVzigEL/UpNebUwkha0AT9l10ctZH8BhcCdHMPNVc", - "VEuxaMNQBWXTIYMnViTW5AUSrSEz65rso9cpVQXxM15cL/amG7IZlmhMCAMvYjI3AcglqS5IfQM624P+", - "9mDQH/S3Q0djqsc+Unw0zYN2V3fXxVqYUej9aU/x3vtTpASeTGik6Sgjwo7aXDD1QQf5ecpjkjSstPvQ", - "hVAkfa1kCBpPtZCjl8uK/uY3czllROhxvT080xccrP73aNhhWJlvSk7fywSf05jE6NfDt/pqjV6fb9pV", - "s6qpaVhLGEEjeLcj8zEjqmn85inE4Ia3AkJp1zFfs0RLW7SOvD9PotEXv2nguxUyScnBtHSihdiCBPvo", - "VUV+tfKnC1zRIuiyRF+04A7x96gY7Q8g0KCUYCYhhFLL+r6QQeWQOemjInBoSRvZFdWfipwZLmXuw57H", - "fUEsMfIISGRFkP5c/39vLnl0aUM8jJZZcFqGUzjd/tItSVZUtRJy1poAPIUlsKSFROYLnlpHkAxncsYV", - "aNXuH1uCSMUF2dTKuY2X0s8znEuC/oJSknKxGDL3ftecswQrE8NQfDvRjKmDNqKEM+LbEUwAAR4yqXiW", - "kdg6Jr7XkhocblwEEXJR2KrNS1pLhi7klpEAJCpmARtQemCSRbnJ3kw2wdQAsymmpeecp2bYM66yJJ/2", - "7Dw7aCOhc2KnrSdIPxLTxhgnCeesZ+Mh9aul7QQeIYXFlChrepbQPtDMsDNkG3BukaEhrV7mjEZwm8N7", - "0yzv+SaODtp4efZuq4xZGjLvMXwSU3nZo7yX0JQqsJRQeYlOtl4joW9o+JmyqRk9vGymU0wS3rdT7KP3", - "OMntOQVq1+urFWEj/cNIe0rkajbJkwM0/2iFsOp+FDtZSEXbu11raUrAwtQoybw/RRLPiSNI+Eqr1FLp", - "Y4TjueZh0lpjL9GGJaee9XMB3UnECIkR40Pmt6UPMpCpHmJd5EfGECNRbZGGrBTbapHk7mpzLsPCFOEE", - "n/YWm9UaTYOP4fO1lhuoyOeFUl+7SDLa7FU9PDsxgcM4KpfJ018Jep0Rpt+KeZSnhGmZaMLdq9UoskF/", - "pykyraH7c/9aKbqXMy4UmlKtCGsxjjPLzYadXkyF0sdf5pMJvQYtMgcrkJFpjHGzqznUsJOzS8av2LBT", - "HebTJ3hnEo931+6GG3a3sobBfQCmZ63Sjfl6q31oRysdZlrU1JK75vae5m8p/Au4y8qUJH3piZ4+KwmF", - "yH1H210TrTslTKuq+tEENHYnoTW4fHpBUfbOvUo38sQ07KiJnG3c0DVzmCZ8DBeem0gZdNpHJ9W16yJs", - "LB9U1le5yJbwAkPRBulP+/p2ySLaGwwGgx7e6Q0GvUGd2pO9ngl5z7BSROgB/j+/4d7Hw97/DHrPP5R/", - "jvq9D3/5j6BC3jJa1fm17Tw33FnqIjdYP4S1PtB14a239qz5w28+vGBaaNzpSBA4BjhZO4azXM6OvNeb", - "af3oZDnqzaxUzKNLIvqUbyV0LLBYbLEpZdcHWpyTqrpuq99dZWAJJMcYwaiwO2CJrIXlt605FhQz9cGM", - "EQ1944CW3xDcGvreP4YRoV7PtWPZl4u8rzgA+uhXrhC2Hmmt0h3AS8v9TgVOUwzWzjlOaAyHw7rZJI2J", - "Mc4yeKbf0FKSICoXDO0NBu7JyBuUlhu4dFMZdqzhVEQzZA1CAoEpxGTIRCSD6Fse41qakm8nWTpp+pj9", - "Zav4a8P92f/wl83//R9flNSB0FbQuDFn3JCf1eK8gUttJPyKiAhLghKiZyu7KKZTqqQR3mIIjQKD2vco", - "MsEeJnRRa3zMmajhvSo5p4sezmjPWl6Mu/sVYVM16xw82Q0u7ob9o/fhP91PDQsr8iSkr73hOVhe4bGf", - "eO3G0Mr54lY3T8DpklJ2Yj7bXhYnP2+H3URW7bRR6hq3Wt8xRVbQmoEsZ+B/6naiNCREzIkQ+igavnZ0", - "eqxVmEtiGZrWGtEwHwx2I3gB/iT2Fy3EaVUGftusSjw2hjAg7PzWIdGMQxhkkvCbGCm7dabeFCW40kcW", - "Wu2S+y/HFIIT3bjFIdjdvojGgl8SAS4ZvcSUSHRJFsZcMK0YNAmbozkWzjX4FtISjNJqH4E3iIB6rJVY", - "SM6fcQjwMGzR6mwmDYrgpOSWKabM2D57MuKZlvGYfc1Y9C8Im1+gFGdGrRfEGM1TrIigWl0sDYspianm", - "0UNG4GCgDOszH0VcxJADzY0zvVyF7yS6MGriBTR/QZkm6wtzMGum0j86r9+9/fH1u1+PR6/PXvx6eDL6", - "5cV/65/NR52D3/7oGDCNIvDqR4IFEeg//oD5fjLBsxDT1znM1YwLq+06Fz3Eq+CM9nlGGKb9iKedbuc/", - "/X9++PTBhXuazL25PgaBgX0KipnW9hmIwXcJUBLZpBqXzqmXTLMoZ3bwzA01U76R/G50JEBFoXw0zmQw", - "N6Vutyjl0O3B4PTHLRs9su/+sdlH1nULw9c8iAsrHsuZJp8ClwP8aUnCI5uhMWlKwXddhRg8+FozTkMh", - "4jXmVL66zKN6vfLpDVjR1piyLam3oRfdbN2Bbm4dqPyCzangDNRzEJfGCZHVs/Lr6+MXoxe/vu8c6Isg", - "ziObiHX2+s3bzkFndzAYdEIESq4zKogMBj4ejiVPckUQvFTGmPbRaS4huNMyj0kOdlCQzyRKc5WDWkSu", - "oySXmk+BNKBUEso/ftbb2X67/exgMDgYDP6ndcCqJv01zPvl2TuToQ7nneBEzUbgmlv34c/w7hG8CqwC", - "bJOg2gdWqdhTZ63Up9d+gzZmVenKTA4BQQ47uy9/NAdq+yWcJUeINhW4aMU0XFu9lz+G1sWzRi1LeqWx", - "DTLcWOyofhmMxtjrc2nNRL4nvMcwQOyAeIFFjMYci/j7aiwNfPnXF6fvvpMIrBU4sX4iI3rDN0aW5ESC", - "ruxifEpjq+zWlgAsP6kxJWKGCHhK51SznF6aUu6+q7Ila0yp2FD7noMpYMfzwoU63Y6emf2Pm2Cn25l/", - "rOZUrzcH2tCYVrriGiUQJxllZIUW+JUI+1dcXCYcx2Al+pKyvhfvVoOhMQ+q210op47Al9CdxpjFVzRW", - "s1HMr5gecuCGtE9Q8XJxTV7rmeDkX//45/vT0iCy/XKc2Ttze2f/M+/M2i2pmw7m2hQTybPwNN5l4Um8", - "P/3XP/7pZvKwkzAi5a3Ec7v/L0wLdYAai9tlovEb/K6FHFbm5/IGX/oaDIPQlcvnRCR44V0ndkyd7QHw", - "9NqoBDXuRvudZ0Jefbno1py49rJujdsZhK+Pb7ajr9x2JAiw3lFWYOWtOiBvzNulWh8gugDN/Yhl4Xtt", - "Q2kFoW3vnNo/d5ZJLkxx8pJmJphihKdF7ucqeL3zS5pZpRm+cK58w+jjHNTsMeeqP2QGPkefTaAcck0i", - "5yBV6PDsRJroAH2lwaWxLAhpFdzDXYLXjS9U5KyLxrnSejVXBFkLh83j12OBl8cE5WWkZk3LtRNcjj6A", - "ZbkkgpFkZLRY2XJlzEfIftS4ODDVCZYW7kuoPKuu1/Evp+do43jBcEoj9Itp9ZTHeULQuRHVNqur1x2y", - "TACGiu5EkyO1/fIJ4rnq8UlPCULcEFNorHB2WCSJ+cuzdxaLRG72h+wN0QtLWGyjSp1EUYSVsu+U8TdX", - "mvX7ry16E96M86G3PFzn9vXydLU3+3U7SiUhBLnEyNYJnRCTrCB4WlKfy5/ratXrJS8BD8yB7KN3Uh/I", - "gRx2NIPimrWOuZr5KtyEkiSWvppR4lSWr2klL6zHlSpjTSHZmwWdw1GWV0l3p9sIHDg3UQlaZqgouEGU", - "FZvdsryKBlfVN6jY+7sEWlRVJIq2NmDTMqCgrg2LtKZcI/G3NuV6xsUlo66zfP3RMltgdfsnzA1kpSm7", - "NH59Rl/nppGl/ALzc9fN7BardFKsSc0A/mWW51B6xsJWgKVGgjHXvEQbFzijfUvH/YinF1108Z+VHzSP", - "c4q/lpOvkFkN4JtM/+S3L5dCetcYMG8d1ovl7ffjUDbCTaH5NlICM2kCzGc4I330M1xWSJFUi2CAz+fH", - "1DF+9T3iRjh3nw6ZHpqsBhk6M7akU0bZdBOixccE4Tg2tm5rqnKBJzKE8+sMUEsSsBmdResBVF0bZo4u", - "nM35osoTly3SywYba6Je0tTNkoCGDjYMtZXmSnevJ1wI1DxXBr3LTr2KrFaze6+LiLFjKWxwt9j/84Jd", - "1MGT5wFVHXJBJqWjwvOYNDkmrEAWdppckoVJELAOErzkIvF9I2EPho3ltuKF710ZAzw0t/GdhWPFuEis", - "VwSibmtB3yF/wbqt0OvVevmrKm8wAaXnJltSjNViHTCn40J6cqY/sLZJAosPKvSByWe66Bqdn4CGhZgm", - "lsQFpdebh3hciN67sL/0bWsXRXbclwDbLtMe/K1F3s468daEMFvxSJG4W5UNLgnJ5PpJmbjMWYO/T5Ar", - "QR0jc8iOLcVQmy6SWmXo8wwgL7zGVkRCtW1iOT3UrK83Zofpi22AGey92Q9w/FSgfuuI93FNOzWQKdUu", - "L3CSXKAN+9ImEuTvAIdq94pxVhL726MzRwJFnNX7066mSM0FLmZKZSP9f3KkT/FFvTH7rTvhJRr5swHo", - "kXt7u9VkBTPgWrNVO3EQBqZ5a85yObujwKUGa7SDfTcWaXcDZbmcoQ2Fp/roWLwffU9sfq6lmjJJolyQ", - "9Wf9EMQjizkDY0owZb2f3749Q4JMqYQc22A0v4kJbwAnswp8Gaflpqt41Vy5s7u3/+Tps+f9+FL0SST6", - "uewRLFVvu49T/JEzfCW12LWVLnCWHcy31/J5swXF+JpZvtM2m0mBp5olu0jcNprrUflJ6Sm7pCxu28Av", - "+t310Z5Osb5rf0MmSC/PpgID3OWX9DbcOqAHVrN5V9eUAFkdyZtLxVMf+3ZjdQBvdbHmPOnFWOGbxO+a", - "4S6H6qYL05RRyYNmvqa46PN6MHQFsCZYCOBzo6vcWELb0gSmawwJJB4pHshbdbLDyTFgb9t3W6GGAvav", - "4qP5hPJ12e0QM1LJWTVSiTVv6CZ6WUSt9cwlfICwahYBdIf3p35QSX/IeiCFHaDjooOi2aJJDCoGjo17", - "e4OLSuIsOJHHi02E0fvTPnpbjPY7iay32KILFymTOXjSQCjqGZHMH0AOGSZU1T+3pkIDRLwJsTPcPusX", - "5UrAKFkYtcCGNaa1+ZiEZtgoe5f4mCstjbSrYLnemPtoFTRXJRRivzfY7m3vv90e3DQU4g6wlkNtHVZ5", - "iw1697nP0buT4x1rity8NRrXF0djDrOy4zJaH23kkniZqFwEY/S9UPiGGPxbh9bfUaR8iTu56l2zEm/1", - "m3cBMx0CwrJIlTcHgq4zzLVoo97klg06iwzMD+Up8QR5m1UR0WDK8TGVlz8Kgi8BPn/53gYAhpG5z8KB", - "doCSMV4AKhQYuQTnaiJRAZ5SGr+3957uPdt9svdsMAgAGS8TPI/oCPJmWg3g9dEJSvCiwPTbAP99jMYJ", - "H1cJfX/3ybOng+fbO23HYTzW7dah0L/dV2jDrshfXIkz96QyqJ2dp092d3cHT57s7LUalXUbtBqUczFU", - "RJKnu0/3tp/t7LVahZBe98IBS9cF+FCVg0NTGAjyPmVGIjqhEQJANqQ/gJT5GWWkcM5Wz+QYx64WRPju", - "UJgmcmVYm+nMvmnsrWmeKJolxDyDDWnlkoCZH0NLweJajBW1N27WkoXjXhvw5OZSvALiTEzG+XRqse6K", - "pTu1aeyl8ERJEh+gQmVbyedgN8uBfWiiAzuHltTwSqtOFnXOIwJzdRmIBkFQQSdm0yqzcrEOlGV5kCQa", - "l/KnXBj0CWgU4THPlfGq22IpZScGyIWBlzlncTtzx09cXK5NbNA3cVETpqXBwGW4mwz4Wm67L/QV3m8T", - "I2CfS/TGfGEMheXPWV4tiNeFnqxBkbm0/LiwG9tm2kqXYbkFbOYuXMb0V/LOewrl601M9NOX1bDFlEAt", - "JLVWYtGUYuKRzuH11nlS+sO1hpQW687I1X0sOmT69TTZ9iTD2d2s+KqI4cLW4OXpcxtn30dwuiDIzyXo", - "107auYOasEveHzKbaOThFYAjDbAETJj4jFCBuKBTWu24ame9x5jdm1CmI65bU6f/YVMMnizcgCEeYJCW", - "JyYE1hQA8YFe7Z50up3zomiUZUzVpXlTFN5aWpEysn4Zc+fs3U0jbzPBJzQEhAORQPapVdRcTOqrvcF5", - "b/uvJmpekx9IbJSZ6KGUx7UaT/b9dhfRy7N3Z01jKqouIX90S3NaAe9TRrsUKK7G1Wh91VahcadB3zNF", - "J6Uo/jwk2gKKxzifTIgYpQFb20/6OTIvFCgEpz9WxVstRrdVos8qmwNa9ARHlE03W69+wD5Xm8Y6mL+X", - "Z+/e2Co+TQDIequKSj8G+7iPfi3qXKGXZ+9kCXLUDxju6kB6DXldZ7OFpBFOTIumSAVlvr0NiLO1wHxW", - "fmgtkwGxOVwezR0EtDGfZjkcw/M3vZPX77fSmMy7lTFBXN2MJ0SPe9PjFnMHLlAmoVWYxLzJ8GEIQ7Y9", - "QN5aFSe49SJ55zWwOoornIxkwkMhPG/1QwQP0cb7n0x2sB5BF2WVrdS/+8g/Pn0/CZ4YAJRv6PYcOqxb", - "UCsHPKhK1hHJOtXpVToNHRWT2bQs8izXJuSX1Y3ml+vr4ZlGmvs9cslXNRu3lSuRydEy8IkuKgGZT40R", - "21pKJMmwwIoki1p4ZBXfnCw7mMk1iW6Q/fVCv/7J1LTIBRmpmSByxpNqWMJud7kuqoQQ4DmxpaDMnDw7", - "vOIoxeISLkYnV6OcmRWoRpDvroN7mSmV3WBSP799e2aUbUXEHCf1HAS55HA/JgleoDFRV4QwNxUsEfZD", - "UOs5nLKhHI9Qo4wIyqtr2NkN9HtuwpLRVOCIIPOVq6pst0TCrdl2KW0vAQDBKCJSNuzv9qr9tZ9O8qTd", - "HoeGtb22CHl0kw1+e3TmysoUZXvdMu8sr/IZET1z5Fz93tVbuyNXFzhyXTGDC7skMIwJVDyyiR9+XqYL", - "N4KKTvrzSi6kxxykn71n+4FTYJaqa875h1bCXv24h/zqKWZxqPyxifc3mfJTgM+CoGORgyuJxiYWySTv", - "G7HcT1sQBMeUESlracZRLpJOt9Ob2FkdbG0lPMIJIAju7W4/21od1bkynNdGL41iukrdczFOJgrGJbUa", - "+DWYdJUktnCWtTCImXVccz8Ae1oOH4SSx/pu8yQ8F08+GCxlTF/jSLkibWAhq3hAsX9sAee3Mh9oMDV1", - "V7So/dw/n4OgSzrDalYl/60l2ocwFQgx1DRirA+1dTRE/jEoUXERqsTFhbIJeGPiYhCL+9BF+DlE24qv", - "bPDMn+WT/f3d/XV8CJhN7ZjbcxeYqnm7mmJMvOW259c2ANFSVZnDHenVxUT1uqyhKc0Rl0hq9YLyjLAb", - "ref+3u7Ozdaz7UROXJRWjS+FEFqOTo+NTBRxpjBlRKCUKBxjhatMBkxLmstAqRdMUsjSmXy/mrU0hDP4", - "kCu3rVP1pZzhDSXr3jiE5hQzOtEM2b7p9yxneGf/yYEprBmTyd7+k36/f1Mgihcl8kSrrdgyMXMeJkVf", - "zj5vH+4Ab6LNXP7onB2+/VkzslwKc2ltyTFlB96/i3+WD+AP888xZWGcija1WOlkqQZrNTwst0DAJD5A", - "ZbltJ/e0qtcftg1DsDCA4wRR3ypBk3cH71bQOK2i+N8gVXdF6qoWV16zZNG4Nrcuq1oWHFdeOVU/+atF", - "aVX6cbW725m74B3bp8GDLqrOLju6b1U3WK4srbhUeSsjrCimmCTmr4gzwNkNVVasXJHuWYvCXHCN2Apc", - "RZf+j0Xv3o9H/kC8311hL+8nW2Lxww0jVFYKpH9blkPXcyEnjq45zGHbY3ErtC1na2HhgqHZD3wX3iaK", - "q9r76+l//f5/5NnTv2///ur9+/+ev/yv41/pf79Pzl5/FuTIakTAB4X1+2JIfqZEuw/n15aUTrGKAjY6", - "rf41rLB9YiwOKppBAU40JgdD1kOvqCLClHOr5SIOO2iDgKYEX2lxF2rVmDSwTf3xmXEw6o//cGLwp3ob", - "sc0RF3ZDCugPmY9jnmLKNodsyGxbyE1Egl6g/4pRhDNTk40ypPXfBRoLKKBnPT5l5130B86yT5tDZovm", - "G3DrDEMNskmRhMWcv9aOykSl2tdJgQJhnC5DVtzWBSSecfv1S/x8SpJ6Ck/DoqzW36zm9GwQAg+E9BK9", - "kVAjBlSQgrI1GRV5L+jZYHNZn1ujYxQ0tIL8rF/c5B8e5iFzcVPO4jGJaQR8xaXtzWxiZ5ExaSjNGvEy", - "wa8XsDdvTC5ZjHCuZpoXRTbPPeL8kpIubGkXTQQUeOiaL417fcaz3njRm/GswDzAwgSfYOOgrirZ/6dn", - "J9p7TwSd2J6CqeuaRAJCJxwZOzOTAVhYF5Ym9taU12Ba9JkT+7qp0yJNaQ4TWa1ywVz1CAI1JAFco6A+", - "EpLJv0dRQsHqJGc8T2I0A/w7pZsJQdh1BkUGCx5HMZnU/12NMNjZfwIarPv37k7rBFKzdKuoLE8COm3q", + "H4sIAAAAAAAC/+y9+3LjNrY3+ioonf1V7D2SLN/64lTqO47d6XgnTnva7p5v7yhHhkhIwpgEGACUrU51", + "1flrHmDqPOE8ySksACRIgRLtbtvtSddUTdwiievCwrr+1h+diKcZZ4Qp2Tn4oyOjGUkx/HmoFI5m73mS", + "p+Qt+T0nUumfM8EzIhQl8FLKc6ZGGVYz/a+YyEjQTFHOOgedM6xm6HpGBEFzaAXJGc+TGI0Jgu9I3Ol2", + "yA1Os4R0DjpbKVNbMVa40+2oRaZ/kkpQNu187HYEwTFnycJ0M8F5ojoHE5xI0q11e6qbRlgi/UkPvina", + "G3OeEMw6H6HF33MqSNw5+NWfxm/Fy3z8dxIp3flhrvi5wiweL854QqPF8mR/piy/gd4QzhVPsaIRkuYb", + "lMFHaIwliRFnCEeKzgmibMxzFqOLozMUccZIpBuTQ8bHkog5idFE8BSpGUEzLhW8owSOrpDC44T0h6zT", + "re0HYfpJvH6V/jYjakZEYLBUItsKmnCB1IxKRJl+GpG+v2FK5GR5ZbsdGidkpGhKeK6WF+pHfo0SzqYw", + "LdcuSnOp0AzPCfpABEe/5zihkwVl0+ZFGpMJFwT9uMhIihnKEhwRiahClCnuZmPWqKSx/TREXHTKuCCj", + "mEhFGdbtjzIuzImojv4N/IET5L0LQ4P3kZph5aiccYWuCMmqE8XX+Kq6jL/u7HRfDgaD37odqkhqjhW+", + "oWmedg6e7e/v7nc7KWXm39vF6ClTZEqEHr79BQuBF950JM9FREYRjcWqmUQJJUyho5Pjt3ecQGd70If/", + "bb3odDvbL3f6289ewL+3n3X8aS0tfHXkH1cfvXOFVS6XeZA5TSNLKCOPSJZn/UuejolAfIKiXAjCVLJA", + "cKRI3ILoKtMehLYi4mxCp7lwRzB05CrLOcMSYWaYRq/GL8rGWp27SDOxmF+zkSAppkyv8dIg3rpHSJ9Q", + "ZA+RHlLEmRI8STRTUIqkmZLuFHU1G2cIZ1lCI2A9lUO1lw5kp9theZLoh7URlrtNEjql8EKrpaHS2yT3", + "LVIcEaaIKE54m6WpsMWmjsvlDu5GyRdbdTnjSTzKmaLJcq/v9M92Tf2eqEQzksSITyZdRCcII92K/tnQ", + "eHXZdwY7z3qDvd7g2cX284PBy4PB/v90up0JFylWnYNOjBXp6V1uszfAtCVlUXh3WJ1EUn0hCRKZ3Sku", + "rMoGjknEU4J00ytHvvfpI2+8bo7KTdUvIvtiebMGtrpfuzLaUXeCpSqYEOwXVYsRDozpgqZEKpxmmg/p", + "MXiL2cSFXIP1fXArv3KBtz9pgRm5USO7QsH5hOiD3GQk0jcid9ykEDB0e5a8C5b1IDQuCJZ6wFpM0pfp", + "r52cyTzTVzeJR1mClW5Xy1RABqOUSqk/LX6IqTR8pNtxRD5iXI1Ezph5kRF1zcWV/6ZtZUSzTrczw3I0", + "n2Z5p7vq2qoSNXRBEpxJaM/uuBgRIbjoGNF4MZpw4TZJ37nlEq5oammFZHHFBlao0+1UFqBg524ubtzF", + "rgYHB70ALQmjVRg1ACazPHC/reXhFkNbzdjNLWKEaLfNyH4sqxwgpnjKuFQ0kq3YPAgPentTHgdY53HR", + "HKIxYYpOKBFWriZI6PshJcg1gnQjiDKUy9o5KET/EZlrXW003xupKFtelJpi42+eJ5uUN6J3KxfbX5yU", + "NURanXtQcZpjCmfymMypuVqqspvdmlEs6JyIAPsuBADDCs17aEOfdc1CGGdks7JSbE5jituwgxjGNKIB", + "6jk7OkHmMTo5RhszclPtZOf5+EWnuUmG0wAt/JinmPX0gdDDcu3Du37bP+8FVRSepvloKnieLbd88ub0", + "9B2Ch4iBhOu3+GInJKlmER3hOBZEyvD83UN/bIPBYHCAdw4Gg/4gNMo5YTEXjUtqHoeXdHsQkxVNtlpS", + "2/7Skv7y/uT45BAdcZFxATrb2oPjL48/L59sqrsSov/vc5rEy1Q/1j+ThnUiQlKpNL/63ryGBDGqHLqe", + "cUlQhKMZQWOjtIB6Du21IfmiY3d7hUZwTGXGgc+j96elNAeqIbkhUa4q/X6LYqp14cgKVd7kWowo4nqP", + "9P0bkixgBZB9R6vayolOdxYCIkHwmu70G606Wz7/uSGvUSqbWnevaDaf0iShkkScxdLvgzL1bK95Mt4p", + "NtfmUlev9M8oJVLiKUEbYJYCdcNweC1tTTBNSLzZTsJumszf+di71ypnDsigh8fR9s5ukKGleEpGMZ1a", + "u2KdBvXvWj7W7SgEb4cnAhJGu3lAl4JMlvv7Ae4T6ESQCRFEU/wndpcJPicMW5XqP6Dfzv+1VRpct6y1", + "dQsW86x8/WO383tOcjLKuKRmhEtswj7RZARLjeCL8Jjh0aq99ihKKixWnw944zOcxFLYXLs21vSj5S08", + "XfvJhX6nztCBHRUCjscFGvn2Ky1pBUQWzpR9UDMB8ylKKDNqkObfZi9A2Ftk5LuETzc7n20diuVfPvx6", + "3HdgXuaHhtb0s26hFSR86q/mjGChxqSymA33qm2oHF3j8p9Vjk/tAsWSjFZzkDPKGInB5m4PtnlTy9ZB", + "3QdO0RVVo7m+gUNnDob1E1XIvtHYVMKjqwlNyGiG5cwaKeOYGoPrWWUmARGy4szAYCRwDYJoA0r1+Y+H", + "O/vPkO0gsIbW+qtfWJ6J97Vu3ooXCosxTpIgbTST2+3v6GUKCVNAafBtunsKCnSEaThdx+6mVd5zOTN/", + "Ae/Wo4K7T7MBTV6J/nuJcLudm55utTfHAlZdN+8N66+uJ++378tOvV/Piv69H9/aoXg//eBG5f12VA7Q", + "rYpRk4ImcJh0WKrklKkeZbAFWgC3lidzdZRmVmvJQHobCwkPbvZPEqyOlkQqtPH2h6Pd3d2Xm+2FKyqv", + "RpJ+IKPpeKXcbAdtBWX9GdKf6atySqd4vFBE9tERZowrNCYommE2JTHCE6W/smOtKOD7g3VOmEYhiYgm", + "EUl92MMvX9zcYPXyGb2WLz+kYzH9+27Q/wnWRc1vmi1w2O6mfhUJzAKbeLeLOsU3WvM3VBJyfp4adxUq", + "XrJ7IOtD6KNSoZ/QG73mCm1XVjq4uGHtr3BhMc56OaO/57DbWYIXyzqgIjjtAUWEltfwDXOA5PrjI/X5", + "Md+4mV5jqvTJKXymdspdxJNY3zsTKqTqtPeG3UowIOJziUiVUxaWmJYpor6AjZd6Mc7Gs5LQCYkWUUKW", + "mbtj35nIrbk1JvrOgT8LA2JbNu6GUuHE7sezoovKz8dlf5XfX5nOP3Y7RzjDY5pQx5mrfNr65EfW8Ldu", + "l/zGjs2nb+2XH7udCcEqFySwlucQH4CMnbiXkDlJkH1bE+8BOjw7QTIXE3CZA7WadxG5ybgkcsgEmWIR", + "J0QCqV/PaDQrzJVjHF1JhFkZFoAOk2u8kCgTRBKmDoZsWFjI5bDTRUOjfNm/DZWYv01oiHuJTQWR9h/k", + "hkTDTle3lfCpHHb66EcuVU+ry1aWOkBDa4+Rww7asCa1DEupZoLn09mQpZjhKUkJU10EQRpgBtacKVls", + "IsxiNOwILolSuEfSPAHerxs7zLKEDNk5TWjEGUpx9ObcfntN1Qy9NR95lyfMWF/WXZRZ07PEKRkybMyg", + "eicXKBN8TFCCcxbNiESETbiIyGYfnRHRc0vs9hZhQfRmGCMuypk+IgSXmyF//Q0RpoSNdGjPXPRMbkN/", + "eukLJVre5ssT88XHwjtym49/sZ9oVmVnHNRRxKK0pnv0bG7EwtTvrqOKJ6A7ZLCisKtUv3XNUGXLJgme", + "Aql4h8gQoxyyiKcpKHq2FyTJnAicFDuk7zgO9gR9N9gbaciihOdxb7bIiJhTyUVX3xAkgmAE0UW/kzTv", + "Qqf6r15KI8HnKeJsyBJNxls4jZ/tbfbRIUq03BMX86eGuC2JAScAewDVox12sLPJDztmYlQiffH30Sum", + "BDU0h6QhuLG5SWvE1XbzPGa1dLfB/tymsXPzxZKHw/xsCbq7xGI9uikJsCBjj4uG7qwVDHhZBnfrutoZ", + "ZcdX7FaEmd2pCnUeIKoQzjKChUSUDVlJTSwGKrUfZYLAckiq7NalRKGNFCtgL2qG1ZABh/hGosrub/YR", + "yI0mPqzkSGDoFSRZmAGRIXNjvqZJAoZDBIENhsOZgwaWMS+CAg4TZgXvS0pvl3V+Wb9oZQKVI2L6/uur", + "03dILqQiKRpThsViE7mINM1rDWkuO+bCIqPdPdDcQVSrzjrmEPWkv0WcVQXI+nld60KwImhJF+so7Ece", + "CrHEIpqFwuikQkdn75B+TBWJNBFXxotF+izoSuKyoTndKwYh1qx3pTlgOmvnzPWRghGvm+xJcY+ERaTC", + "Hb/s7QJrTunHJQmJNK8y8TRGfyPIOpcRT4Gl1zbTstCmJXKNy3W9S7TB5Zae8KZ3s+gzLXLWR28YAjEC", + "VaSIIbNBlVGSx0Qij5+j0nzr5ItKGFRB9kP2r//3/zOyRIzmFBfSBnpPhcpxQj8YLXYicEo006teadGM", + "RFdDVhdDkG5VcoTdpVIsM5X+hSLodKYQ49f9ITsxYyoGvCH5RF1jQfJMq5mo17Oj7lk5a9MEcUFDVNVC", + "NH+t7Y23OLeMJay484oN7S5T2DpS/aUUW6q0OsWKXOOAQvxak15vTiWErAFN2HfRyVkfwWNwJUQz81Rz", + "US3Fog1DFZRNhwyeWJFYkxdItIbMrGuyj96kVBXEz3hxvdibbshmWKIxIQy8iMncBCCXpLog9Q3obA/6", + "24NBf9DfDh2NqR77SPHRNA/aXd1dF2thRqH3pz3Fe+9PkRJ4MqGRpqOMCDtqc8HUBx3k5ymPSdKw0u5D", + "F0KR9LWSIWg81UKOXi4r+pvfzOWUEaHHdXF4pi84WP1v0bDDsDLflJy+lwk+pzGJ0S+HF/pqjd6cb9pV", + "s6qpaVhLGEEjeLcj8zEjqmn85inE4Ia3AkJp1zFfs0RLW7SOvD9NotEXv2ngmxUyScnBtHSihdiCBPvo", + "54r8auVPF7iiRdBlib5owR3ib1Ex2u9AoEEpwUxCCKWW9X0hg8ohc9JHReDQkjayK6o/FTkzXMrchz2P", + "+4JYYuQRkMiKIP25/v/eXPLoyoZ4GC2z4LQMp3C6/aVbkqyoaiXkrDUBeApLYEkLicwXPLWOIBnO5Iwr", + "0KrdP7YEkYoLsqmVcxsvpZ9nOJcE/QWlJOViMWTu/a45ZwlWJoah+HaiGVMHbUQJZ8S3I5gAAjxkUvEs", + "I7F1THyrJTU43LgIIuSisFWbl7SWDF3ILSMBSFTMAjag9MAki3KTvZlsgqkBZlNMS885T82wZ1xlST7t", + "2Xl20EZC58ROW0+QfiCmjTFOEs5Zz8ZD6ldL2wk8QgqLKVHW9CyhfaCZYWfINuDcIkNDWr3MGY3gNof3", + "plne800cHbTx+uzdVhmzNGTeY/gkpvKqR3kvoSlVYCmh8gqdbL1BQt/Q8DNlUzN6eNlMp5gkvG+n2Efv", + "cZLbcwrUrtdXK8JG+oeR9pTI1WySJwdo/sEKYdX9KHaykIq2d7vW0pSAhalRknl/iiSeE0eQ8JVWqaXS", + "xwjHc83DpLXGXqENS0496+cCupOIERIjxofMb0sfZCBTPcS6yI+MIUai2iINWSm21SLJ3dXmXIaFKcIJ", + "Pu0tNqs1mgYfw6drLbdQkc8Lpb52kWS02at6eHZiAodxVC6Tp78S9CYjTL8V8yhPCdMy0YS7V6tRZIP+", + "TlNkWkP35/61UnQvZ1woNKVaEdZiHGeWmw07vZgKpY+/zCcTegNaZA5WICPTGONmV3OoYSdnV4xfs2Gn", + "Osznz/DOJB7vrt0NN+xuZQ2D+wBMz1qlG/P1VvvQjlY6zLSoqSV3ze09zd9S+Gdwl5UpSfrSEz19VhIK", + "kfuOtrsmWndKmFZV9aMJaOxOQmtw+fSCouy9e5Vu5Ylp2FETOdu4oWvmME34GC48N5Ey6LSPTqpr10XY", + "WD6orK9ykS3hBYaiDdKf9vXtkkW0NxgMBj280xsMeoM6tSd7PRPynmGliNAD/H9+xb0Ph73/GfRe/lb+", + "Oer3fvvLfwQV8pbRqs6vbee54c5SF7nB+iGs9YGuC2+9s2fNH37z4QXTQuNOR4LAMcDJ2jGc5XJ25L3e", + "TOtHJ8tRb2alYh5dEdGnfCuhY4HFYotNKbs50OKcVNV1W/3uKgNLIDnGCEaF3QFLZC0sv27NsaCYqd/M", + "GNHQNw5o+Q3BraHv/WMYEer1XDuWfbnI+4oDoI9+4Qph65HWKt0BvLTc71TgNMVg7ZzjhMZwOKybTdKY", + "GOMsg2f6DS0lCaJywdDeYOCejLxBabmBSzeVYccaTkU0Q9YgJBCYQkyGTEQyiL7lMa6lKfl2kqWTpo/Z", + "X7aKvzbcn/3f/rL5v//js5I6ENoKGjfmjFvys1qcN3CpjYRfExFhSVBC9GxlF8V0SpU0wlsMoVFgUPsW", + "RSbYw4Quao2PORM1vFcl53TRwxntWcuLcXf/TNhUzToHz3aDi7th/+j99p/up4aFFXkS0tfe8hwsr/DY", + "T7x2Y2jlfHGrmyfgdEkpOzGfbS+Lk5+2w24iq3baKHWNW63vmCIraM1AljPwP3Y7URoSIuZECH0UDV87", + "Oj3WKswVsQxNa41omA8GuxG8AH8S+4sW4rQqA79tViUeG0MYEHZ+7ZBoxiEMMkn4bYyU3TpTb4oSXOkj", + "C612yf2XYwrBiW7c4hDsbl9EY8GviACXjF5iSiS6IgtjLphWDJqEzdEcC+cavIC0BKO02kfgDSKgHmsl", + "FpLzZxwCPAxbtDqbSYMiOCm5ZYopM7bPnox4pmU8Zl8zFv1LwuaXKMWZUesFMUbzFCsiqFYXS8NiSmKq", + "efSQETgYKMP6zEcRFzHkQHPjTC9X4RuJLo2aeAnNX1KmyfrSHMyaqfSPzpt3F9+/effL8ejN2atfDk9G", + "P736b/2z+ahz8OsfHQOmUQRefU+wIAL9xx8w348meBZi+jqHuZpxYbVd56KHeBWc0T7PCMO0H/G00+38", + "p//P3z7+5sI9TebeXB+DwMA+BsVMa/sMxOC7BCiJbFKNS+fUS6ZZlDM7eOaGminfSH63OhKgolA+Gmcy", + "mJtSt1uUcuj2YHD6/ZaNHtl3/9jsI+u6heFrHsSFFY/lTJNPgcsB/rQk4ZHN0Jg0peC7rkIMHnytGaeh", + "EPEacypfXeZRvV759BasaGtM2ZbU29CLbrfuQDd3DlR+xeZUcAbqOYhL44TI6ln55c3xq9GrX953DvRF", + "EOeRTcQ6e/P2onPQ2R0MBp0QgZKbjAoig4GPh2PJk1wRBC+VMaZ9dJpLCO60zGOSgx0U5DOJ0lzloBaR", + "myjJpeZTIA0olYTyj1/0drYvtl8cDAYHg8H/tA5Y1aS/hnm/PntnMtThvBOcqNkIXHPrPvwR3j2CV4FV", + "gG0SVPvAKhV76qyV+vTab9DGrCpdmckhIMhhZ/f19+ZAbb+Gs+QI0aYCF62Yhmur9/r70Lp41qhlSa80", + "tkGGG4sd1S+D0Rh7fS6tmcj3hPcYBogdEC+wiNGYYxF/W42lgS//+ur03TcSgbUCJ9ZPZERv+MbIkpxI", + "0JVdjE9pbJXd2hKA5Sc1pkTMEAFP6ZxqltNLU8rdd1W2ZI0pFRtq33MwBex4XrhQp9vRM7P/cRPsdDvz", + "D9Wc6vXmQBsa00pXXKME4iSjjKzQAr8QYf+ai6uE4xisRJ9T1vfi3WowNOZBdbsL5dQR+BK60xiz+JrG", + "ajaK+TXTQw7ckPYJKl4urskbPROc/Osf/3x/WhpEtl+PM3tnbu/sf+KdWbslddPBXJtiInkWnsa7LDyJ", + "96f/+sc/3UwedxJGpLyTeG73/5VpoQ5QY3G7TDR+g9+1kMPK/Fze4Etfg2EQunL5nIgEL7zrxI6psz0A", + "nl4blaDG3Wi/80zIqy8X3ZoT117XrXE7g/D18dV29IXbjgQB1jvKCqy8VQfkrXm7VOsDRBegue+xLHyv", + "bSitILTtnVP7584yyYUpTl7RzARTjPC0yP1cBa93fkUzqzTDF86Vbxh9nIOaPeZc9YfMwOfoswmUQ25I", + "5BykCh2enUgTHaCvNLg0lgUhrYJ7uEvwuvGFipx10ThXWq/miiBr4bB5/Hos8PKYoLyM1KxpuXaCy9EH", + "sCxXRDCSjIwWK1uujPkI2Y8aFwemOsHSwn0JlWfV9Tr+6fQcbRwvGE5phH4yrZ7yOE8IOjei2mZ19bpD", + "lgnAUNGdaHKktl8+QTxXPT7pKUGIG2IKjRXODoskMX999s5ikcjN/pC9JXphCYttVKmTKIqwUvaNMv7m", + "SrN+/7VFb8KbcT70lofr3L5enq72Zr9uR6kkhCCXGNk6oRNikhUET0vqc/lzXa16veYl4IE5kH30TuoD", + "OZDDjmZQXLPWMVczX4WbUJLE0lczSpzK8jWt5IX1uFJlrCkke7OgczjK8irp7nQbgQPnJipBywwVBTeI", + "smKzW5ZX0eCq+gYVe3+XQIuqikTR1gZsWgYU1LVhkdaUayT+1qZcz7i4ZNR1lq8/WmYLrG7/hLmBrDRl", + "l8avT+jr3DSylF9gfu66md1hlU6KNakZwD/P8hxKz1jYCrDUSDDmmpdo4xJntG/puB/x9LKLLv+z8oPm", + "cU7x13LyNTKrAXyT6Z/89uVSSO8aA+adw3qxvPt+HMpGuCk030ZKYCZNgPkMZ6SPfoTLCimSahEM8Pn8", + "mDrGr79F3Ajn7tMh00OT1SBDZ8aWdMoom25CtPiYIBzHxtZtTVUu8ESGcH6dAWpJAjajs2g9gKprw8zR", + "pbM5X1Z54rJFetlgY03US5q6WRLQ0MGGobbSXOnu9YQLgZrnyqB32alXkdVqdu91ETF2LIUN7g77f16w", + "izp48jygqkMuyKR0VHgekybHhBXIwk6TK7IwCQLWQYKXXCS+byTswbCx3Fa88L0rY4CH5ja+s3CsGBeJ", + "9YpA1G0t6DvkL1i3FXq9Wi9/VeUNJqD03GRLirFarAPmdFxIT870B9Y2SWDxQYU+MPlMl12j8xPQsBDT", + "xJK4oPR68xCPC9F7l/aXvm3tssiO+xxg22Xag7+1yNtZJ96aEGYrHikSd6uywRUhmVw/KROXOWvw9wly", + "LahjZA7ZsaUYatNFUqsMfZoB5JXX2IpIqLZNLKeHmvX1xuwwfbENMIO9N/sBjp8K1G8d8T6uaacGMqXa", + "5SVOkku0YV/aRIL8HeBQ7V4xzkpivzg6cyRQxFm9P+1qitRc4HKmVDbS/ydH+hRf1huz37oTXqKRvxiA", + "Hrm3t1tNVjADrjVbtRMHYWCat+Ysl7N7ClxqsEY72HdjkXY3UJbLGdpQeKqPjsX70ffE5qdaqimTJMoF", + "WX/WD0E8spgzMKYEU9b78eLiDAkypRJybIPR/CYmvAGczCrwZZyWm67iVXPlzu7e/rPnL1724yvRJ5Ho", + "57JHsFS97T5O8QfO8LXUYtdWusBZdjDfXsvnzRYU42tm+U7bbCYFnmqW7CJx22iuR+UnpafsirK4bQM/", + "6XfXR3s6xfq+/Q2ZIL08mwoMcJef09tw54AeWM3mXV1TAmR1JG8uFU997NuN1QG81cWa86QXY4VvE79r", + "hrscqpsuTFNGJQ+a+Zrios/rwdAVwJpgIYBPja5yYwltSxOYrjEkkHikeCBv1ckOJ8eAvW3fbYUaCti/", + "io/mE8rXZbdDzEglZ9VIJda8oZvoZRG11jOX8AHCqlkE0B3en/pBJf0h64EUdoCOiw6KZosmMagYODbu", + "7Q0uKomz4EQeLzYRRu9P++iiGO03EllvsUUXLlImc/CkgVDUMyKZP4AcMkyoqn9uTYUGiHgTYme4fdYv", + "ypWAUbIwaoENa0xr8zEJzbBR9i7xMVdaGmlXwXK9NffRKmiuSijEfm+w3dvev9ge3DYU4h6wlkNtHVZ5", + "iw1697nP0buT4x1rity8MxrXZ0djDrOy4zJaH23kkniZqFwEY/S9UPiGGPw7h9bfU6R8iTu56l2zEhf6", + "zfuAmQ4BYVmkytsDQdcZ5lq0UW9yywadRQbmh/KUeIK8zaqIaDDl+JjKq+8FwVcAn798bwMAw8jcZ+FA", + "O0DJGC8AFQqMXIJzNZGoAE8pjd/be8/3Xuw+23sxGASAjJcJnkd0BHkzrQbw5ugEJXhRYPptgP8+RuOE", + "j6uEvr/77MXzwcvtnbbjMB7rdutQ6N/uK7RhV+QvrsSZe1IZ1M7O82e7u7uDZ8929lqNyroNWg3KuRgq", + "Isnz3ed72y929lqtQkive+WApesCfKjKwaEpDAR5nzIjEZ3QCAEgG9IfQMr8jDJSOGerZ3KMY1cLInx3", + "KEwTuTKszXRm3zT21jRPFM0SYp7BhrRyScDMj6GlYHEtxoraG7drycJxrw14cnMpXgFxJibjfDq1WHfF", + "0p3aNPZSeKIkiQ9QobKt5HOwm+XAfmuiAzuHltTws1adLOqcRwTm6jIQDYKggk7MplVm5WIdKMvyIEk0", + "LuUPuTDoE9AowmOeK+NVt8VSyk4MkAsDL3PO4nbmjh+4uFqb2KBv4qImTEuDgctwNxnwtdx2X+grvN8m", + "RsA+l+it+cIYCsufs7xaEK8LPVmDInNp+XFhN7bNtJUuw3IL2MxduIzpr+SdDxTK15uY6KfPq2GLKYFa", + "SGqtxKIpxcQjncPrrfOk9IdrDSkt1p2R64dYdMj062my7UmGs/tZ8VURw4WtwcvT5zbOvo/gdEGQn0vQ", + "r520cwc1YZe8P2Q20cjDKwBHGmAJmDDxGaECcUGntNpx1c76gDG7t6FMR1x3pk7/w6YYPFm4AUM8wCAt", + "T0wIrCkA4gO92j3pdDvnRdEoy5iqS/O2KLy1tCJlZP0y5s7Zu9tG3maCT2gICAcigexTq6i5mNSf9wbn", + "ve2/mqh5TX4gsVFmoodSHtdqPNn3211Er8/enTWNqai6hPzRLc1pBbxPGe1SoLgaV6P1VVuFxp0Gfc8U", + "nZSi+MuQaAsoHuN8MiFilAZsbT/o58i8UKAQnH5fFW+1GN1WiT6rbA5o0RMcUTbdbL36AftcbRrrYP5e", + "n717a6v4NAEg660qKv0Y7OM++qWoc4Ven72TJchRP2C4qwPpNeR1nc0WkkY4MS2aIhWU+fY2IM7WAvNZ", + "+aG1TAbE5nB5NHcQ0MZ8muVwDM/f9k7evN9KYzLvVsYEcXUznhA97k2PW8wduECZhFZhEvMmw4chDNn2", + "AHlrVZzg1ovkndfA6iiucDKSCQ+F8Fzohwgeoo33P5jsYD2CLsoqW6l/95F/fPp+FjwxACjf0O05dFi3", + "oFYOeFCVrCOSdarTq3QaOioms2lZ5FmuTcivqhvNr9bXwzONNPd75JKvajZuK1cik6Nl4BNdVAIynxoj", + "trWUSJJhgRVJFrXwyCq+OVl2MJMbEt0i++uVfv2jqWmRCzJSM0HkjCfVsITd7nJdVAkhwHNiS0GZOXl2", + "eMVRisUVXIxOrkY5MytQjSDfXQf3MlMqu8Wkfry4ODPKtiJijpN6DoJccrgfkwQv0Jioa0KYmwqWCPsh", + "qPUcTtlQjkeoUUYE5dU17OwG+j03YcloKnBEkPnKVVW2WyLh1my7lLaXAIBgFBEpG/Z3e9X+2k8nedJu", + "j0PD2l5bhDy6zQZfHJ25sjJF2V63zDvLq3xGRM8cOVe/d/XW7sjVBY5cV8zgwi4JDGMCFY9s4oefl+nC", + "jaCik/68kgvpMQfpZ+/ZfuAUmKXqmnP+Wythr37cQ371FLM4VP7YxPubTPkpwGdB0LHIwZVEYxOLZJL3", + "jVjupy0IgmPKiJS1NOMoF0mn2+lN7KwOtrYSHuEEEAT3drdfbK2O6lwZzmujl0YxXaXuuRgnEwXjkloN", + "/BpMukoSWzjLWhjEzDquuR+APS2HD0LJY323eRKeiycfDJYypm9wpFyRNrCQVTyg2D+2gPNbmQ80mJq6", + "K1rUfumfz0HQJZ1hNauS/9YS7UOYCoQYahox1ofaOhoi/xCUqLgIVeLiQtkEvDFxMYjFfegi/ByibcVX", + "Nnjhz/LZ/v7u/jo+BMymdsztuQtM1bxdTTEm3nLb82sbgGipqszhjvTqYqJ6XdbQlOaISyS1ekF5Rtit", + "1nN/b3fnduvZdiInLkqrxpdCCC1Hp8dGJoo4U5gyIlBKFI6xwlUmA6YlzWWg1AsmKWTpTL5dzVoawhl8", + "yJW71qn6XM7whpJ1bx1Cc4oZnWiGbN/0e5YzvLP/7MAU1ozJZG//Wb/fvy0QxasSeaLVVmyZmDkPk6Iv", + "Z5+2D/eAN9FmLn90zg4vftSMLJfCXFpbckzZgffv4p/lA/jD/HNMWRinok0tVjpZqsFaDQ/LLRAwiQ9Q", + "WW7byT2t6vWHbcMQLAzgOEHUt0rQ5P3BuxU0Tqso/rdI1V2RuqrFlTcsWTSuzZ3LqpYFx5VXTtVP/mpR", + "WpV+WO3uduYueMf2afCgi6qzy47uO9UNlitLKy5V3soIK4opJon5K+IMcHZDlRUrV6R71qIwF1wjtgJX", + "0aX/Y9G79+ORPxDvd1fYy/vJllj87ZYRKisF0r8ty6HruZATR9cc5rDtsbgV2paztbBwwdDsR74L7xLF", + "Ve39zfS/fv8/8uz537d///n9+/+ev/6v41/of79Pzt58EuTIakTAR4X1+2xIfqZEuw/n15aUTrGKAjY6", + "rf41rLB9YiwOKppBAU40JgdD1kM/U0WEKedWy0UcdtAGAU0JvtLiLtSqMWlgm/rjM+Ng1B//4cTgj/U2", + "YpsjLuyGFNAfMh/HPMWUbQ7ZkNm2kJuIBL1A/xWjCGemJhtlSOu/CzQWUEDPenzKzrvoD5xlHzeHzBbN", + "N+DWGYYaZJMiCYs5f60dlYlKta+TAgXCOF2GrLitC0g84/brl/j5lCT1FJ6GRVmtv1nN6cUgBB4I6SV6", + "I6FGDKggBWVrMiryXtCLweayPrdGxyhoaAX5Wb+4yT88zEPm4qacxWMS0wj4ikvbm9nEziJj0lCaNeJl", + "gt8sYG/emlyyGOFczTQvimyee8T5FSVd2NIumggo8NA1Xxr3+oxnvfGiN+NZgXmAhQk+wcZBXVWy/0/P", + "TrT3ngg6sT0FU9c1iQSETjgydmYmA7CwLixN7MKU12Ba9JkT+7qp0yJNaQ4TWa1ywVz1CAI1JAFco6A+", + "EpLJv0VRQsHqJGc8T2I0A/w7pZsJQdh1BkUGCx5HMZnU/12NMNjZfwYarPv37k7rBFKzdKuoLE8COm3q", "WF8Ljm3YJAzAiAcjZwhfExOkb0BbyArsFIrDf8+Ra6g8cQUjMd4pk9ImbTGCRHrJbJvBrCN7DCyCxwjb", - "09TmPlo6hZUkphYtmGAB+CxpgR3ywiRPvn11jhQRqUtn34j07sApMUARPSplbutiHR6dvtjsd4K4RxVX", - "FWzVyiSn6qAD0Ac2eKApJqK00eCUdNHJMSSv2mul1MUg2+AnLlBibsXyMjoA7IyquQebinonx1YATRZl", - "BIIRW4adTddiVr/eDtCbQgXExVCKNMSStlyT5WUCzdp4NJMKsdR6LWsV/GNW/bP3MSQ+QLlBw4sBu7Hx", - "/mpvc3RIUPqiqlnIbnwh+UEhjfYvb++/NMrxl5fRd28mo1sf8CibYRmi7pnv1YSXlvbddyNX2b1oDuGp", - "9DuSNHi2/uaKrnjXkCL6nqt8HoL13O9tb7/d3ru5+e6mALVVZCoPta7AqG0PLnsXIK0ByFWqRo2x3kg/", - "tpHdzi7y/hTNsGTfKXhYs45s7z5tY5SAXttGSfvx0XxihlRwKQdzVUT3GsCvS5okRoCRdMpwgp6jjfOT", - "l7+cvHq1iXro9evT+las+iK4P7fAqoVbANbRJHwFkI4qGfqoSOV7+/YVHK6EQDaEkcMvb49gu9a02ALR", - "1g3u5dk7cPxjOXJxlM2pg7hMvyXXVCq5DHLWKhz5cxB0zaftau67SZo2ytL7q2F4f67gxAZR6zbvAD/X", - "xZIvLecDQMs+ZM7e1wdruxKI9nPRZK2d4Y7AZBuvtBAQaw2gYL/pdrs9LOydDKeC7xJiW76E4xKqb43D", - "2u3QQDLpodQXD4nRyVlZeKl0Rrjma3N6vtPffvIMaoduD9ow9hRHK/o+PTxq3/lgx9wyB3h8EMUHoLDf", - "1mdlCduoIDi5wguovWeWdtgxF6an3XrH1iqSreJrluFub4duWxfjGvBrQZx1gUtylK4s/dEiW7COYZbm", + "09TmPlo6hZUkphYtmGAB+CxpgR3yyiRPXvx8jhQRqUtn34j07sApMUARPSplbutiHR6dvtrsd4K4RxVX", + "FWzVyiSn6qAD0Ac2eKApJqK00eCUdNHJMSSv2mul1MUg2+AHLlBibsXyMjoA7IyquQebinonx1YATRZl", + "BIIRW4adTddiVr/eDtDbQgXExVCKNMSStlyT5WUCzdp4NJMKsdR6LWsV/GNW/bP3MSQ+QLlBw4sBu7Hx", + "/mpvc3RIUPqiqlnIbn0h+UEhjfYvb+8/N8rx55fRd28no1sf8CibYRmi7pnv1YSXlvbddyNX2b1oDuGp", + "9DuSNHi2/uaKrnjXkCL6nqt8HoL13O9tb19s793efHdbgNoqMpWHWldg1LYHl70PkNYA5CpVo8ZYb6Qf", + "28huZxd5f4pmWLJvFDysWUe2d5+3MUpAr22jpP34aD4xQyq4lIO5KqJ7DeDXFU0SI8BIOmU4QS/RxvnJ", + "659Ofv55E/XQmzen9a1Y9UVwf+6AVQu3AKyjSfgKIB1VMvRRkcp3cfEzHK6EQDaEkcOv7o5gu9a02ALR", + "1g3u9dk7cPxjOXJxlM2pg7hMvyU3VCq5DHLWKhz5UxB0zaftau67SZo2ytL7q2F4f6zgxAZR6zbvAT/X", + "xZIvLecjQMs+Zs7elwdruxKI9lPRZK2d4Z7AZBuvtBAQaw2gYL/pdrs7LOy9DKeC7xJiW76E4xKq74zD", + "2u3QQDLpodQXD4nRyVlZeKl0Rrjma3N6udPffvYCaoduD9ow9hRHK/o+PTxq3/lgx9wyB3h8EMUHoLDf", + "1WdlCduoIDi5xguovWeWdtgxF6an3XrH1iqSreJrluFu74ZuWxfjGvBrQZx1gUtylK4s/dEiW7COYZbm", "FqExpUlCJYk4i2VVRp5hiWRmgElNCYxCgh8yGGAXFZWIQUpBOIpEXpoerXRt5f08s3QPZTgzzrQOADj8", - "v5CFRCkFJ2jRPYQ+SlQkpcRDtiFcAlORqQQVOGP9A6QDdF3YuR4aVVDqQ38wZHKWK83ENvvoiDOZp0RY", - "qywaU/AYbSKZG5UWxgursdAMU9KYiCHTrwWgT/8o1JODJ4PBYNDtFJrcrv73IERNd+r87FtoX5OCC+B7", - "zIL8AqqfyBnKWUxEUY6bGHKoh8jd0HH6mZi+7vN24pX9vJSrwgdzHQRwO2zfzwVUhaE26OcQHXoL5Xz/", - "9iJ6qxQgJ7/a5B/71egmEQwERTxPYq3xjfVtZwxyJLZmSEmU4c7mXSrRO1MKszp1G3qsOPo9J2KB3p+e", - "VsIeBJloHtBu4sAlGvaBZzfahp01NpK1o7mJe9mDn70PyNm6pOJJiF8cYNb3MLqcZEOhFcNWRXFeZV/T", - "WmUwi4Qys0+aaFZMsGbKj8l8lOchrUQ/crAk796dHFcoBeMn288Gz573no23n/T24sF2D2/vPunt7OPB", - "ZDd6uru9s7si46RFFtntE8OCqmkgYLgIDx+5MPVQ9HBTkkBNCLCBz1eUxfyqcs8EI1H93m2U67rul2PY", - "Ww8hmPmSYKmMnaCBZZzCbUoi3baJ/LaZikWJobBF8cnbwfbnmllgcA3M+K3ImfFfmtz6wlafegP2N6s6", - "ztvxVhiQyzBZt1p+5+0XbXCw//xg/3MXzWVJrBtjnZzucXObQq8c9nAtDcOlAnoGG2cJ7Fjhw5jPbdZG", - "p9spEkvgb7h1a0HLxeNW2VJNB7YbZiOr+HdD0vBJRTGAkBCDPRcfaJGgyDcd5woVqela1jhKeB4jz+hl", - "oLjA4XXiKQm6GfA/WVuYgdY0WQ9amQAsZ6iYQJlmxODo043YDOMD9BLehUc4NfqTHYSp2+H7uHC8MIEp", - "+ny5ro02s3rI51aRgW+0VoP0v2DaehmsbXR1E0YMOkC/cvimUKsYrxtZzeugzyy/XjfIbtjUX4cYAZ1Z", - "me4A/VTIcYUkaCW/DUnsnyPLsEqgls1Kurzd8Y6mlnLnvNTvbsesaKfbcQsFKeLLyeLvSqpfOn8+KYYi", - "tghO4CyXybi5oomFp4aZUKloJG2Wht7cJvnClhQi8choKU3BnybD02oyxUdOfHl/ijYAgfAvyFqQ9b82", - "i0DRyl2383zv+ZOnO8+ftMIZKge4XgY9gvzj5cGtFUijLB9ZI0TT1I/O3hkjQ2TU9yLI5P2pD+uQCa5Z", - "j565a9Dv/Hn/uQ+vFPN8nHgePIvFZtBcYcOCCGIFL2oIOPydJnM6mbDfP0aXO38XNN2+fiJ3xtsNsLGm", - "o7B968T34i8Zg8m4Z8oDhRFwgKCEbASJekMkzACdE4WAfnoIR6BHFGnDluQclJRd8SBh7e3u7j57ur/T", - "iq7s6LyDMwJrV+BStiPwjhi8iTbenJ+jLY/gTJsOSwGQvZnVMcPnDNnavoOqQNrfHuyGqKTh4i6pxrY9", - "TxuX/L3V0+yk7KJD9nOhwy2d8uBq7+4Onu7tP9tvd4ytHXYkrldzGJcbZJbHAtD7O78B0uTbwzMEmbcT", - "HFWNKC4U60ajUjcaFRRPMKDnNxjYs6dP9vd2d7bboZ2Fojssjl/lwFZ5V+DQBYgisBuBpVhmvd2m2yIk", - "ThkCe0OiBNP0MHK5DLXbx4Cbj4R5rdyENheDNf0vXVwtvm1lRSpsQyYTxogGXKCcFSU1+ut9n1/EhdnM", - "tc31sJ6rh/JfmF49C8tjSofdYikzQeaU5/ILNMSVSU6dJJyLG33bpLC8ITJPlPEzUonen34HPEXTGpKK", - "ZFUdylLjCvCiW07uRue5QiJhIm9arFa70WbrV02423Bqu6uQKyrcoBEyLNacK2froyyPcBLlUEQGF/up", - "ZwVoP5B7n2XJwgTRJwnnDEUzzMAbIWzJKjZFGM14EveDIaf6yWgSDF/gVyjhBuz4kpDM1lcxg9CfaRGG", - "zgna8CuLGVKq1fvcTw2TsRU0qtS4n4YLF2IZygorcs71emLFPRxe80nF5JjwqQSlUEF6QL8O/55hYaL+", - "MTP1guap0SUDkc2BIdaYeehGNTcpn1gF14ockNFtVhJHgkuJSEKnUJvm/WktUXhFclmRLrw+crI62Bak", - "azyHgasMrjjZuqxY6H4MJM58zg0JNAzJeStiEp1xMsUsh4orHiFbi3e/ddzhjEs1KgCgbjhYqUZQRiEX", - "pESJK9LbC3uQeyd4LzrWdpvlsgG+t/p6iarCTTUNsJmnBlc0vFrdggZDZLwMgbUSdauE8apjNt0EpK3E", - "3acSWqUePhjagOQSjy152PGbbaJRwiqr7mdJW7VVMV/tDc7b4qethks7w2p2wiY8ALJxAxels0TbsNCM", - "iJRCIREUE0ZJ7HTJwldpTV2QmZ1IguKc2JUz8qnAdsGxOd4AlMGcjYyyaY3X1ztsYx42Y1hdZQH6tS+2", - "iSuS4czVtyKHtTKBgRLhMoe1VbQllaOwO2u5YUGmeYIFqmMErhiyXKQJZZdtWpeLdMwTGiH9Qd0BPeFJ", - "wq9G+pH8Aeay2Wp2+oNRU0meczM4m4BnNqTWbzmFH/QsN2vpv2CJ2TLfbwE0S5swrWBI9k80IRZG7x2j", - "1x6hV2HI93YGTWnpDY1WEtKXIRhvyrktyQZPfC4DSXwrpRxXTIjEFhveiD1ZLk1dkxa3kgMfdS7A23l0", - "qhkan4cBcmT4dQ0BBI0JJNi4qS1zjRZssc1UgiUVcjlDf+fjqkG0bXxtoFDXBiuxKASZBAPpYUdXGqTN", - "G0tr4u3uTcAegK3qicJHN8RQWFfSrAxkauInb5aqe82IXTLq5mgqfbWoXOECLQqcANtre8CAeh22QGAw", - "wMFItYCKplBGZuEVF5RozIUA5GUt4XDmZgP4Jlrm0WvtAKbQ2xlZIEFSTNmQUVYYSQG1jCBG5kR46ahc", - "aCVrSuI++pun4gFWdZqphQVBB+P5dxLxK1aMccj8QerGc6nbOWTGsihyKJJfvqSbBa1PEwqkB4MTTAnq", - "8G8ngsiZP/dQrUgt411xETcW4Vkg9wrUdgEfK1L8kjCflRXNBFVD09DIfLUcLmcKvcJTq3+iSu1VVK+t", - "urq/XBIRFhKLKRWvtApd8Y6Kp5wYtBWAHoGaevYvw+ILuJEW4CJl8391TZY/nRWNV3+rveYBiDg830Nj", - "tg2aYCOTL1ML9ql60taGqkC+2SrYmGVfAtpwscquMElVEvAKhLS6J9ulvNWj8t1otiSJqr3vPdt/+qRl", - "hZbPctYZmKwv7Zqbpytccg07ddrG7/Ns/9nz57t7+893buRhcQkcDfvTlMTh7w/aINdKH9bkX//45/vT", - "mtdnH4KdBzcalEnhCA+pIY2jOqD3p//6xz/dqG49oBCjWYbibvDbN0bpJP5OukCBqguvnZNshX5/WDES", - "4ILNoA0ymRAwg47MuvXKwdTwNtpJwTjDEVWLACPHVyasvHilBindxh1UHWxI5DVtW/hRzblkPi6zOzdc", - "5+g/jW+4RgvPWhd6kvm4yQ/9ut6r8UKXXgs/xqFFiIEsaowvG7iL+VxhWYmc1n9HkODgUrmW01rMG6vh", - "bes5BxDFYuuZeaGAIVj0mjxpP/K3v7adnt+yYtapr/iHFeew+QjeyOobuJEDRt9ofQ5rjT/YC/B2X43G", - "fgm2lTXuKvXaylv35v22SNNdLkhQ3GA378/LTLzJh3XwXaBHOwa75GXb3QpJNFCTl3QSMKDxhPSKQD2b", - "kYJkbjyC+sxbPPdAqmR0ySeTKqjsfjMIOeDrQFaV6wUrpTWTLiLXzmZRR7A2YDrDzr4cdrQKMOxsp8NO", - "zW0VzFNM8fXIdlAFURmsQgUv88xrg5RuBuOER5emuhgUre6jAUoJZhLlDA5/zau2PVjtHep2Mm9vCgxu", - "YkKcltgWjGlMZnhOofSD9alMK4GY5JoqCQGj0M4BirmBVaqUVrUz1K+ZLMKDctJw6WC2sA3rBvV7nLmI", - "1vJdMPBNoKAr+0gE71pUAM2xX78+7ZoABgg9NAOrxDe6iZoRaAZZdFGrY1D+Ho4fHidkBOOu4+Kny+vo", - "J3+DZ1UQSZS0QNklOdSIAEU8Z6oOmJ+2U+Sq+VvLV1LOINjPhn8AAJrt3dblj0kEJ1Iun8Uqod+CuGt5", - "A3alQ4kDuyEShkMBvqSwr/iNdQjXB2CMDV5VZNOOH9dtvIQjqbgto1Wc6hG5jgiJ68ia4VfaxsrbL4Ox", - "8q+wBeMpChbbtyHeeXl2/bvLpIKxNq22H9PPOOsBDIjbUgvZYTD4LChMldAqGN8edsQohGMaeqFNajO5", - "Xr3Wv5JrBUDkcZ4YdLkw6VpWZS+jdSt+6xTCpgPNxfr6/ndQrs3Em9+qYJsNVX+Imm32rTup07a0O+dE", - "uXfPLRk1V+uvVFSpuLRcwL97pRpjY0ipi+wFj7bTzRoJ7s3CVhGLftsyGZLhlIwyQSb0egXxmBeMYlzF", - "DykPUpHBYIA8N1J8jfaeomiGhayNndHpTCWLagDOXgC06LOKGQqiCHOGwjY7X+6m+3A52s1up996SDg+", - "9zB4lmqHWJF0tAqg+qj0tlnrfIYXYMVpdBI+3d0bDHZ3BrdCqHbDusFyHZWf2NJ/1XaaUuq876yjvxKl", - "6rdQZDMv15O9EhSSootlkkoQnB5A4k2GI4ISMgE0uqKO93rPYr3r1YO3ApVFfino322U3Tfng6/Wpim6", - "suDebhod51ysgv34z9c4RBvYTLSEXRfIudvtDZ683d492H9ysL19F6jSxSI1ZXs8/bh99TTZwZO95Nni", - "6e/bs6fTnXQ3qIddUlOCpw2t/qLfbYyyKS/JKmhQhaWhDTuHjIh6oeB6gW1JEspITxYZUuvTFFfwAuN/", - "X3v+b2bnNzNYKTucVyfpixBYlYtToayHAbqyk1npu6jP5uR49SxulYFUH0iY3upDAfJqNxgoBbHd+UwI", - "hJy1vIbeeS+2vohWZsWtu4pCHnY46cFdbljxEHnXEBC8Wa+6wJcvuYDtdMoFVbN09W1RvFbgdUPc9Eep", - "4iqwUh+dTBlUCfd/LsLkfCVKf9zpdpKPe9UzY39vD7FloX4LArRb7UsFLcLIoAj96lWAV0rFQ5hIdq2r", - "6zH/sN3bfg5xCMnHvR8GvefViIOuWS1/+bbd25VfB23W0K+152o0bT+/UcS1W89VFPQLDVWKK+9lCwJs", - "abysyeyuDpdwW9ng8vHSHtcgcxoF0M+V9OzlNvKFppgkeBECgfcMtbKmPfpEhsZkSplsY7fdHRSG2/10", - "2OmjQ4vEDbpsWYG/0jzUXvfohKYpiamWMY3q35zBsNPSFlfXJW5WBMR9FZDW+mFx7fl6iIR1CVfrrsn+", - "Z+Tjfpb2207jXYXeAXY1p6ICWBe82EV0gjCrVQKlbI4TGttEekiMhHi1A4eIVpKs5QGylAOdnaSLplyh", - "MoW+pb0tZ812wWL85BrsrSswMwxB7HwRQJQCqYuuYl8nxygTPM6jMn80gUGXiB8ir2GhrRDy14fk3qV9", - "AxKzJ1yg9faNJoNGO/tk037XbJOaYJu3enuwfqvvxCjS7eRZvJ6HmZfacbAbQaSvSUEMmGiqy16TBL3J", - "fGjB0d/4K7is8xpbcqRFojxzDhZNU8uUFHC3gIshFNd7TBKir6nlRhBP4jJLgsqSi65nqdtPns2aXJzg", - "kVoeyC+EZFpXAfwj6C/FbBEcmKvvWdwlGwMHqy2Nw6tn6gLZ1aoO7ulaSaxxq3wTblOtAsPlazZvg5dy", - "6Zm/CzBtXzRbRkBxDL8ipL1pxtq3X7qwt0b78V2Y5R5SSHttXQ81fFSH3lvAkLv+y1hgLdZViXcv5J4P", - "kcVbqxk3Yb7Ws0B9q/Nh73+MlRmN+gdbP/zl/+59+M+gtbmmN0siejGZQKDRJVn0TJUfraP3q4inUGJA", - "C9NTSyoEp2BDAjRxexj98e4PCqax+BWnS1OACC2vRM/22gn95T+a45u8ZXwHfHItyX52BY67qFSquLuO", - "NlIipi6W3CWSbfaHDHLTLslCIq/wlxVpHKF+J4tPvAh0dGHEwD5h8ws0plBJUQ6Z1mpxFJFMaxO2lgw1", - "5cA5cB9BcOK3YwuQucRv65A08QQEvT9dgst9/e7tj6/f/Xo8en324tfDk9EvL/4bQjyueqaHuKdpb2//", - "iS0C7q/kdrAQxc3rKfTRqQ3Tt67+SQ4KLeB0SZTmKoegEHIdJbmkc+cgVMntKycsJ+vevhLBZ0LtKpWE", - "ohIsJHRCJwT8+nCd2KAaKh0xUgnV061xgzK0fGMbwhl2gJN6xe9DdSv0VoRXu9zY6qI/mbVjoQaENHDY", - "IeMVytwHtBcqAa/CxX54L6MNyBxxJV5d4uzmzUBRD4sGg5GHX7iSz+D5l6i2+W5lec05T3pavWkoSRC0", - "Jpu1CEbOQ1MmI6HT5HSYjgMyvDXtTukUB/wMIX/CF6mK6Qa0NmNqaf8by4OF8xiO6/UazLE0S1WrL1Az", - "EkjVa05zSLVUOypL/1eDZ3Jmc1epF1tXTVRNmdqy1WtDeBkxB9TwVdnK5Slz6Ig9+Gh9Eu5KvcqbmTeS", - "5r05depDTcFZsUBnemmuZkQQbyPggxIH/4ZLZvNyWqCwmOp/GRFlzKpL6tFSKbibJdooLD9uCYps42Vz", - "+Oo6B6f4uugBXClYLvkfYR5lnaXtlz8CJv0bV1uSTlwTMIyachdGYK9S0ao1cVS1vBk+VS3P27wfPHiW", - "V63gfk1nq0acZR8V0gzR498wVT9xAepgM+bJnQO5w+UfEwEYcHWY9lYY5zQl8YjnavX5t6Xr7ZVf1B8t", - "69c61RcDEUeVdN4mXuBQOcoxLK+0Xg4S5YKqxbleLxvMDWmQrmgsLCR0BD+XHUOhzk+fwGg8CSSMvCSM", - "CBpBGVR9HlPMQGNC70+9animMOISXiuIQK+PTqy5wUH+gvpIFZCei7s8PDvpdDtzIozK3Rn0d/sDOMwZ", - "YTijnYPObn+7P+iAVjWDKW5B6XqbP23zjQvF9SS2ktCP7iX9pcApUfDFbwEkAIg7tK+DCoKnnhKZYSqs", - "FpklgFBgCIbqrwHX312oB+ZW7pplb20zhTRjyH4h2Wu7uR9AUIazA9PcGQwssLmy1y/k7piEga2/2+jR", - "st9WUp1dogDM/ZKa52TLYuk/dTt7g+0bjWnVUODshjp+x7BN4iWgne/fcCFu1ekJM2l5NsnahkP5Jw4I", - "yT9rv33QeybzNMVi4RbMX62MyybBmEiE3btGj1MSRZpVQDGePnrNiHmOsELYRC6LnEENY/ehptDqKTBt", - "u00uQIp+5PHiiy1hpQ9no/hUZWf6uHxaoucvRzsFGS9vpH3kELYN1d4DAf2IiwLcD3ZS9gbP777TI84m", - "CY0U6hUEbOORqYSQnwTwwh32EBfo95wrjIpw/kd0pK3MOi7IrVteRVt/0PiTOd4JCZnBz4hIMTPJEead", - "NYd+6Tgbl0R5nFfeao7wobQH3FQOhMdcVCDIVY+of23VhcHl62gvgMBg+zTTix+Q8Pfu4YTbyRY1WB/y", - "yEHlS5RL8piOk3WxjUshJCjLvSTqa6H5wX1eWbaIwJ/wFD0WAn5JCgmv3K2lS2ErEzkzCnBQAnxTJiza", - "776rCn9vyydelAz4NXTTUM5CGb8qjhd95NbUKP1qARBLgsA84+Vr5UwP72s5YTv3ccJgxoWn6Ns19e2a", - "WnXKDbW4KcDB9E55CxvEjSwQfz77w42tD99sD+1tD60sD4xcWevC3/m4j2xEasRjguSM50mMxgQZvCMX", - "e6Kw6E8/IiyiGZ0TALWDIm15omiGBUSWpCjGChsfeqNhYqVZomhuSzfXc3GI5QLXcSwkGQEO36gJf7KM", - "QKSMkRjpTyx0XwknuFS325z9oIG9aLC8GtHVjEtS4Pkx5d3mkN4sjXYMzfaH7K0FetULCMHUjtdIkgBc", - "7Qr7D2cID5n94HvHQlwgmMRpybmwAMxAapApzbYsp7bpkY5kxENYO28Jw0z1ZEYiOqGRndYlWdh4zmCD", - "reou6QG7cb4/LRI20M5mGK8N4BnD4LzHxTNkKanqv2EQBB0leVw6uRyEEBZjnCTBwhzThI9xMjLrc0kC", - "PsGX8IZdFL++v/MmMR4TU6s9W6gZZ+bvfJwzlZu/x4JfSSKGnc3+kEEihl1rEndLARFdQSG3NOP6nAme", - "mj63zBC3/rgki0/9ITuMU8ocRcAnOJEckWv4DupbAWaG4V4N9GBOU9gPfpRLxVMf+dTRnRkmz1WWK5tR", - "IonqhlA/h0xx9IfDdvy09UfZ4ydwFhMcazrxXjFTAtm6adRyhPXsR/BqwN1OYAGGHX2RmjCPqcBMGdjO", - "ApwSTf0t3SiqI+hDullf4QgzlPHMVJYAopphTXKVNgCrAScJUnCU3LdacIedbJiPhd5Lx424ewYorXaM", - "KEOnP3qHabD3LHyeJIkECUWU/Nf5618R3Mp6D8xrZbiWSelgWmBAcQ6uU8fTXuBohoyjCooJDjs0HnYK", - "d268CWPNpQ2X6fXAp/iDHtoPppsujX/o93VTxl15gH77w7RyoM9Slhoc0GHnUxd5D6ZUzfJx8exDeEGb", - "4MvOK4wAbZhrbhM4CaaANOPd+OaKxCxG3N4CyQJhVHIgP3BlTBkWi1WJhIGltyvIJyaS0VuMP4YQuTjs", - "HAxd7OKw0x12CJvDbzbAcdj5FF4B67VsrlwH91nh3CyI6MlgsLkeCduub8Bn2cIx8IV1wEatqCi7qXfQ", - "wrD+ufwD/9b6Z+H6wUx3XkITGcXfGd8foQPCk9h9TTTggqiJ3ZhFJHFi93pDz/07D/RmRSRJ7ptAH4o8", - "C/dYgdT/qMgRNqs8RivN9w9McYP7ulQqZvuHod9HZz8PWM+t7ZzMXahzuE4JYNBYVRqZlxGW6BzG1DvX", - "yvcL+LVv/+t0P8BUvEj49OLAqO4o4VOUUGbzAbxAZS0e2LWEjwwMTfGdRaVxReI2jCTxr3/8EwZF2fRf", - "//inxXb/1z/+Ccd9y8CrQY3pixnBQo0JVhcH6BdCsh5O6Jy4yUAVWDInYoF2B9bmD4+QV+reSmlyyIbs", - "DVG5YF7ehKnXJm2D1lWg50NZTqSF8dEv0oktJmNiGwN2G3eWzVLe64nuBuAQYQbeBPSt6GgAsOSoKbRt", - "NdFO2GRq5lwxmtbDNJeC9dbzF0WulaHenhngDRkMLHHo3MEDO2m0cX7+YrOPQNsyVAEFg0B3KJuxakT/", - "G09az5MMR6kyFFhlw5sinOExTagzOTZUOzFHMMXRjDJSxhcXWOOuiQM3Us1jDs9OkA2E7MKrQ/b6fAtM", - "rIpEKhekazmBsAijZTk0bvNcoAfgX1RBdFjPvjtkE4IhT+jk2DABD4S7yAcsGmYA5AExrlRVKq91h8wg", - "yVrkYn3wUh6TBD6C/qdYkSu86KKi1q2rjpJgpRVi2dUvD5nBerVr0AOoEuQNsw/8zAyp5yJ5bc6WIJNE", - "q8YQgW/KfkPfGxMukI1w9qr8u+5MkqUZll60FEevz/X8pqAJcmMPhJZen7vd2OwiyVGUUKCGCLMhm0Ig", - "kAPv5ayyq0VC2QyLuBdxfQn4YE6XjF8lJJ428dgjn8juUJKp9BM4Tj/XyfWxCRez5QnoQ2wA6lZ77o7t", - "O+1cd7bFP5PvzhaCvIHzzlhwieE3ZnW/OfJaOPLC6+aceiHP2rFDYLy7iF/TxQMF/DraW15z88Rbsoew", - "6KENB20DXhEu0NnRCcJxLIiUm//e9j49U0Olpfyn70fNih8i9MSOhQsL+mftLVUCeSzs4I0dNcJuXvX6", - "uv79tlUpvtN40xV1eMor7+5vj1qnN7lGSqG3pLVvN8naYFsqIw5lBktq6YFolJBCfCnOqU9F66zKJoy3", - "uHJWikuWPZ8cuwN5f/Zl23XO6nfDPTDF4xpDfEBGWE219qtmPyZqflfsokObXmF+/rpIc3B/UtB9m6JD", - "ZP6Y1MW4tmyaCxqgk8YL9CVRBt7kLvV020Ng4udEuFNtBrowsy6mZT5FBqcFJgSWmNW674l5pZ3qa9r7", - "M2m+sDw3kVjskn8TUVoou+VarVJwT2wJ6LvTb6GHG6m3Xy5sxRJYYJHBijp2biewrG5guWDR5rfIlS9O", - "0SausVRihZs3iQtLtkFTKvSs+5LrDplfb1zLdFavpQxNEjqdWSdATCcQq6f8+t0wyp17GGVRJ1tgRWyI", - "4mPM+z3Ti2y9wHMiFHp9dGLW379St/6AoNX1qpJjXitv13dvXvUIi3hcOE+aZVL75AsrTIb+K7m893/q", - "HmE+K3XiQZPA+Bn7b4LJkYl/71P+v3Z+SuhYYLH4Xzs/4SSjjPyv3cMEKyLV5p0Ry+C+brr7VmAeMfFp", - "/YVWFw1YE5sCZOwagb94q6XM797/U4n9ZtI3EvyLdf0m+7eR/f3lWin+2624UwXA9PFAHq6C2EKrDY++", - "Qdrcg9HUUqQHaVPxIpWgNjMuFTx6fPnNNqicFhTnXxstrf/lgVx5fTjSPTnuwkJCRWmoaGHTB+/JF+DG", - "ce/Cre33/h0Bh+mYTnOeSz8zMcUqmhFps3YTUmXAj03sLq/nRsH7K6bSwX1eHfcuV3+j+zuS+Osbapi3", - "ceitk/ndW21lfvu+lvkNoqnNbLZlN7quJNNmQ6C1wzRtS8YV6NflAPDQuEK6CHqnFZVSXUCgQRwM2f/W", - "+sdviuD0ww8uhTIfDHaewO+EzT/84LIo2akjFcKUoLaC3uGvx+BFnUKgLBTZKxO26+MwNbuB9FxZgX87", - "Bal0JLfXkBwVftOQWmlI3nKt1pDsXtytilQtTXLvOpKjt9CCW0zxP6eW9Cd3j1Q0OJlPJjSihEGBF0hM", - "l0vxgEaT++YZuWVCMrP+SC+YqCKJtFYjC661RkIva0rfeyDZSVlE6761R1e++nEmVvHM1oO1+lopLTQr", - "bF8bPQzu9/a6f0XtMZOY0YiWly7TQnegMpCpCZXmJgut+LIEFeujt29fuYwzLfELV2dKcVdcytXdHDK/", - "uFQfvSirdpkXXAtaIiexzVCFPDxbrikmOE4oIxCiS2QoOaxaEu5Bj8WXFyrD9e5aCZX3fCxtBdOHEyof", - "jBXci/h2UikMzUsd3y+FV5wWJ8LBqXlU/MoyoADjCYlPWzhXvGdzWLdm3ACbhbEdzxIcAbSjfs2gjlnY", - "AAMz6DcFWACCJwkRBk0uy5WrgThkxeAo82q82xo3F7r5Uc4UTS66JkIGIEEkwmxhIZWGrNIZVoqkmYLU", - "XkhbhxEKkpkR14o/6kFTnkt4C7Jw/S4RTq7wQg6ZTQY2n0OhXEEiA7yYJH30MwccBoSnmDKP8ZoKhN/J", - "IbugcUJGFkbhAlGJ5IwLRRiJUcrnRFb7JVgklAiYxBHWKydRiheAZ2agHc368IwYzLAKWAPX/8YsplDL", - "TvdcTPlgyDDaGQxQSjCTNvVa4glcOLYNBIOoDOh7hNHe4Ln9qrZvgLnrln9DnyYhyJxHeJwsENFUDOAP", - "ahM2MLW1JU2NXr19Eyqk2a/CZGiLhlU2lkpXKjHuopyVyeVgPs9ZkQuut0vlgsE8rWONUFFcgxZPY0wi", - "rNeT8Wo/gGTIoygXoQtSb7VX5PTfUXD0pncOSxVO3U5AC49IDHvOuJrBmeZwlDa/b6Cqkqj+HBdN8JBw", - "gTDy6Lo0EpAoB9a4Ach/F2XFPuYq8F5sfu/Ojj6+lhG442+w9x7L/QRExCeTygFcfzWZA7wqZWKZhP+s", - "5/TIlWr1WVxM8ZRxqWjkmGG9svs3hbC1Qrh6ZYPUPOHi0petqvT7ExeXbTUwiydKH5ci5s/wK7Tt6+EB", - "dvPDm/jBwGyUFU00966k1emrOKUgdFElXewwRwlnU32KSkP3vVvifa1uw+Cw6ctUGP9xgZqjlZCR/dFU", - "e9WTsbU0wWof2VYfmhfp3u/Bv/MrV4imWUJSAtVge4bY9GaXCEtQOZ9KD2foZrxSnyo/HdjogtK49LtO", - "HAK6chu2AdL78nYFmWrCp+tx/IrOHWhdAMhvyExlf4IujDfnAhU8WAu0BjUfXc1oNANQP9BbdfsG8w9n", - "2UWBZ7x5gF7CQfZhnaHzDYOVr2lN8oQYrL55ml4cLNc7fX96Ch8ZPD9T2fTiALkap8X9IfVbPkifnkWC", - "pUK/WujBjUIZhx29UFjrm8X8Ni18X4k3PWQhKD9GrmyDdIIuPFS/iwbIKcdvX/Hpgwlj3eYqAWYuiiOr", - "OgJtEhZ3muImaBIG9NseDELg1S3BBc0w7hhbcGkwr/i0qFBQIWWcZW3J1w4TqHiepitoGG14SGVSxTxX", - "f5EqJkLAx5a6m4gbbeDIVqfCl5pQLS6dO9ibQH7B6CADGR5cKs1UO90OYXnaOfjN/muepp1ux47Hgxq/", - "gXC/BqSx3uByFIveGQ+J8ZtYfhOMxSqz90AWazeHVaebJfI35oU/vbfQ2ewekAxBPqgZcb8mEdQbb9Xg", - "w3gBFgkje34fIwNESRQlXJKKg+fx4FFZQ1dNZmw2FLk17unhxbkr4NMmKOTcfnruvvwKdO91sSJuzMhN", - "996DRpZH8Jhza+XSbCZc1EGM1kWTfPWE9OW2ZGmqbSjkG23e3MrYijC1nrDMIuwHsSnohnPFU6xoBMWE", - "ohnn0iP7AnHYlP2yxuOCMsG0YrRcG5R/oUn1wpqhL6wacWBNZgj7j2wfffjchvKHv3CPyi9+8qwCBcfv", - "OtEfAPeh2rmgZIIynEuipbo8JShaRJormupRBEczFOFM5YJAYTyCUspomqc+lLTesTkG2IuL7fSii8a5", - "QgkWU9DKzEMXbBPxNCUsJmCfG7IZwXOqVUqBEqwIixY9SaCg7pygKy4uE45jMDFkMQZPDxTkE0RTIOBy", - "p0ThGCsMgs6FPvEjkxd0UdTYNWo9I9clNcRDJnL2vSkSoJu9cAO9QARQsKmcFbUYIxwTFgXRoc+/bjb2", - "5W3R50TVJ/pAkUG34qUPGSrk21zdcL6OKKJHFt7Mhd3GNmx+hdArm1XYakKFI6N/zyNt5urm+EAOpmKJ", - "V53ir8OzVBDdV+Ndenj3ERcozk133qkEMv+z+oQKhuIHW0GyptnG2zqGiqJzxTLfiOdt/eH+PLmFLe8r", - "4YTdRsW+qbxROemvgeXaVb0Vz30gI6a1Jfk2uYdjwS6i68HEJy48LvdYjK2WYZujWfBtnzspgUH74uwb", - "266zbRvwcFu27WyzSy59j5FT1oMY0TAHt2bcRlZtTQf/ptkotdl5LPPBWWTpubg3tnhSMELDGjO8SDiO", - "/wxBwiv8RxEXwiBKAEbFY0I09ayGfnoA2ObKumldl635/vR0s4lLCLWSRwj1iDmEl5KjP0vjZQPu6zkR", - "gsYW+BMdnR7bcF0qkchZH71OqUKKo0tCsjKjBbIK+3p+DltjuUZ7BUSj2yFMiUXGKVNrR1G+ejeD+XSr", - "yu73zCctRPY3d3hrdzhY9h8fOwMuAzkbZgKrNVOF1drSnZRNuEiNXIbHPNetax6kl0nvpymBOaEJkQup", - "SGqiEid5AscNyi3Ykrr2O7PLXYjJ1SfHpMtlRKRUSsqZHDKbK5IRofvWn+v2vQCroENA4YK/nhkm+XUE", - "7+nBmHg1rJpWDVCQoFRn56CzhbNsK8YKNwSI2eF9xpB+gmg8JBfpmCc0QglllxJtJPTSqCdoLlGi/9hc", - "Gc43gu++dMHg258svdInbMKD5dgMzRbE/KfK6rJszTkmHx1be0n8w+L4D2x0mK2tL0ksCE56UOLXYeGg", - "XNGEfjSsTjdCpaKRSTnCxdq9Py2Yan/ITokS+h0MqW1JYhANQLvcygSPtob5YLAbZRQA1XYJDA4YXvPj", - "FHo8Ontn0lBJysWiO2T6H9Dw28Mz492dYGtN8AZqaxGjk63XawKcz2GZ/o0jBM0EV6IXBDf8m0vw5hgj", - "jWdINhxRnq1SlXj2pw9htRLcN7vC47QrAMhTMZuNqcARCMVylquYX7GwDWHOkzzV/zB/nKyDClM4mr2H", - "V78aadcMZ203boKP4lDaOcXElIt8EKeHWbDHGrOqF85NAYSYSjRg8BY4VH9G6v7y5nt/Hb9Cd6ddUVeK", - "9as5W/d989kxOIQNfz0eyzE3lOZmovhq69MVps3Wpx8THl1KC8Ximw213gaQ5frHEmLaughBTIDMUGQh", - "jAxQFpHdIasZIA3ij0QYKSJSynCyBXM2jQBYtrNi4TmnkKAdQZ5KT9IYMJMSQMQG+Ds9GzBUuQY8j660", - "xar8d3xnpOJoTCKeEgcgvhlS3f6GqfqJiyoa+NfCF9966w+QgJiCvX0NAHpzj58FiH6KryFUOs6tQ9mN", - "aOMlL380pqAugr0ZdnYHctjpomFnJx129A4cYTChYoX2UUpZrojso2Nj34IU3CcDJEnEWSwdjrmz4O0O", - "ZFNCriHLhuzOJ/DdfYo9lqpgKd/YTkLsQb+H9PeQtIM2/ANnz2TchUMXI54rY+6358q+FRMF5pHNe/fV", - "emfkm27fhpP/zR7fCo+CXdbs0tt6w9mzXM5Is8ntlakNlKsx4GO7ep1yhv7Ox7KLGLky1nAhVX+J7+mv", - "z0wH94Hdr7u6CW6/nfs30P4WoP3lWoXBGk2Apb6SHXUYxEZynXGhAMXR5tobGgJNApAjeIQT9ProZMgi", - "zYoMtKAgKQfuZCHGzS18+Ldz9OLoTRcdQ+1I9HM+3uyj1yxZuArexkczZEYSM8wrwgyNDdWSOHQ9m7ED", - "9dxlsLju4IGKMZuTEfCsuL1yQeLdzozgGCSSPzqvuOksgDr85pU+QAD8a74str2zUvjovCFKLHqHE0XE", - "crOnNk+KFZgZ9pJ2EHRWcDPAl7pD6ZDXyj6NbGCgMXZ3OgGkjE/f6ijcfc3R+/GSmTgRU8FunAPSKIMk", - "AxwvHlcsk5yhgjmGWKB/XReVCJqyhC0vW6lgQJdNkd9fkcl9Je+qYMv/u54umOmjdTRllX3SRFxUMFnr", - "6XXJwTMDh2wdVRHOcETVootwktg7yt4ERURKrxB/x4Lgy5hfsf6QvSlqp9iEXnR09q7rHLUopvLStGB9", - "sX30ek6EzMfF4BAcNOM1hjUn8ZApjiKcRHmixQ0ymZAIcnGhJIps8OUWQ+nc4dkpOwnWb/Gi2vNHVzYu", - "TBOweyVZ1Cluy2z1liBRgmnaDD5uBTUIOIRQg7FulDNE2SSxIVWR4FIi21SPJHRKx4kNEJJ99HZGkMQp", - "GbIswYwRgXJpouL10HuZIFLmJsFbNwAgvYaiuqgEFswEVzY0IeFcSBNNoCn8/SmSimQryOyNafkU5nxH", - "sq1p3Pb0QEbq2hiaTSH2FaQ3xFCKWXBNR3niAhjvNRTdDOihpcTHcvDfCjqdEqFPBTZM1oTjmWPtltMc", - "+krGcmMJyfPirXYlJItWvaxEL2NvJTDcqMTajjs3i/oLdH5JG7ED7aObZRH/oj9q2Xc1WzU8CPvoM2f5", - "Z6nMf+4lCbY1YJUU/tjMSd7IK0e1kmi7HlardWbtXWa6tsbPejDYrMeMloUr6bNNCu/XRwiD+0V5uO8i", - "a4+btipoVxXdtCHlfz2a/ldBgXcDo//AKCe3gNH/qvLuAef84fBPggf1ofLoK75nV7/2T4+Ef1fp8wYO", - "H+DYmtLnDdezwasrFaX39p12apJt8c8kwdt4xxvI727Zv2n9LVQGb7HWuaA1wZM0UwsX0GZ9lWXQmaQf", - "Sb/BEVzErd6dK/gWIZ1fjjwcnTYGdP45y80/SMyoLR1IJTo5DtRxf2QYg/6Zq1wsW/rW6WERzeicNBvd", - "qyfYLlEmSC/jGThXYrNgdj3cXaaw6E8/Itu8xVy1/4LakwDVT2IUU0EilSxMHVDNEUwf30kkuNYE4DkX", - "i+YoEXNEfhI8PbSzWXMf2jNljWFlnGG66MVY4d7ccZsVJrTPiO508ZSa4SHK0Msf0Qa5VsJUuEATrfkg", - "OimW1BTwl0CTm/6AtwcNlk36kYym4zajXFGr5LWtBYOiXCqeur0/OUYbUPtsSpjeCy3qT0CSzQSf05jE", - "lTF25jwxq7rdsKA3tbtqoaIoXOeUCzO4B5Fh2lxI0480q7KFIiRmTBmGwa2tClI9UyaJX/eHKXMBOHaP", - "3Ci+XWFW89twyo6mRKjDaRdRcW4gnje/XXOP+Zrzk6HcnVa57Vx4zmrjdbv8qJZpS3dR+KHInbtfs/X7", - "ryelh8pHmc1jTefzQiFtMpt/XSQ4uL/74b7N5e8fcQroS+KUb89UDg3oFkME8wpiumMyJwnPUqiHDu92", - "up1cJJ2Dzkyp7GBrC2K/Z1yqg73nT3c7nz58+v8DAAD//xqpZv3s7gEA", + "P5GFRCkFJ2jRPYQ+SlQkpcRDtiFcAlORqQQVOGP9A6QDdF3YuR4aVVDqQ38wZHKWK83ENvvoiDOZp0RY", + "qywaU/AYbSKZG5UWxgursdAMU9KYiCHTrwWgT/8o1JODZ4PBYNDtFJrcrv73IERN9+r87FtoX5OCC+B7", + "zIL8AqqfyBnKWUxEUY6bGHKoh8jd0nH6iZi+7vN24pX9vJSrwgdzHQRwO2zfTwVUhaE26OcQHXoH5Xz/", + "7iJ6qxQgJ7/a5B/71eg2EQwERTxPYq3xjfVtZwxyJLZmSEmU4c7mXSrRO1MKszp1G3qsOPo9J2KB3p+e", + "VsIeBJloHtBu4sAlGvaBZ7fahp01NpK1o7mNe9mDn30IyNm6pOJJiJ8dYNb3MLqcZEOhFcNWRXFeZV/T", + "WmUwi4Qys0+aaFZMsGbKj8l8lOchrUQ/crAk796dHFcoBeNn2y8GL172Xoy3n/X24sF2D2/vPuvt7OPB", + "ZDd6vru9s7si46RFFtndE8OCqmkgYLgIDx+5MPVQ9HBTkkBNCLCBz9eUxfy6cs8EI1H93m2U67rul2PY", + "Ww8hmPmSYKmMnaCBZZzCbUoi3baJ/LaZikWJobBF8dnFYPtTzSwwuAZmfCFyZvyXJre+sNWn3oD9zaqO", + "8268FQbkMkzWrZbfeftFGxzsvzzY/9RFc1kS68ZYJ6cH3Nym0CuHPVxLw3CpgJ7BxlkCO1b4MOZzm7XR", + "6XaKxBL4G27dWtBy8bhVtlTTge2G2cgq/t2QNHxSUQwgJMRgz8UHWiQo8k3HuUJFarqWNY4SnsfIM3oZ", + "KC5weJ14SoJuBvxP1hZmoDVN1oNWJgDLGSomUKYZMTj6dCM2w/gAvYZ34RFOjf5kB2Hqdvg+LhwvTGCK", + "Pl+ua6PNrB7yuVVk4But1SD9L5i2XgZrG13dhBGDDtAvHL4p1CrG60ZW8zroM8uv1w2yGzb11yFGQGdW", + "pjtAPxRyXCEJWslvQxL758gyrBKoZbOSLm93vKOppdw5L/W72zEr2ul23EJBivhysvi7kuqXzp9PiqGI", + "LYITOMtlMm6uaGLhqWEmVCoaSZuloTe3Sb6wJYVIPDJaSlPwp8nwtJpM8ZETX96fog1AIPwLshZk/a/N", + "IlC0ctftvNx7+ez5zstnrXCGygGul0GPIP94eXBrBdIoy0fWCNE09aOzd8bIEBn1vQgyeX/qwzpkgmvW", + "o2fuGvQ7f9l/6cMrxTwfJ54Hz2KxGTRX2LAggljBixoCDn+nyZxOJuz3D9HVzt8FTbdvnsmd8XYDbKzp", + "KGzfOvG9+EvGYDLumfJAYQQcICghG0Gi3hIJM0DnRCGgnx7CEegRRdqwJTkHJWVXPEhYe7u7uy+e7++0", + "ois7Ou/gjMDaFbiU7Qi8IwZvoo235+doyyM406bDUgBkb2Z1zPA5Q7a276AqkPa3B7shKmm4uEuqsW3P", + "08Ylf2/1NDspu+iQ/VzocEunPLjau7uD53v7L/bbHWNrhx2Jm9UcxuUGmeWxAPT+zm+ANHlxeIYg83aC", + "o6oRxYVi3WpU6lajguIJBvT8FgN78fzZ/t7uznY7tLNQdIfF8asc2CrvChy6AFEEdiOwFMust9t0W4TE", + "KUNgb0mUYJoeRi6XoXb7GHDzkTCvlZvQ5mKwpv+li6vFt62sSIVtyGTCGNGAC5SzoqRGf73v87O4MJu5", + "trke1nP1UP4L06tnYXlM6bA7LGUmyJzyXH6GhrgyyamThHNxq2+bFJa3ROaJMn5GKtH702+Ap2haQ1KR", + "rKpDWWpcAV50x8nd6jxXSCRM5E2L1Wo32mz9qgl3G05tdxVyRYUbNEKGxZpz5Wx9lOURTqIcisjgYj/1", + "rADtB3LvsyxZmCD6JOGcoWiGGXgjhC1ZxaYIoxlP4n4w5FQ/GU2C4Qv8GiXcgB1fEZLZ+ipmEPozLcLQ", + "OUEbfmUxQ0q1ep/7qWEytoJGlRr303DhQixDWWFFzrleT6y4h8NrPqmYHBM+laAUKkgP6Nfh3zMsTNQ/", + "ZqZe0Dw1umQgsjkwxBozD92o5iblE6vgWpEDMrrNSuJIcCkRSegUatO8P60lCq9ILivShddHTlYH24J0", + "jecwcJXBFSdblxUL3Y+BxJlPuSGBhiE5b0VMojNOppjlUHHFI2Rr8e63jjuccalGBQDULQcr1QjKKOSC", + "lChxRXp7YQ9y7wTvRcfa7rJcNsD3Tl8vUVW4qaYBNvPU4IqGV6tb0GCIjJchsFaibpUwXnXMptuAtJW4", + "+1RCq9TDB0MbkFzisSUPO36zTTRKWGXV/Sxpq7Yq5s97g/O2+Gmr4dLOsJqdsAkPgGzcwkXpLNE2LDQj", + "IqVQSATFhFESO12y8FVaUxdkZieSoDgnduWMfCqwXXBsjjcAZTBnI6NsWuP19Q7bmIfNGFZXWYB+7Ytt", + "4opkOHP1QuSwViYwUCJc5rC2irakchR2Zy03LMg0T7BAdYzAFUOWizSh7KpN63KRjnlCI6Q/qDugJzxJ", + "+PVIP5LfwVw2W81OfzBqKslzbgZnE/DMhtT6LafwnZ7lZi39FywxW+b7LYBmaROmFQzJ/oEmxMLovWP0", + "xiP0Kgz53s6gKS29odFKQvoyBONtObcl2eCJz2UgiW+llOOKCZHYYsMbsSfLpalr0uJWcuCjzgV4N49O", + "NUPj0zBAjgy/riGAoDGBBBs3tWWu0YIttplKsKRCLmfo73xcNYi2ja8NFOraYCUWhSCTYCA97OhKg7R5", + "Y2lNvN29DdgDsFU9UfjolhgK60qalYFMTfzk7VJ1rxmxS0bdHE2lrxaVK1ygRYETYHttDxhQr8MWCAwG", + "OBipFlDRFMrILLzighKNuRCAvKwlHM7cbADfRMs8eq0dwBS6mJEFEiTFlA0ZZYWRFFDLCGJkToSXjsqF", + "VrKmJO6jv3kqHmBVp5laWBB0MJ5/IxG/ZsUYh8wfpG48l7qdQ2YsiyKHIvnlS7pZ0Po0oUB6MDjBlKAO", + "/3YiiJz5cw/VitQy3jUXcWMRngVyr0BtF/CxIsWvCPNZWdFMUDU0DY3MV8vhcqbQKzy1+ieq1F5F9dqq", + "q/vLJRFhIbGYUvFKq9AV76h4yolBWwHoEaipZ/8yLL6AG2kBLlI2/1fXZPnTWdF49bfaax6AiMPzPTRm", + "26AJNjL5MrVgn6onbW2oCuSbrYKNWfYloA0Xq+wKk1QlAa9ASKt7sl3KWz0q341mS5Ko2vvei/3nz1pW", + "aPkkZ52Byfrcrrl5usIl17BTp238Pi/2X7x8ubu3/3LnVh4Wl8DRsD9NSRz+/qANcqP0YU3+9Y9/vj+t", + "eX32Idh5cKtBmRSO8JAa0jiqA3p/+q9//NON6s4DCjGaZSjuBr99Y5RO4u+kCxSouvDaOclW6PeHFSMB", + "LtgM2iCTCQEz6MisW68cTA1vo50UjDMcUbUIMHJ8bcLKi1dqkNJt3EHVwYZEXtO2hR/VnEvm4zK7c8N1", + "jv7T+IZrtPCidaEnmY+b/NBv6r0aL3TptfBjHFqEGMiixviygbuYzzWWlchp/XcECQ4ulWs5rcW8sRre", + "tp5zAFEstp6ZFwoYgkWvyZP2I3/7a9vp+S0rZp36iv+24hw2H8FbWX0DN3LA6Butz2Gt8Qd7Ad7tq9HY", + "L8G2ssZdpV5beevevt8WabrLBQmKG+z2/XmZibf5sA6+C/Rox2CXvGy7WyGJBmrykk4CBjSekF4RqGcz", + "UpDMjUdQn3mL5x5IlYyu+GRSBZXdbwYhB3wdyKpyvWCltGbSReTG2SzqCNYGTGfY2ZfDjlYBhp3tdNip", + "ua2CeYopvhnZDqogKoNVqOBlnnltkNLNYJzw6MpUF4Oi1X00QCnBTKKcweGvedW2B6u9Q91O5u1NgcFN", + "TIjTEtuCMY3JDM8plH6wPpVpJRCT3FAlIWAU2jlAMTewSpXSqnaG+jWTRXhQThouHcwWtmHdoH6PMxfR", + "Wr4LBr4JFHRlH4jgXYsKoDn2mzenXRPAAKGHZmCV+EY3UTMCzSCLLmp1DMrfw/HD44SMYNx1XPx0eR39", + "5G/wrAoiiZIWKLskhxoRoIjnTNUB89N2ilw1f2v5SsoZBPvZ8A8AQLO927r8MYngRMrls1gl9DsQdy1v", + "wK50KHFgN0TCcCjAlxT2Fb+1DuH6AIyxwauKbNrx47qNl3AkFbdltIpTPSI3ESFxHVkz/ErbWHn7ZTBW", + "/mdswXiKgsX2bYh3Xp5d//4yqWCsTavtx/QzznoAA+K21EJ2GAw+CwpTJbQKxreHHTEK4ZiGXmiT2kxu", + "Vq/1L+RGARB5nCcGXS5MupZV2cto3YrfOYWw6UBzsb6+/z2UazPx5ncq2GZD1R+jZpt9617qtC3tzjlR", + "7t1zS0bN1forFVUqLi0X8O9eqcbYGFLqInvBo+10s0aCe7OwVcSi37ZMhmQ4JaNMkAm9WUE85gWjGFfx", + "Q8qDVGQwGCDPjRTfoL3nKJphIWtjZ3Q6U8miGoCzFwAt+qRihoIowpyhsM3Ol7vpPlyOdrPb6bceEo7P", + "PQyepdohViQdrQKoPiq9bdY6n+EFWHEanYTPd/cGg92dwZ0Qqt2wbrFcR+UntvRftZ2mlDrvO+vor0Sp", + "+i0U2czL9WSvBYWk6GKZpBIEpweQeJPhiKCETACNrqjjvd6zWO969eCtQGWRXwr6dxtl98354Ku1aYqu", + "LLi3m0bHORerYD/+8zUO0QY2Ey1h1wVy7nZ7g2cX27sH+88OtrfvA1W6WKSmbI/nH7avnyc7eLKXvFg8", + "/3179ny6k+4G9bArakrwtKHVn/S7jVE25SVZBQ2qsDS0YeeQEVEvFFwvsC1JQhnpySJDan2a4gpeYPzv", + "a8//7ez8ZgYrZYfz6iR9EQKrcnEqlPU4QFd2Mit9F/XZnByvnsWdMpDqAwnTW30oQF7tBgOlILY7nwiB", + "kLOW19A778XWF9HKrLh1V1HIww4nPbjLDSseIu8aAoI361UX+PIlF7CdTrmgapauvi2K1wq8boib/iBV", + "XAVW6qOTKYMq4f7PRZicr0TpjzvdTvJhr3pm7O/tIbYs1G9BgHarfamgRRgZFKFfvQrwSql4CBPJrnV1", + "PebvtnvbLyEOIfmw992g97IacdA1q+Uv37Z7u/LroM0a+rX2XI2m7Ze3irh267mKgn6ioUpx5b1sQYAt", + "jZc1md3V4RJuKxtcPl7a4xpkTqMA+qmSnr3cRr7QFJMEL0Ig8J6hVta0R5/I0JhMKZNt7La7g8Jwu58O", + "O310aJG4QZctK/BXmofa6x6d0DQlMdUyplH9mzMYdlra4uq6xO2KgLivAtJaPyyuvVwPkbAu4WrdNdn/", + "hHzcT9J+22m8q9A7wK7mVFQA64IXu4hOEGa1SqCUzXFCY5tID4mREK924BDRSpK1PECWcqCzk3TRlCtU", + "ptC3tLflrNkuWIyf3IC9dQVmhiGInc8CiFIgddFV7OvkGGWCx3lU5o8mMOgS8UPkNSy0FUL++pDc+7Rv", + "QGL2hAu03r7RZNBoZ59s2u+abVITbPNWbw/Wb/W9GEW6nTyL1/Mw81I7DnYriPQ1KYgBE0112WuSoDeZ", + "31pw9Lf+Ci7rvMaWHGmRKM+cg0XT1DIlBdwt4GIIxfUek4Toa2q5EcSTuMySoLLkoutZ6vazF7MmFyd4", + "pJYH8hMhmdZVAP8I+ksxWwQH5up7FnfJxsDBakvj8OqZukB2taqDe75WEmvcKt+E21SrwHD5ms3b4KVc", + "eebvAkzbF82WEVAcw68IaW+bsfbtly7srdF+fB9muccU0t5Y10MNH9Wh9xYw5K7/MhZYi3VV4t0LuedD", + "ZHGBp1CUbo0fpZFUQgUcwcmBkcK2ihRUstT/RFCuUzrAFicgwOcuo9dW9UNUITzF9RINTfUfjSd6fQxR", + "c0WVC2siaAK/rafD+ub3w97/GHM7GvUPtr77y//d++0/g2b3mgFBEtGLyQQirq7IomfKHSk8rV6hf5ha", + "C1qrmNozQ3AKxjSAVbdcyR/v/qDgnotfcLo0BQhV82oVba+d0F/+oznQy1vGd3BhrD27n1yK5D5Ktiru", + "7uWNlIipC6p3GXWb/SGDJL0rspDIq4BmZTt3Yr+RxSdeKD66NOTeJ2x+icYUSkrKIdPqPY4ikmm1yhbV", + "oaYuOgc2LAhO/HZsJTZ3Xqxn1gRWEPT+dAk3+M27i+/fvPvlePTm7NUvhyejn179N8S6XPdMD3FP097e", + "/jNbDd1fye1gRY7bF5boo1Obr2BjHiY5aPYAWCZRmqscomPITZTkks6dp1Qldy8hsZy1fPeSDJ+IOaxU", + "EgrPsNjYCZ0QCHCAe9VGF1HpiJFKKCNvrTyUoWXRxRDOsANXiuJOkggW8NBbEV7tcmOri/5s1u4uMWis", + "gcMOqb9Q7z+gxlEJwB0uCMZ7GW1ACo2rdesyiDdvhw57WDQYDMH8zCWNBi8/R9nRdyvrjM550tN6XkNt", + "hqBZ3axFMIUAmjKpGZ0m78t0HFBmrI17Sqc44HAJOVY+S3lQN6C1qWNL+99YJy2c0HFcL1xhjqVZqlqh", + "hZq1RKpec75HqsX7ESTaBqKIcmaTeKkXZFjN2E2Z2rJlfEPAITEH+PRVadvlKXMwkT34aH028koF05uZ", + "N5LmvTl1elRN01uxQGd6aa5nRBBvI+CDsiDALZfMJii1gKMxZRAzIsrgXZfdpMVz8LtLtFGYwNwSFGnX", + "y36B1QUfTvFN0QP4lLBccsTCPMqCU9uvvwdw/reuyCaduCZgGDUtNwxFX6WiVWviqGp5M3yqWp63eT94", + "8CyvWsH9ms5WjTjLPiqkGaLHv2GqfuAC9OJm8Jd7R7SHyz8mAsDw6nj1rcDeaUriEc/V6vNva/jbK78o", + "xFoW8nU2AAxEHFXympt4gYMnKcewvNJ6OUiUC6oW53q9bFQ75IO66rmwkNAR/Fx2DBVLP34E6/kkkDnz", + "mjAiaAT1YPV5TDEDjQm9P/XKApoKkUvAtSACvTk6sXYXh30MejRVQHouAPXw7KTT7cyJMLaHzqC/2x/A", + "Yc4IwxntHHR2+9v9QQe0qhlMcQtq+NtEcqtRFxr8SWwloe/dS/pLgVOi4ItfA5AIEIBpXwcVBE89JTLD", + "VFgtMksAqsEQDNVfQ4EDd6EemFu5a5a9tfEY8q0hDYhkb+zm/gaCMpwdmObOYGAR3pW9fiGJyWRObP3d", + "htGW/baS6uwSBfD+l9Q8J1sWS/+x29kbbN9qTKuGAmc31PE7hm02MwHtfP+WC3GnTk+YyU+02eY2Lsw/", + "cUBI/ln79Te9ZzJPUywWbsH81cq4bBKMiUTYvWv0OCVRpFkFVCXqozeMmOcIK4RNCLfIGRRzdh9qCq2e", + "AtO22+QCrel7Hi8+2xJW+nA2io9VdqaPy8clev58tFOQ8fJG2kcOatxQ7QMQ0Pe4qET+aCdlb/Dy/js9", + "4myS0EihXkHANjCbSoh9SgA43YEwcYF+z7nCqMhreEJH2sqs44LcuuVVtPUHjT+a452QkD/gjIgUM5Ml", + "Yt5Zc+iXjrPxzZTHeeWt5ggfapzATeXQiMxFBYJc9Yj611ZdGFy+jvYCUBS2TzO9+BEJf+8BTridbFGM", + "9jGPHJQARbkkT+k4WV/juBRCgrLca6K+FJofPOSVZasp/AlP0VMh4NekkPDK3Vq6FLYykTOjAAclwLdl", + "5qb97puq8HdRPvHChcCvoZuGuh7KOJhxvOgjt6ZG6VcLwJoSBOYZL18rZ3p4X8oJ23mIEwYzLjxFX6+p", + "r9fUqlNuqMVNAQ6md8pb2CBuZYH489kfbm19+Gp7aG97aGV5YOTaWhf+zsd9ZENzIx4TJGc8T2I0JsgA", + "P7kgHIVFf/oBYRHN6JwAuh9Uq8sTRTMsIMQmRTFW2PjQGw0TK80SRXNburmeC8gsF7gO6CHJCEJgRk1A", + "nGUoJmWMxEh/YqNmSlzFpQLm5uwHDexFg+XViK5nXJIC2JAp7zaHPG9ptGNotj9kFxbxVi8gRJU7XiNJ", + "Ari9K+w/nCE8ZPaDbx0LcRFxEqcl58ICwBOpgeg027Kc46dHOpIRD4EOXRCGmerJjER0QiM7rSuysIGt", + "wQZbFaDSA3bjfH9aZK6gnc0wcB2ELoVRio+LZ8hSUtV/wyAaPEryuHRyOSwlLMY4SYIVSqYJH+NkZNbn", + "igR8gq/hDbsopcOl9CYxHhNTtD5bqBln5u98nDOVm7/Hgl9LIoadzf6QQUaKXWsSd0sBEV1DRbs04/qc", + "CZ6aPrfMELf+uCKLj/0hO4xTyhxFwCc4kRyRG/gO4sYAPMRwrwZ6MKcp7Ac/yqXiqQ8B6+jODJPnKsuV", + "Ta2RRHVD8KdDpjj6w4Fcftz6o+zxIziLCY41nXivmCmBbN00ajnCevYjeDXgbiewAMOOvkhNmMdUYKYM", + "fmmB0omm/pZuFGUi9CHdrK9whBnKeGZKbABRzbAmuUobAFqBkwQpOEruWy24w042zMdiEKbjRgBCgxhX", + "O0aUodPvvcM02HsRPk+SRIKEIkr+6/zNLwhuZb0H5rUyXMvktjAtMKA4B9ep42mvcDRDxlEFVRWHHRoP", + "O4U7N96EsebShsv0euBT/E4P7TvTTZfG3/X7uinjrjxAv/5hWjnQZylLDSDqsPOxi7wHU6pm+bh49lt4", + "QZtw3M4rjABtmGtuEzgJpgC549345orELEbc3gLJAmFUciA/cGVMGRaLVRmVgaW3K8gnJpLRW4w/hhC5", + "OOwcDF3s4rDTHXYIm8NvNsBx2PkYXgHrtWwu4Qf3WeHcLIjo2WCwuR4S3K5vwGfZwjHwmXXARq2oqD+q", + "d9Di0f65/AP/1vpn4frBTHdeYjQZxd8Z35+gA8KT2H1NNOCCqIndmEUkcWL3ekPPwzsP9GZFJEkemkAf", + "izwL91hRsuBJkSNsVnmMVprvH5niBg91qVTM9o9Dv0/Ofh6wnlvbOZm7UOdwwRYA47GqNDIvIyzROYyp", + "d66V71fwa9/+1+l+AC55mfDp5YFR3VHCpyihzOYDeIHKWjywawkfGTye4jsLz+Oq5W0YSeJf//gnDIqy", + "6b/+8U8Lcv+vf/wTjvuWwZmDYtuXM4KFGhOsLg/QT4RkPZzQOXGTgXK4ZE7EAu0OrM0fHiGv5r+V0uSQ", + "DdlbonLBvLwJU7hO2gatq0DPh7KcSItnBGlCE1tVx8Q2Buw27iybpXzQE90N4ELCDLwJ6FvR0QCA6lFT", + "cdxqop2wydTMuWI0rYdpLgXrrecvitwoQ709M8BbMhhY4tC5gwd20mjj/PzVZh+BtmWoAionge5QNmPV", + "iP5XnrSeJxmOUmUosMqGN0U4w2OaUGdybCj7Yo5giqMZZaSMLy5A110TB26kmsccnp0gGwjZhVeH7M35", + "FphYFYlULkjXcgJhoVbLunDc5rlAD8C/qILosJ59d8gmBEOe0MmxYQIeGnmRGFk0zADRBGJcqaqUoOsO", + "mYHUtRDO+uClPCYJfAT9T7Ei13jRRUXRX1cmJsFKK8Syq18eMpNqaNegB5gtyBtmH/iZGVLPRfLanC1B", + "JolWjSEC39Q/h743JlwgG+HcLfNKXXcm29QMSy9aiqM353p+U9AEubEHQktvzt1ubHaR5ChKKFBDhNmQ", + "TSEQyKEYc1bZ1SKhbIZF3Iu4vgR8VKsrxq8TEk+beOyRT2T3KMlU+gkcpx/r5PrUhIvZ8gT0ITZIfas9", + "d8f2nXauO9vin8l3Zyti3sJ5Zyy4xPAbs7pfHXktHHnhdXNOvZBn7dhBUd5fxK/p4pECfh3tLa+5eeIt", + "2WNY9NCGw/gBrwgX6OzoBOE4FkTKzX9ve5+eqaHSUv7T96NmxY8RemLHwoVFP7T2liqBPBV28NaOGmE3", + "r3qhYf9+26pUIWq86YqCROWVd/+3R63T21wjpdBb0trXm2RtsC2VEYd6iyW19EA0SkghvhTn1KeidVZl", + "E8ZbXDkrxSXLnk+O3YF8OPuy7Tpn9bvhAZjicY0hPiIjrKZa++XDnxI1vyt20cFurzA/f1mkOXg4Keih", + "TdEhMn9K6mJcWzbNBQ3QSeMF+pooA29yn3q67SEw8XMi3Kk2A12YWRfTMp8ig9MCEwJLzGrd98S80k71", + "Ne39mTRfWJ7bSCx2yb+KKC2U3XKtVim4J7YW9v3ptxUktwcOW7EEFlhksKKOndsJLKsbWC5YtPk1cuWz", + "U7SJayyVWOHmTeLCkm3QlAo966HkukPmF17XMp3VaylDk4ROZ9YJENMJxOopv5A5jHLnAUZZFAwXWBEb", + "ovgU837P9CJbL/CcCA+p0b9St/6AoNX1qpJjXitv13dvf+4RFvG4cJ40y6T2yWdWmAz9V3J5H/7UPcF8", + "VurEgyaB8RP23wSTowK683/t/GDBO//Xzg84ySgj/2v3MMGKSLV5b8QyeKib7qEVmCdMfFp/odVFW2JN", + "WwpP/TzUKmU6+NrbUGYFdtYZu4tEms9GfZ9fyKtj9baS8B6M7rUm9dCIJSeuLIHZUi4celUNkvjBDuN5", + "hbYe3HRW6Z5KGAHIWE8wYJgLB4CKLYI0dXRm2QSbAsT2GrtA8VZL04B7/09lHTCTvpV9oFjXryaCNiYC", + "f7lWWgnsVtyrncD08UiO8ILYQqsNj74iXz2Ab8VSpId8VXE2l9hXMy4VPHp6MAj2KqEFxfnXRksnYXkg", + "V14fjnRPjruwkFCBHyoA2SzjB3IZunE8uA5s+314oecwHdNpznPpJzCnWEUzIm1yf0KqDPipaefl9dyo", + "n3/BVDp4yKvjwdXvr3R/T4aB+oYa5m38/utkfvdWW5nfvq9lfgN8bAEQbJmiritht9mQj+Ggj9uScQUh", + "ejlPJDSukC6C3mlFpVQXEGgQB0P2v7X+8asiOP3tO5dpnQ8GO8/gd8Lmv33nkq3ZqSMVwpSgtuLo4S/H", + "EGwxhXh6KEpa4jrUx4HSXCpDeq76yL+dglTGm7TXkBwVftWQWmlI3nKt1pDsXtyvilStYPTgOpKjt9CC", + "29IDf04t6U/uRa1ocDKfTGhECYM6UGD8k0thw0aT++pAvSNuAbNhC17MYUUSaa1GFlxrjYRe1uB/8HjT", + "k7Lo4ENrj67c/9PMv+SZrZ9t9bVSWmhW2L40ehg87O318IraUyYxoxEtL12mhe5AATHjOUlzk6xafFli", + "D/bRxcXPLjFVS/zClaNT3NWgc3WKh8yvQddHr8rifuYF14KWyElsE9khXddWdYsJjhPKCETyExnKIa1W", + "jnzUY/H5hcpwWcyHduC2OZa24vPjCZWPxgoeRHw7qRTS56WO71fMLE6LE+Hg1DwpfmUZUIDxhMSnLZwr", + "3rOp7lszbvAPwxCwZwmOAAFWv2bACS26iEEj9ZsCyBDBk4QIAzqZ5cqVSh2yYnCUKV6UVbalsC5186Oc", + "KZpcdk0gHSAHSYTZwiKvDVmlM6wUSTMFCACAbgEjFCQzI67ViNWDpjyX8BYk6/tdIpxc44UcMosZYD6H", + "wuKCRAafNUn66EcOcC2mVrLHeE2h0m/kkF3SOCEji7ZyiahEcsaFIozEKOVzIqv9EiwSSgRM4gjrlZMo", + "xQuAPTQIsGZ9eEYMtGAF04Xrf2MWUyh5qXsupnwwZBjtDAYoJZhJi9Ag8QQuHNsGgkFUBvQtwmhv8NJ+", + "Vds3gOZ2y7+hT5MQZM4jPE4WiGgqNqWkN2EDU1uC1tQ019s3oUKa/SpMhra2YGVjqXQVVeMuylmJQQHm", + "85wVkBF6u1QuGMzTOtYIFcU1aGF3xiTCej0Zr/YDgKc8inIRuiD1Vnu1kP8dBUdveuewVGGEhwS08IjE", + "sOeMqxmcaQ5HafPbBqoqierPcdEEDwkXCCOPrksjAYlyYI0bABB6WRb2ZK5Q9+Xmt+7s6ONrGYE7/gai", + "86ncT0BEfDKpHMD1V5M5wKsyq5ZJ+M96To9cRWefxcUUTxmXikaOGTr8ZV9p/qoQtlIIV69skJonXFw1", + "h9P+wMVVWw3MxRo+LUXMn+EXaNvXwwOI98c38YOB2SgrmmgeXEmr01dxSkHookoWUbYo4WyqT1Fp6H5w", + "S7yv1W0YuEZ9mQrjPy7AtbQSMrI/mqLQXtAyWO0j2+pj8yLd+wP4d37hCtE0S0hKoGh0zxCb3uwSiG28", + "sEBxBRzZ7XilPlU+aoDRBaVx6XedOAR05TZsA6T35e0KMtWET9fDfRadO2zLAN7nkL2TBoj/0nhzLlHB", + "g7VAa4proOsZjWaA/Ql6q27fQIPiLLssYM83D9BrOMg++jt0vmFKamhakzwhBtJznqaXB8tlkd+fnsJH", + "BvbTFEC+PECuFHJxf0j9lo/lqWeRYKnQLxahdKNQxmFHLxXW+mYxv02L8lnC0g9ZCPGTkWvbIJ2gSw/8", + "87IBmc7x25/59NGEsW5zMREzF8WRVR2BNgmLO01xEzQJ435uDwYhjPuWGKRmGPcMQbo0mJ/5tChkUiFl", + "nGVtydcOE6h4nqYraBhteICGUsU8V3+RKiZCwMeWupuIG23gyBaxw1eaUC18pTvYm0B+weggU1kguFSa", + "qXa6HcLytHPwq/3XPE073Y4dj1eR4BbC/Ros13qDy1Esemc8wNavYvltoFirzN7DYq3dHFadbpbI35oX", + "/vTeQmeze0QyBPmgZsT9kkRQb7xVgw/jBaYsjOzlQ4wMgGdRlHBJKg6epwNbZw1dNZmx2VDk1rinhxfn", + "rs5Xm6CQc/vpufvyC9C918WKuDEjN90HDxpZHsFTTsGXS7OZcFHHOlsXTfLFE9Ln25KlqbahkK+0eXsr", + "YyvC1HrCMouwH8Sm7iPOFU+xohHUHItmnEuP7AtgclMd0BqPC8oE04rRcm1Q/qUm1Utrhr60asSBNZkh", + "7D+yffThcxvKH/7CPSq/+MGzChQcv+tEf6jLIRFGY0HJBGU4l0RLdXlKULSINFc0ReYIjmYowpnKBYH6", + "mQSllNE0T33Eeb1jcwzoOJfb6WUXjXOFEiymoJWZhy7YJuJpSlhMwD43ZDOC51SrlAIlWBEWLXqSQN3t", + "OUHXXFwlHMdgYshiDJ4eqNspiKZAgO9PicIxVhgEnUt94kcmL+iyKMVt1HpGbkpqiIdM5OxbU0tEN3vp", + "BnqJCIDlUzkrSrZGOCYsCoLIn3/ZbOzz26LPiapP9JEig+7ESx8zVMi3ubrhfBlRRE8WZaEFm18h9Mpm", + "FbaaUOHI6N/zSJu5ujk+koOpWOJVp/jL8CwVRPfFeJce333EBYpz0513KoHM/6w+oYKh+MFWkKxptvGu", + "jqGiNmWxzLfieVt/uD9P7mDL+0I4YbdRsW+qglZO+ktguXZV78RzH8mIaW1Jvk3u8Viwi+h6NPGJC4/L", + "PRVjawV3rODbPndSAoP2xdlXtl1n2zbg4a5s29lml1z6HiOnrAcxomEObs24jazamg7+TbNRarPzWOaj", + "s8jSc/HgIIKONWZ4kXAc/xmChFf4jyIuhEGUAIyKpwR87FkN/fQAsM2V5RW7Llvz/enpZhOXEGoljxDq", + "CXMILyVHf5bGywbcN3MiBI0douTR6bEN16USiZz10ZuUKqQ4uiIkKzNaIKuwr+fnsDVqw66DaHQ7hCmx", + "yDhlau0oylfvZzDlD9xCjHyBoqRF0v/qDm/tDgfL/tNjZ8BlIGfDTGC1ZqqwWlvhl7IJF6mRy/CY57p1", + "zYP0Mun9NJVyJzQhciEVSU1U4iRP4LhBVRZbedt+Z3a5CzG5+uSYdLmMiJRKSTmTQ2ZzRTIidN/6c92+", + "F2AVdAgoXPDXM8Mkv4zgPT0YE6+GVdOqAQoSVPTtHHS2cJZtxVjhhgAxO7xPGNIPEI2H5CId84RGKKHs", + "SqKNhF4Z9QTNJUr0H5srw/lG8N3nrit+95OlV/qETXiwaqOh2YKY/1RZXZatOcfkk2Nrr4l/WBz/gY0O", + "s7X1lcsFwUkPKoE7LByUK5rQD4bV6UaoVDQyKUe4WLv3pwVT7Q/ZKVFCv4MhtS1JDKIBaJdbmeDR1jAf", + "DHajjAKg2i6BwQHDa36cQo9HZ+9MGipJuVh0h0z/Axq+ODwz3t0JttYEb6C2ZDk62XqzJsD5HJbp3zhC", + "0ExwJXpBcMO/ugRvjzHSeIZkwxHl2SpViWd/+hBWK8F9tSs8TbsCgDwVs9mYChyBUCxnuYr5NQvbEOY8", + "yVP9D/PHyTqoMIWj2Xt49YuRds1w1nbjJvgkDqWdU0xMVdlHcXqYBXuqMat64dwUQIipRAMGb4FD9Wek", + "7s9vvvfX8Qt0d9oVdRWbv5iz9dA3nx2DQ9jw1+OpHHNDaW4miq+2Pl1j2mx9+j7h0ZW0UCy+2VDrbQBZ", + "rn8sIaatixDEBMgMRRbCyABlEdkdspoB0iD+SISRIiKlDCdbMGfTCIBlOysWnnMKCdoR5Kn0JI0BMykB", + "RGyAv9OzAUOVa8Dz6Epb085/x3dGKo7GJOIpcQDimyHV7W+Yqh+4qKKBfyl88cJbf4AExBTs7WsA0Jt7", + "/CRA9FN8A6HScW4dym5EG695+aMxBXUR7M2wszuQw04XDTs76bCjd+AIgwkVK7SPUspyRWQfHRv7FqTg", + "PhsgSSLOYulwzJ0Fb3cgmxJyDVk2ZHc+g+8eUuyxVAVL+dZ2EmIP+j2kv4ekHbThHzh7JuMuHLoY8VwZ", + "c789V/atmCgwj2w+uK/WOyNfdfs2nPxv9vhWeBTssmaX3tYbzp7lckaaTW4/m9pAuRoDPrYr6ytn6O98", + "LLuIkWtjDRdS9Zf4nv76zHTwENj9uqvb4PbbuX8F7W8B2l+uVRis0QRY6ivZUYdBbCQ3GRcKUBxtrr2h", + "IdAkADkCKv69OToZskizIgMtKEjKgTtZiHFzCx/+7Ry9OnrbRcdQYhb9mI83++gNSxau0L/x0QyZkcQM", + "84owQ2NDtSQOXc9m7EA99xksrjt4pJrt5mQEPCtur1yQeLczIzgGieSPzs/cdBZAHX77sz5AAPxrviy2", + "vbNS+Oi8JUoseocTRcRys6c2T4oVmBn2knYQdFZwM8CXukPpkNfKPo1sYKAxdnc6AaSMj1/rKNx/aeKH", + "8ZKZOBFTwW6cqydbChUOYsEcQyzQv66LSgRNWcKWl61UMKDLpsjvL8jkvpJ3VbDl/11PF8z0yTqasso+", + "aSIuKpis9fS65OCZgUO2jqoIZziiatFFOEnsHWVvgiIipVeIv2NB8FXMr1l/yN4WtVNsQi86OnvXdY5a", + "FFN5ZVqwvtg+ejMnQubjYnAIDprxGsOak3jIFEcRTqI80eIGmUxIBLm4UBJFNvhyi6F07vHslJ0E67d4", + "Ue35kysbF6YJ2L2SLOoUt2W2ekuQKME0bQYft4IaBBxCqMFYN8oZomyS2JCqSHApkW2qRxI6pePEBgjJ", + "PrqYESRxSoYsSzBjRKBcmqh4PfReJoiUuUnw1g0ASK+hqC4qgQUzwZUNTUg4F9JEE2gKf3+KpCLZCjJ7", + "a1o+hTnfk2xrGrc9PZKRujaGZlOIfQXpDTGUYhZc01GeuADGBw1FNwN6bCnxqRz8C0GnUyL0qcCGyZpw", + "PHOs3XKaQ1/JWG4sIXlevNWuhGTRqpeV6GXsrQSGG5VY23HndlF/gc6vaCN2oH10uyzin/RHLfuuZquG", + "B2EffeIs/yyV+c+9JMG2BqySwp+aOckbeeWoVhJt18Nqtc6svc9M19b4WY8Gm/WU0bJwJX22SeH98ghh", + "8LAoDw9dZO1p01YF7aqimzak/K9H0/8iKPB+YPQfGeXkDjD6X1TePeCcPx7+SfCgPlYefcX37OrX/umR", + "8O8rfd7A4QMcW1P6vOF6Nnh1paL03r7TTk2yLf6ZJHgb73gL+d0t+1etv4XK4C3WOhe0JniSZmrhAtqs", + "r7IMOpP0A+k3OIKLuNX7cwXfIaTz85GHo9PGgM4/Z7n5R4kZtaUDqUQnx4E67k8MY9A/c5WLZUvfOj0s", + "ohmdk2aje/UE2yXKBOllPAPnSmwWzK6Hu8sUFv3pB2Sbt5ir9l9QexKg+kmMYipIpJKFqQOqOYLp4xuJ", + "BNeaADznYtEcJWKOyA+Cp4d2NmvuQ3umrDGsjDNMF70YK9ybO26zwoT2CdGdLp5SMzxEGXr9PdogN0qY", + "ChdoojUfRCfFkpoC/hJoctMf8PagwbJJP5DRdNxmlCtqlbyxtWBQlEvFU7f3J8doA2qfTQnTe6FF/QlI", + "spngcxqTuDLGzpwnZlW3Gxb0tnZXLVQUheuccmEG9ygyTJsLafqBZlW2UITEjCnDMLi1VUGqZ8ok8ev+", + "MGUuAMfukRvF1yvMan4bTtnRlAh1OO0iKs4NxPPm12vuKV9zfjKUu9Mqt50Lz1ltvG6XH9Uybek+Cj8U", + "uXMPa7Z+/+Wk9FD5JLN5rOl8XiikTWbzL4sEBw93Pzy0ufz9E04BfU2c8u2ZyqEB3WKIYH6GmO6YzEnC", + "sxTqocO7nW4nF0nnoDNTKjvY2oLY7xmX6mDv5fPdzsffPv7/AQAA///BBhRZHPQBAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 4773437a0..9d078c1fb 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -229,10 +229,11 @@ var RouteScopes = map[string]Scope{ "POST /resources/memory/reclaim": ResourceWrite, // Images - "GET /images": ImageRead, - "POST /images": ImageWrite, - "DELETE /images/{name}": ImageDelete, - "GET /images/{name}": ImageRead, + "GET /images": ImageRead, + "POST /images": ImageWrite, + "POST /images/{name}/tag": ImageWrite, + "DELETE /images/{name}": ImageDelete, + "GET /images/{name}": ImageRead, // Pushes (outbound image pushes to remote registries) "POST /pushes": ImageWrite, diff --git a/openapi.yaml b/openapi.yaml index d6016524d..e7cef8a6c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1290,6 +1290,15 @@ components: tags: $ref: "#/components/schemas/Tags" + TagImageRequest: + type: object + required: [target] + properties: + target: + type: string + description: Target OCI image reference with a tag. The local tag points to the source image without pulling it again. + example: docker.io/library/nginx:stable + Image: type: object required: [name, digest, status, created_at] @@ -2640,6 +2649,57 @@ paths: schema: $ref: "#/components/schemas/Error" + /images/{name}/tag: + post: + summary: Create or update a local image tag + operationId: tagImage + security: + - bearerAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + description: URL-encoded source image name or digest + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TagImageRequest" + responses: + 200: + description: Image tag created + content: + application/json: + schema: + $ref: "#/components/schemas/Image" + 400: + description: Invalid source or target image reference + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 404: + description: Source image not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 409: + description: Source image is not ready + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + 500: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /images/{name}: get: summary: Get image details diff --git a/stainless.yaml b/stainless.yaml index 0d95928b2..dbd40fbc9 100644 --- a/stainless.yaml +++ b/stainless.yaml @@ -97,11 +97,13 @@ resources: # [reference]: https://www.stainless.com/docs/reference/config#model models: image: "#/components/schemas/Image" + tag_image_request: "#/components/schemas/TagImageRequest" methods: list: get /images create: post /images get: get /images/{name} delete: delete /images/{name} + tag: post /images/{name}/tag instances: models: From 9dee14681fc5b3c596d8cddc8121230aea4aba2f Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:18:37 +0000 Subject: [PATCH 02/11] Add route-level URL decoding test and builds mock TagImage Ported from the earlier tag draft: verify the generated tag route decodes URL-encoded source names, and keep the builds test mock satisfying the extended Manager interface. --- cmd/api/main_test.go | 44 ++++++++++++++++++++++++++++++++++++++ lib/builds/manager_test.go | 13 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/cmd/api/main_test.go b/cmd/api/main_test.go index 34dbba428..ef88b586b 100644 --- a/cmd/api/main_test.go +++ b/cmd/api/main_test.go @@ -252,6 +252,50 @@ func (h *testImageHandler) GetImage(w http.ResponseWriter, r *http.Request, name } } +func TestTagImageRoute_URLDecodedName(t *testing.T) { + // Verifies the generated POST /images/{name}/tag route exists and that the + // URL-encoded source name is decoded before reaching the handler. + r := chi.NewRouter() + + var receivedName string + handler := &testTagImageHandler{ + tagImage: func(w http.ResponseWriter, r *http.Request, name string) { + receivedName = name + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"name":"` + name + `","digest":"sha256:test","status":"ready","created_at":"2026-01-01T00:00:00Z"}`)) + }, + } + + oapi.HandlerFromMux(handler, r) + + token, err := generateValidJWT("user-123") + require.NoError(t, err) + + body := bytes.NewReader([]byte(`{"target":"docker.io/library/alpine:stable"}`)) + req := httptest.NewRequest(http.MethodPost, "/images/docker.io%2Flibrary%2Falpine%3Alatest/tag", body) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + assert.Equal(t, "docker.io/library/alpine:latest", receivedName) +} + +// testTagImageHandler implements oapi.ServerInterface with just TagImage for testing +type testTagImageHandler struct { + oapi.Unimplemented + tagImage func(w http.ResponseWriter, r *http.Request, name string) +} + +func (h *testTagImageHandler) TagImage(w http.ResponseWriter, r *http.Request, name string) { + if h.tagImage != nil { + h.tagImage(w, r, name) + } +} + func TestImageNameWithSlashes_URLEncoding(t *testing.T) { // This test verifies how chi router handles image names with slashes. // Image names like "docker.io/onkernel/chromium-headful:latest" contain slashes diff --git a/lib/builds/manager_test.go b/lib/builds/manager_test.go index 7be58b0fc..a947090a2 100644 --- a/lib/builds/manager_test.go +++ b/lib/builds/manager_test.go @@ -369,6 +369,19 @@ func (m *mockImageManager) DeleteImage(ctx context.Context, name string) error { return nil } +func (m *mockImageManager) TagImage(ctx context.Context, source, target string) (*images.Image, error) { + m.mu.Lock() + defer m.mu.Unlock() + img, ok := m.images[source] + if !ok { + return nil, images.ErrNotFound + } + tagged := *img + tagged.Name = target + m.images[target] = &tagged + return &tagged, nil +} + func (m *mockImageManager) RecoverInterruptedBuilds() {} func (m *mockImageManager) TotalImageBytes(ctx context.Context) (int64, error) { From e1333afdbd6c9557d399176689446b4fd36c6c84 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:52:52 +0000 Subject: [PATCH 03/11] Simplify local tag creation --- lib/images/manager.go | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/lib/images/manager.go b/lib/images/manager.go index 7310cc0d9..d4ab85276 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -824,22 +824,14 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, m.createMu.Lock() defer m.createMu.Unlock() - digestHex := sourceRef.DigestHex() - if !sourceRef.IsDigest() { - digestHex, err = resolveTag(m.paths, sourceRef.Repository(), sourceRef.Tag()) - if err != nil { - return nil, err - } - } - - meta, err := readMetadata(m.paths, sourceRef.Repository(), digestHex) + digestHex, err := m.resolveTagSource(sourceRef) if err != nil { return nil, err } - if meta.Status != StatusReady { - return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + meta, err := m.readyImageMetadata(sourceRef.Repository(), digestHex) + if err != nil { + return nil, err } - if sourceRef.Repository() != targetRef.Repository() { if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { return nil, fmt.Errorf("create image alias: %w", err) @@ -854,6 +846,24 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, return img, nil } +func (m *manager) resolveTagSource(ref *NormalizedRef) (string, error) { + if ref.IsDigest() { + return ref.DigestHex(), nil + } + return resolveTag(m.paths, ref.Repository(), ref.Tag()) +} + +func (m *manager) readyImageMetadata(repository, digestHex string) (*imageMetadata, error) { + meta, err := readMetadata(m.paths, repository, digestHex) + if err != nil { + return nil, err + } + if meta.Status != StatusReady { + return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + return meta, nil +} + func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) From 925840fd86ab0c09ebf0bae97fbbd763321898fa Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:26:12 +0000 Subject: [PATCH 04/11] Track tag API mutations --- lib/images/manager.go | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/images/manager.go b/lib/images/manager.go index d4ab85276..e15b19dc3 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -823,6 +823,7 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, m.createMu.Lock() defer m.createMu.Unlock() + m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) digestHex, err := m.resolveTagSource(sourceRef) if err != nil { From cc2efae23d9b2e82f2701b4b79e52c76eb80166d Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:15:35 +0000 Subject: [PATCH 05/11] Simplify local image tagging --- cmd/api/api/images.go | 40 +++++++++-------------- lib/images/manager.go | 74 ++++++++++++++++++++++++------------------ lib/images/tag_test.go | 7 ++-- 3 files changed, 60 insertions(+), 61 deletions(-) diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index e9f6defc8..60df40052 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -104,35 +104,25 @@ func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestO img, err := s.ImageManager.TagImage(ctx, request.Name, request.Body.Target) if err != nil { - log := logger.FromContext(ctx) - switch { - case errors.Is(err, images.ErrInvalidName): - return oapi.TagImage400JSONResponse{ - Code: "invalid_name", - Message: err.Error(), - }, nil - case errors.Is(err, images.ErrNotFound): - return oapi.TagImage404JSONResponse{ - Code: "not_found", - Message: "source image not found", - }, nil - case errors.Is(err, images.ErrImageNotReady): - return oapi.TagImage409JSONResponse{ - Code: "image_not_ready", - Message: err.Error(), - }, nil - default: - log.ErrorContext(ctx, "failed to tag image", "error", err, "source", request.Name, "target", request.Body.Target) - return oapi.TagImage500JSONResponse{ - Code: "internal_error", - Message: "failed to tag image", - }, nil - } + return tagImageErrorResponse(ctx, err, request.Name, request.Body.Target), nil } - return oapi.TagImage200JSONResponse(imageToOAPI(*img)), nil } +func tagImageErrorResponse(ctx context.Context, err error, source, target string) oapi.TagImageResponseObject { + switch { + case errors.Is(err, images.ErrInvalidName): + return oapi.TagImage400JSONResponse{Code: "invalid_name", Message: err.Error()} + case errors.Is(err, images.ErrNotFound): + return oapi.TagImage404JSONResponse{Code: "not_found", Message: "source image not found"} + case errors.Is(err, images.ErrImageNotReady): + return oapi.TagImage409JSONResponse{Code: "image_not_ready", Message: err.Error()} + default: + logger.FromContext(ctx).ErrorContext(ctx, "failed to tag image", "error", err, "source", source, "target", target) + return oapi.TagImage500JSONResponse{Code: "internal_error", Message: "failed to tag image"} + } +} + // GetImage gets image details by name // Note: Resolution is handled by ResolveResource middleware func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestObject) (oapi.GetImageResponseObject, error) { diff --git a/lib/images/manager.go b/lib/images/manager.go index e15b19dc3..9187116bd 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -802,42 +802,25 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } -// TagImage creates or updates a local tag pointing at an existing ready -// image. The source may be a tag or a digest; the target must carry a tag. -// No pull or conversion happens: the target becomes another reference to the -// same shared content. Source and target may live in different repositories; -// a cross-repository target first promotes the digest into shared content so -// both references resolve to one rootfs copy. +// TagImage creates a ready-image tag without pulling or converting content. +// Cross-repository tags promote legacy content into the shared layout. func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { - sourceRef, err := ParseNormalizedRef(source) - if err != nil { - return nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) - } - targetRef, err := ParseNormalizedRef(target) + sourceRef, targetRef, err := parseTagReferences(source, target) if err != nil { - return nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) - } - if targetRef.IsDigest() { - return nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + return nil, err } m.createMu.Lock() defer m.createMu.Unlock() m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) - digestHex, err := m.resolveTagSource(sourceRef) + digestHex, meta, err := m.readyTagImage(sourceRef) if err != nil { return nil, err } - meta, err := m.readyImageMetadata(sourceRef.Repository(), digestHex) - if err != nil { + if err := m.promoteTagContent(sourceRef, targetRef, digestHex, meta); err != nil { return nil, err } - if sourceRef.Repository() != targetRef.Repository() { - if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { - return nil, fmt.Errorf("create image alias: %w", err) - } - } if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { return nil, fmt.Errorf("create image tag: %w", err) } @@ -847,22 +830,49 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, return img, nil } -func (m *manager) resolveTagSource(ref *NormalizedRef) (string, error) { - if ref.IsDigest() { - return ref.DigestHex(), nil +func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, error) { + sourceRef, err := ParseNormalizedRef(source) + if err != nil { + return nil, nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) + } + targetRef, err := ParseNormalizedRef(target) + if err != nil { + return nil, nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) + } + if targetRef.IsDigest() { + return nil, nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) } - return resolveTag(m.paths, ref.Repository(), ref.Tag()) + return sourceRef, targetRef, nil } -func (m *manager) readyImageMetadata(repository, digestHex string) (*imageMetadata, error) { - meta, err := readMetadata(m.paths, repository, digestHex) +func (m *manager) readyTagImage(ref *NormalizedRef) (string, *imageMetadata, error) { + digestHex := ref.DigestHex() + if !ref.IsDigest() { + var err error + digestHex, err = resolveTag(m.paths, ref.Repository(), ref.Tag()) + if err != nil { + return "", nil, err + } + } + + meta, err := readMetadata(m.paths, ref.Repository(), digestHex) if err != nil { - return nil, err + return "", nil, err } if meta.Status != StatusReady { - return nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + return "", nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) } - return meta, nil + return digestHex, meta, nil +} + +func (m *manager) promoteTagContent(source, target *NormalizedRef, digestHex string, meta *imageMetadata) error { + if source.Repository() == target.Repository() { + return nil + } + if err := promoteImageToContent(m.paths, source.Repository(), digestHex, meta); err != nil { + return fmt.Errorf("create image alias: %w", err) + } + return nil } func (m *manager) DeleteImage(ctx context.Context, name string) error { diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index e44e32259..341b21ef3 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -27,7 +27,7 @@ func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digest } func newTagTestManager(p *paths.Paths) *manager { - return &manager{paths: p} + return &manager{paths: p, tagGenerations: make(map[string]uint64)} } func TestTagImageSameRepository(t *testing.T) { @@ -96,12 +96,11 @@ func TestTagImageCrossRepository(t *testing.T) { require.NoError(t, err) require.Equal(t, targetRepo+":v1", img.Name) - // Promotion moved the digest into shared content and retired the legacy tree. + // Promotion adds shared content while retaining the legacy tree for readers. data, err := os.ReadFile(p.ImageContentPath(digest)) require.NoError(t, err) require.Equal(t, "rootfs!", string(data)) - _, err = os.Stat(legacyDir) - require.True(t, os.IsNotExist(err), "legacy tree should be retired after promotion") + require.DirExists(t, legacyDir) // Both repositories resolve to the same digest. for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { From 4e09e59dce7069ce25767988160796784b7c3c41 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:27:13 +0000 Subject: [PATCH 06/11] Cover cross-repository tag rejection --- lib/images/tag_test.go | 147 +++++++++++++++++------------------------ 1 file changed, 60 insertions(+), 87 deletions(-) diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index 341b21ef3..9c603c7c4 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -10,84 +10,82 @@ import ( "github.com/stretchr/testify/require" ) -// seedReadyContentImage writes a ready image directly into the shared content -// layout and tags it, without going through a pull. func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { t.Helper() require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0o755)) require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digestHex), &imageMetadata{ - Name: repository + ":" + tag, - Digest: "sha256:" + digestHex, - Status: StatusReady, - SizeBytes: 7, - CreatedAt: time.Now().UTC(), + Name: repository + ":" + tag, Digest: "sha256:" + digestHex, + Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), })) require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs!"), 0o644)) require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) } -func newTagTestManager(p *paths.Paths) *manager { - return &manager{paths: p, tagGenerations: make(map[string]uint64)} -} - -func TestTagImageSameRepository(t *testing.T) { +func newTagTestCase(t *testing.T) (*paths.Paths, *manager, string) { + t.Helper() p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" - digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" - seedReadyContentImage(t, p, repository, "latest", digest) + return p, &manager{paths: p, tagGenerations: make(map[string]uint64)}, "docker.io/library/alpine" +} - img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") +func tagAndResolve(t *testing.T, m *manager, p *paths.Paths, source, target, digest string) { + t.Helper() + img, err := m.TagImage(context.Background(), source, target) require.NoError(t, err) - require.Equal(t, repository+":stable", img.Name) + require.Equal(t, target, img.Name) require.Equal(t, "sha256:"+digest, img.Digest) - - resolved, err := resolveTag(p, repository, "stable") + resolved, err := resolveTag(p, "docker.io/library/alpine", "stable") require.NoError(t, err) require.Equal(t, digest, resolved) +} - // Both tags keep the shared content alive. - require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) - _, err = os.Stat(p.ImageContentDir(digest)) - require.NoError(t, err, "content must survive while another tag references it") - - require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) - _, err = os.Stat(p.ImageContentDir(digest)) - require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") +func TestTagImageAliasesReadyImage(t *testing.T) { + cases := []struct { + name, source, target, digest string + }{ + {"same repository", "docker.io/library/alpine:latest", "docker.io/library/alpine:stable", "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, + {"digest source", "docker.io/library/alpine@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "docker.io/library/alpine:pinned", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p, m, repository := newTagTestCase(t) + seedReadyContentImage(t, p, repository, "latest", tc.digest) + img, err := m.TagImage(context.Background(), tc.source, tc.target) + require.NoError(t, err) + require.Equal(t, tc.target, img.Name) + require.Equal(t, "sha256:"+tc.digest, img.Digest) + resolved, err := resolveTag(p, repository, "stable") + if tc.name == "digest source" { + resolved, err = resolveTag(p, repository, "pinned") + } + require.NoError(t, err) + require.Equal(t, tc.digest, resolved) + }) + } } -func TestTagImageFromDigestSource(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" - digest := "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" +func TestTagImageSameRepositoryDeletesContent(t *testing.T) { + p, m, repository := newTagTestCase(t) + digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" seedReadyContentImage(t, p, repository, "latest", digest) + tagAndResolve(t, m, p, repository+":latest", repository+":stable", digest) - img, err := m.TagImage(context.Background(), repository+"@sha256:"+digest, repository+":pinned") - require.NoError(t, err) - require.Equal(t, repository+":pinned", img.Name) - - resolved, err := resolveTag(p, repository, "pinned") + require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) + _, err := os.Stat(p.ImageContentDir(digest)) require.NoError(t, err) - require.Equal(t, digest, resolved) + require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) + _, err = os.Stat(p.ImageContentDir(digest)) + require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") } func TestTagImageCrossRepository(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - sourceRepo := "docker.io/library/alpine" + p, m, sourceRepo := newTagTestCase(t) targetRepo := "registry.example/apps/alpine" digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - - // Ready image still living in the legacy per-repository layout. legacyDir := p.ImageDigestDir(sourceRepo, digest) require.NoError(t, os.MkdirAll(legacyDir, 0o755)) require.NoError(t, writeMetadataFile(p.ImageMetadata(sourceRepo, digest), &imageMetadata{ - Name: sourceRepo + ":latest", - Digest: "sha256:" + digest, - Status: StatusReady, - SizeBytes: 7, - CreatedAt: time.Now().UTC(), + Name: sourceRepo + ":latest", Digest: "sha256:" + digest, + Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), })) require.NoError(t, os.WriteFile(p.ImageDigestPath(sourceRepo, digest), []byte("rootfs!"), 0o644)) require.NoError(t, createTagSymlink(p, sourceRepo, "latest", digest)) @@ -95,93 +93,68 @@ func TestTagImageCrossRepository(t *testing.T) { img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") require.NoError(t, err) require.Equal(t, targetRepo+":v1", img.Name) - - // Promotion adds shared content while retaining the legacy tree for readers. data, err := os.ReadFile(p.ImageContentPath(digest)) require.NoError(t, err) require.Equal(t, "rootfs!", string(data)) require.DirExists(t, legacyDir) - // Both repositories resolve to the same digest. for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { resolved, err := resolveTag(p, repo, tag) require.NoError(t, err) require.Equal(t, digest, resolved) } - - // Deleting the source tag leaves the cross-repository alias intact. require.NoError(t, m.DeleteImage(context.Background(), sourceRepo+":latest")) _, err = os.Stat(p.ImageContentDir(digest)) - require.NoError(t, err, "cross-repository tag must keep content alive") - + require.NoError(t, err) require.NoError(t, m.DeleteImage(context.Background(), targetRepo+":v1")) _, err = os.Stat(p.ImageContentDir(digest)) require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") } func TestTagImageRejectsNotReady(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" - digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" - + p, m, repository := newTagTestCase(t) + digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" require.NoError(t, os.MkdirAll(p.ImageContentDir(digest), 0o755)) require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ - Name: repository + ":latest", - Digest: "sha256:" + digest, - Status: StatusConverting, - CreatedAt: time.Now().UTC(), + Name: repository + ":latest", Digest: "sha256:" + digest, + Status: StatusConverting, CreatedAt: time.Now().UTC(), })) require.NoError(t, createTagSymlink(p, repository, "latest", digest)) - _, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + targetRepository := "registry.example/apps/alpine" + _, err := m.TagImage(context.Background(), repository+":latest", targetRepository+":stable") require.ErrorIs(t, err, ErrImageNotReady) - - _, err = resolveTag(p, repository, "stable") - require.ErrorIs(t, err, ErrNotFound, "failed tag must not create a reference") + _, err = resolveTag(p, targetRepository, "stable") + require.ErrorIs(t, err, ErrNotFound) } func TestTagImageSourceNotFound(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" - + _, m, repository := newTagTestCase(t) _, err := m.TagImage(context.Background(), repository+":missing", repository+":stable") require.ErrorIs(t, err, ErrNotFound) } func TestTagImageRejectsDigestTarget(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" + p, m, repository := newTagTestCase(t) digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" seedReadyContentImage(t, p, repository, "latest", digest) - _, err := m.TagImage(context.Background(), repository+":latest", repository+"@sha256:"+digest) require.ErrorIs(t, err, ErrInvalidName) } func TestTagImageReplacesExistingTag(t *testing.T) { - p := paths.New(t.TempDir()) - m := newTagTestManager(p) - repository := "docker.io/library/alpine" - first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + p, m, repository := newTagTestCase(t) + first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" seedReadyContentImage(t, p, repository, "latest", first) seedReadyContentImage(t, p, repository, "stable", second) - // "stable" moves from the second digest to the first. img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") require.NoError(t, err) require.Equal(t, repository+":stable", img.Name) - resolved, err := resolveTag(p, repository, "stable") require.NoError(t, err) require.Equal(t, first, resolved) - - // The orphaned second digest is cleaned up by delete semantics, not by tag; - // it is still referenced by no tag after the repoint only if it had no other - // tag. Here "stable" was its only tag, so it remains on disk until deleted. _, err = os.Stat(p.ImageContentDir(second)) require.NoError(t, err) } From 47b6953b9ab7c01813ce705825d6d5d86580ab04 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:21:06 +0000 Subject: [PATCH 07/11] Make failed tag calls side-effect free and extract tag code Move the target tag generation bump to after the tag symlink is created so a failed TagImage no longer invalidates pending pulls' claims on the target tag. Use toImageFor for the tag response and GetImage. Extract the tag feature from manager.go into tag.go and share reference resolution between GetImage and readyTagImage via resolveRefMetadata. Fix the digest-source test's dead assertion and add no-side-effect and legacy-layout coverage. --- lib/images/manager.go | 91 +---------------------------------------- lib/images/storage.go | 18 +++++++++ lib/images/tag.go | 63 +++++++++++++++++++++++++++++ lib/images/tag_test.go | 92 +++++++++++++++++++++++++++--------------- 4 files changed, 142 insertions(+), 122 deletions(-) create mode 100644 lib/images/tag.go diff --git a/lib/images/manager.go b/lib/images/manager.go index 9187116bd..06e6cbbe0 100644 --- a/lib/images/manager.go +++ b/lib/images/manager.go @@ -772,23 +772,7 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return nil, fmt.Errorf("%w: %s", ErrInvalidName, err.Error()) } - repository := ref.Repository() - - var digestHex string - if ref.IsDigest() { - // Direct digest lookup - digestHex = ref.DigestHex() - } else { - // Tag lookup - resolve symlink - tag := ref.Tag() - d, err := resolveTag(m.paths, repository, tag) - if err != nil { - return nil, err - } - digestHex = d - } - - meta, err := readMetadata(m.paths, repository, digestHex) + _, meta, err := resolveRefMetadata(m.paths, ref) if err != nil { return nil, err } @@ -802,79 +786,6 @@ func (m *manager) GetImage(ctx context.Context, name string) (*Image, error) { return img, nil } -// TagImage creates a ready-image tag without pulling or converting content. -// Cross-repository tags promote legacy content into the shared layout. -func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { - sourceRef, targetRef, err := parseTagReferences(source, target) - if err != nil { - return nil, err - } - - m.createMu.Lock() - defer m.createMu.Unlock() - m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) - - digestHex, meta, err := m.readyTagImage(sourceRef) - if err != nil { - return nil, err - } - if err := m.promoteTagContent(sourceRef, targetRef, digestHex, meta); err != nil { - return nil, err - } - if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { - return nil, fmt.Errorf("create image tag: %w", err) - } - - img := meta.toImage() - img.Name = targetRef.String() - return img, nil -} - -func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, error) { - sourceRef, err := ParseNormalizedRef(source) - if err != nil { - return nil, nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) - } - targetRef, err := ParseNormalizedRef(target) - if err != nil { - return nil, nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) - } - if targetRef.IsDigest() { - return nil, nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) - } - return sourceRef, targetRef, nil -} - -func (m *manager) readyTagImage(ref *NormalizedRef) (string, *imageMetadata, error) { - digestHex := ref.DigestHex() - if !ref.IsDigest() { - var err error - digestHex, err = resolveTag(m.paths, ref.Repository(), ref.Tag()) - if err != nil { - return "", nil, err - } - } - - meta, err := readMetadata(m.paths, ref.Repository(), digestHex) - if err != nil { - return "", nil, err - } - if meta.Status != StatusReady { - return "", nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) - } - return digestHex, meta, nil -} - -func (m *manager) promoteTagContent(source, target *NormalizedRef, digestHex string, meta *imageMetadata) error { - if source.Repository() == target.Repository() { - return nil - } - if err := promoteImageToContent(m.paths, source.Repository(), digestHex, meta); err != nil { - return fmt.Errorf("create image alias: %w", err) - } - return nil -} - func (m *manager) DeleteImage(ctx context.Context, name string) error { // Parse and normalize the reference ref, err := ParseNormalizedRef(name) diff --git a/lib/images/storage.go b/lib/images/storage.go index 2e3430851..d97e54101 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -240,6 +240,24 @@ func readMetadata(p *paths.Paths, repository, digestHex string) (*imageMetadata, return readMetadataAt(resolveImageLayout(p, repository, digestHex)) } +// resolveRefMetadata resolves a digest reference directly or a tag reference +// through its symlink, then reads the metadata it points at. +func resolveRefMetadata(p *paths.Paths, ref *NormalizedRef) (string, *imageMetadata, error) { + digestHex := ref.DigestHex() + if !ref.IsDigest() { + var err error + digestHex, err = resolveTag(p, ref.Repository(), ref.Tag()) + if err != nil { + return "", nil, err + } + } + meta, err := readMetadata(p, ref.Repository(), digestHex) + if err != nil { + return "", nil, err + } + return digestHex, meta, nil +} + func readContentMetadata(p *paths.Paths, digestHex string) (*imageMetadata, error) { return readMetadataAt(contentLayout(p, digestHex)) } diff --git a/lib/images/tag.go b/lib/images/tag.go new file mode 100644 index 000000000..f97bf5e0c --- /dev/null +++ b/lib/images/tag.go @@ -0,0 +1,63 @@ +package images + +import ( + "context" + "fmt" +) + +// TagImage creates a ready-image tag without pulling or converting content. +// Cross-repository tags promote legacy content into the shared layout. A +// failed call leaves no side effects: the target tag's generation is only +// bumped after the new tag is on disk, so pending pulls that claimed the +// target tag keep their claim. +func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { + sourceRef, targetRef, err := parseTagReferences(source, target) + if err != nil { + return nil, err + } + + m.createMu.Lock() + defer m.createMu.Unlock() + + digestHex, meta, err := m.readyTagImage(sourceRef) + if err != nil { + return nil, err + } + if sourceRef.Repository() != targetRef.Repository() { + if err := promoteImageToContent(m.paths, sourceRef.Repository(), digestHex, meta); err != nil { + return nil, fmt.Errorf("promote image to content: %w", err) + } + } + if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { + return nil, fmt.Errorf("create image tag: %w", err) + } + m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) + + return meta.toImageFor(targetRef.String()), nil +} + +func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, error) { + sourceRef, err := ParseNormalizedRef(source) + if err != nil { + return nil, nil, fmt.Errorf("%w: invalid source reference: %s", ErrInvalidName, err) + } + targetRef, err := ParseNormalizedRef(target) + if err != nil { + return nil, nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) + } + if targetRef.IsDigest() { + return nil, nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + } + return sourceRef, targetRef, nil +} + +func (m *manager) readyTagImage(ref *NormalizedRef) (string, *imageMetadata, error) { + digestHex, meta, err := resolveRefMetadata(m.paths, ref) + if err != nil { + return "", nil, err + } + if meta.Status != StatusReady { + return "", nil, fmt.Errorf("%w: %s", ErrImageNotReady, meta.Status) + } + return digestHex, meta, nil +} diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index 9c603c7c4..24989f538 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -21,19 +21,26 @@ func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digest require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) } +func seedLegacyImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { + t.Helper() + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digestHex), 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digestHex), &imageMetadata{ + Name: repository + ":" + tag, Digest: "sha256:" + digestHex, + Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), + })) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digestHex), []byte("rootfs!"), 0o644)) + require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) +} + func newTagTestCase(t *testing.T) (*paths.Paths, *manager, string) { t.Helper() p := paths.New(t.TempDir()) return p, &manager{paths: p, tagGenerations: make(map[string]uint64)}, "docker.io/library/alpine" } -func tagAndResolve(t *testing.T, m *manager, p *paths.Paths, source, target, digest string) { +func requireTagResolvesTo(t *testing.T, p *paths.Paths, repository, tag, digest string) { t.Helper() - img, err := m.TagImage(context.Background(), source, target) - require.NoError(t, err) - require.Equal(t, target, img.Name) - require.Equal(t, "sha256:"+digest, img.Digest) - resolved, err := resolveTag(p, "docker.io/library/alpine", "stable") + resolved, err := resolveTag(p, repository, tag) require.NoError(t, err) require.Equal(t, digest, resolved) } @@ -42,7 +49,7 @@ func TestTagImageAliasesReadyImage(t *testing.T) { cases := []struct { name, source, target, digest string }{ - {"same repository", "docker.io/library/alpine:latest", "docker.io/library/alpine:stable", "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, + {"same repository", "docker.io/library/alpine:latest", "docker.io/library/alpine:stable", "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1"}, {"digest source", "docker.io/library/alpine@sha256:b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2", "docker.io/library/alpine:pinned", "b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2"}, } for _, tc := range cases { @@ -53,42 +60,48 @@ func TestTagImageAliasesReadyImage(t *testing.T) { require.NoError(t, err) require.Equal(t, tc.target, img.Name) require.Equal(t, "sha256:"+tc.digest, img.Digest) - resolved, err := resolveTag(p, repository, "stable") - if tc.name == "digest source" { - resolved, err = resolveTag(p, repository, "pinned") - } + targetRef, err := ParseNormalizedRef(tc.target) require.NoError(t, err) - require.Equal(t, tc.digest, resolved) + requireTagResolvesTo(t, p, targetRef.Repository(), targetRef.Tag(), tc.digest) }) } } func TestTagImageSameRepositoryDeletesContent(t *testing.T) { p, m, repository := newTagTestCase(t) - digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" seedReadyContentImage(t, p, repository, "latest", digest) - tagAndResolve(t, m, p, repository+":latest", repository+":stable", digest) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, repository+":stable", img.Name) + require.Equal(t, "sha256:"+digest, img.Digest) + requireTagResolvesTo(t, p, repository, "stable", digest) require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) - _, err := os.Stat(p.ImageContentDir(digest)) + _, err = os.Stat(p.ImageContentDir(digest)) require.NoError(t, err) require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) _, err = os.Stat(p.ImageContentDir(digest)) require.True(t, os.IsNotExist(err), "content must be removed once unreferenced") } +func TestTagImageLegacyLayoutSource(t *testing.T) { + p, m, repository := newTagTestCase(t) + digest := "d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8" + seedLegacyImage(t, p, repository, "latest", digest) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":v2") + require.NoError(t, err) + require.Equal(t, repository+":v2", img.Name) + requireTagResolvesTo(t, p, repository, "v2", digest) +} + func TestTagImageCrossRepository(t *testing.T) { p, m, sourceRepo := newTagTestCase(t) targetRepo := "registry.example/apps/alpine" digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - legacyDir := p.ImageDigestDir(sourceRepo, digest) - require.NoError(t, os.MkdirAll(legacyDir, 0o755)) - require.NoError(t, writeMetadataFile(p.ImageMetadata(sourceRepo, digest), &imageMetadata{ - Name: sourceRepo + ":latest", Digest: "sha256:" + digest, - Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), - })) - require.NoError(t, os.WriteFile(p.ImageDigestPath(sourceRepo, digest), []byte("rootfs!"), 0o644)) - require.NoError(t, createTagSymlink(p, sourceRepo, "latest", digest)) + seedLegacyImage(t, p, sourceRepo, "latest", digest) img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") require.NoError(t, err) @@ -96,12 +109,10 @@ func TestTagImageCrossRepository(t *testing.T) { data, err := os.ReadFile(p.ImageContentPath(digest)) require.NoError(t, err) require.Equal(t, "rootfs!", string(data)) - require.DirExists(t, legacyDir) + require.DirExists(t, p.ImageDigestDir(sourceRepo, digest)) for repo, tag := range map[string]string{sourceRepo: "latest", targetRepo: "v1"} { - resolved, err := resolveTag(p, repo, tag) - require.NoError(t, err) - require.Equal(t, digest, resolved) + requireTagResolvesTo(t, p, repo, tag, digest) } require.NoError(t, m.DeleteImage(context.Background(), sourceRepo+":latest")) _, err = os.Stat(p.ImageContentDir(digest)) @@ -113,7 +124,7 @@ func TestTagImageCrossRepository(t *testing.T) { func TestTagImageRejectsNotReady(t *testing.T) { p, m, repository := newTagTestCase(t) - digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" + digest := "d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" require.NoError(t, os.MkdirAll(p.ImageContentDir(digest), 0o755)) require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ Name: repository + ":latest", Digest: "sha256:" + digest, @@ -134,6 +145,25 @@ func TestTagImageSourceNotFound(t *testing.T) { require.ErrorIs(t, err, ErrNotFound) } +func TestTagImageFailureLeavesNoSideEffects(t *testing.T) { + p, m, repository := newTagTestCase(t) + digest := "d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5" + seedReadyContentImage(t, p, repository, "latest", digest) + + // Failed calls must not create the target tag or bump its generation, + // which would invalidate a pending pull's claim on the target tag. + _, err := m.TagImage(context.Background(), repository+":missing", repository+":stable") + require.ErrorIs(t, err, ErrNotFound) + _, err = resolveTag(p, repository, "stable") + require.ErrorIs(t, err, ErrNotFound) + require.Equal(t, uint64(0), m.tagGenerations[repository+":stable"]) + + img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") + require.NoError(t, err) + require.Equal(t, "sha256:"+digest, img.Digest) + requireTagResolvesTo(t, p, repository, "stable", digest) +} + func TestTagImageRejectsDigestTarget(t *testing.T) { p, m, repository := newTagTestCase(t) digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" @@ -144,7 +174,7 @@ func TestTagImageRejectsDigestTarget(t *testing.T) { func TestTagImageReplacesExistingTag(t *testing.T) { p, m, repository := newTagTestCase(t) - first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" + first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" seedReadyContentImage(t, p, repository, "latest", first) seedReadyContentImage(t, p, repository, "stable", second) @@ -152,9 +182,7 @@ func TestTagImageReplacesExistingTag(t *testing.T) { img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") require.NoError(t, err) require.Equal(t, repository+":stable", img.Name) - resolved, err := resolveTag(p, repository, "stable") - require.NoError(t, err) - require.Equal(t, first, resolved) + requireTagResolvesTo(t, p, repository, "stable", first) _, err = os.Stat(p.ImageContentDir(second)) require.NoError(t, err) } From c517bdd51d2073740c514202212d19c1cad68cab Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:36:02 +0000 Subject: [PATCH 08/11] Document promotion-before-symlink ordering in TagImage --- lib/images/tag.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/images/tag.go b/lib/images/tag.go index f97bf5e0c..8df0fc961 100644 --- a/lib/images/tag.go +++ b/lib/images/tag.go @@ -10,6 +10,12 @@ import ( // failed call leaves no side effects: the target tag's generation is only // bumped after the new tag is on disk, so pending pulls that claimed the // target tag keep their claim. +// +// Promotion deliberately runs before the symlink install, so a symlink +// failure after a cross-repo promotion leaves the content promoted with no +// target tag. That state is gc-consistent (unreferenced content is +// collected) and retry is idempotent (promoteImageToContent short-circuits +// on ready content), which beats rolling back a completed promotion. func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, error) { sourceRef, targetRef, err := parseTagReferences(source, target) if err != nil { From 40efaaa00761278ae77fbf1cac8b0526b2a5dec8 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:53:52 +0000 Subject: [PATCH 09/11] Tighten tag test assertions Replace the error-code switch helper with full-response equality and collapse repeated tag-call assertion blocks into a shared helper. --- cmd/api/api/images_test.go | 46 +++++++++++--------------------------- lib/images/tag_test.go | 38 +++++++++++++------------------ 2 files changed, 29 insertions(+), 55 deletions(-) diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index 2a5d02367..5f22c589a 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -545,28 +545,24 @@ func TestTagImage_ErrorStatusMapping(t *testing.T) { t.Parallel() cases := []struct { - name string - err error - wantType any - wantCode string + name string + err error + want oapi.TagImageResponseObject }{ { - name: "invalid name -> 400", - err: fmt.Errorf("tag: %w", images.ErrInvalidName), - wantType: oapi.TagImage400JSONResponse{}, - wantCode: "invalid_name", + name: "invalid name -> 400", + err: fmt.Errorf("tag: %w", images.ErrInvalidName), + want: oapi.TagImage400JSONResponse{Code: "invalid_name", Message: "tag: invalid image name"}, }, { - name: "not found -> 404", - err: fmt.Errorf("tag: %w", images.ErrNotFound), - wantType: oapi.TagImage404JSONResponse{}, - wantCode: "not_found", + name: "not found -> 404", + err: fmt.Errorf("tag: %w", images.ErrNotFound), + want: oapi.TagImage404JSONResponse{Code: "not_found", Message: "source image not found"}, }, { - name: "not ready -> 409", - err: fmt.Errorf("tag: %w", images.ErrImageNotReady), - wantType: oapi.TagImage409JSONResponse{}, - wantCode: "image_not_ready", + name: "not ready -> 409", + err: fmt.Errorf("tag: %w", images.ErrImageNotReady), + want: oapi.TagImage409JSONResponse{Code: "image_not_ready", Message: "tag: image is not ready"}, }, } @@ -578,8 +574,7 @@ func TestTagImage_ErrorStatusMapping(t *testing.T) { Body: &oapi.TagImageRequest{Target: "docker.io/library/alpine:stable"}, }) require.NoError(t, err) - require.IsType(t, tc.wantType, resp) - require.Equal(t, tc.wantCode, tagImageErrorCode(resp)) + require.Equal(t, tc.want, resp) }) } } @@ -595,21 +590,6 @@ func TestTagImage_MissingBody(t *testing.T) { require.IsType(t, oapi.TagImage400JSONResponse{}, resp) } -func tagImageErrorCode(resp oapi.TagImageResponseObject) string { - switch r := resp.(type) { - case oapi.TagImage400JSONResponse: - return r.Code - case oapi.TagImage404JSONResponse: - return r.Code - case oapi.TagImage409JSONResponse: - return r.Code - case oapi.TagImage500JSONResponse: - return r.Code - default: - return "" - } -} - // seedReadyContentImage writes a ready image into the shared content layout // plus a repository tag reference, without pulling from a registry. func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) { diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index 24989f538..40264c9d6 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -45,6 +45,14 @@ func requireTagResolvesTo(t *testing.T, p *paths.Paths, repository, tag, digest require.Equal(t, digest, resolved) } +func tagImage(t *testing.T, m *manager, source, target, digest string) { + t.Helper() + img, err := m.TagImage(context.Background(), source, target) + require.NoError(t, err) + require.Equal(t, target, img.Name) + require.Equal(t, "sha256:"+digest, img.Digest) +} + func TestTagImageAliasesReadyImage(t *testing.T) { cases := []struct { name, source, target, digest string @@ -56,10 +64,7 @@ func TestTagImageAliasesReadyImage(t *testing.T) { t.Run(tc.name, func(t *testing.T) { p, m, repository := newTagTestCase(t) seedReadyContentImage(t, p, repository, "latest", tc.digest) - img, err := m.TagImage(context.Background(), tc.source, tc.target) - require.NoError(t, err) - require.Equal(t, tc.target, img.Name) - require.Equal(t, "sha256:"+tc.digest, img.Digest) + tagImage(t, m, tc.source, tc.target, tc.digest) targetRef, err := ParseNormalizedRef(tc.target) require.NoError(t, err) requireTagResolvesTo(t, p, targetRef.Repository(), targetRef.Tag(), tc.digest) @@ -72,14 +77,11 @@ func TestTagImageSameRepositoryDeletesContent(t *testing.T) { digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" seedReadyContentImage(t, p, repository, "latest", digest) - img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") - require.NoError(t, err) - require.Equal(t, repository+":stable", img.Name) - require.Equal(t, "sha256:"+digest, img.Digest) + tagImage(t, m, repository+":latest", repository+":stable", digest) requireTagResolvesTo(t, p, repository, "stable", digest) require.NoError(t, m.DeleteImage(context.Background(), repository+":latest")) - _, err = os.Stat(p.ImageContentDir(digest)) + _, err := os.Stat(p.ImageContentDir(digest)) require.NoError(t, err) require.NoError(t, m.DeleteImage(context.Background(), repository+":stable")) _, err = os.Stat(p.ImageContentDir(digest)) @@ -91,9 +93,7 @@ func TestTagImageLegacyLayoutSource(t *testing.T) { digest := "d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8" seedLegacyImage(t, p, repository, "latest", digest) - img, err := m.TagImage(context.Background(), repository+":latest", repository+":v2") - require.NoError(t, err) - require.Equal(t, repository+":v2", img.Name) + tagImage(t, m, repository+":latest", repository+":v2", digest) requireTagResolvesTo(t, p, repository, "v2", digest) } @@ -103,9 +103,7 @@ func TestTagImageCrossRepository(t *testing.T) { digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" seedLegacyImage(t, p, sourceRepo, "latest", digest) - img, err := m.TagImage(context.Background(), sourceRepo+":latest", targetRepo+":v1") - require.NoError(t, err) - require.Equal(t, targetRepo+":v1", img.Name) + tagImage(t, m, sourceRepo+":latest", targetRepo+":v1", digest) data, err := os.ReadFile(p.ImageContentPath(digest)) require.NoError(t, err) require.Equal(t, "rootfs!", string(data)) @@ -158,9 +156,7 @@ func TestTagImageFailureLeavesNoSideEffects(t *testing.T) { require.ErrorIs(t, err, ErrNotFound) require.Equal(t, uint64(0), m.tagGenerations[repository+":stable"]) - img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") - require.NoError(t, err) - require.Equal(t, "sha256:"+digest, img.Digest) + tagImage(t, m, repository+":latest", repository+":stable", digest) requireTagResolvesTo(t, p, repository, "stable", digest) } @@ -179,10 +175,8 @@ func TestTagImageReplacesExistingTag(t *testing.T) { seedReadyContentImage(t, p, repository, "latest", first) seedReadyContentImage(t, p, repository, "stable", second) - img, err := m.TagImage(context.Background(), repository+":latest", repository+":stable") - require.NoError(t, err) - require.Equal(t, repository+":stable", img.Name) + tagImage(t, m, repository+":latest", repository+":stable", first) requireTagResolvesTo(t, p, repository, "stable", first) - _, err = os.Stat(p.ImageContentDir(second)) + _, err := os.Stat(p.ImageContentDir(second)) require.NoError(t, err) } From f788844202558d320b707d344d5d7540fa264279 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:23:22 +0000 Subject: [PATCH 10/11] Collect replaced image tags --- lib/images/tag.go | 21 +++++++++++++++++++++ lib/images/tag_test.go | 12 ++++++++++-- openapi.yaml | 6 ++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/images/tag.go b/lib/images/tag.go index 8df0fc961..1391e36b6 100644 --- a/lib/images/tag.go +++ b/lib/images/tag.go @@ -2,6 +2,7 @@ package images import ( "context" + "errors" "fmt" ) @@ -25,6 +26,13 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, m.createMu.Lock() defer m.createMu.Unlock() + previousDigest, err := resolveTag(m.paths, targetRef.Repository(), targetRef.Tag()) + if errors.Is(err, ErrNotFound) { + previousDigest = "" + } else if err != nil { + return nil, err + } + digestHex, meta, err := m.readyTagImage(sourceRef) if err != nil { return nil, err @@ -38,10 +46,23 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, return nil, fmt.Errorf("create image tag: %w", err) } m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) + if err := m.cleanupReplacedTag(targetRef, previousDigest, digestHex); err != nil { + return nil, err + } return meta.toImageFor(targetRef.String()), nil } +func (m *manager) cleanupReplacedTag(ref *NormalizedRef, previousDigest, digestHex string) error { + if previousDigest != "" && previousDigest != digestHex { + if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), previousDigest, true); err != nil { + return fmt.Errorf("remove replaced digest %s: %w", previousDigest, err) + } + } + m.refreshDiskUsageTotals() + return nil +} + func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, error) { sourceRef, err := ParseNormalizedRef(source) if err != nil { diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index 40264c9d6..c0130ab68 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -168,15 +168,23 @@ func TestTagImageRejectsDigestTarget(t *testing.T) { require.ErrorIs(t, err, ErrInvalidName) } -func TestTagImageReplacesExistingTag(t *testing.T) { +func TestTagImageReplacesExistingTagAndCollectsOldContent(t *testing.T) { p, m, repository := newTagTestCase(t) first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" seedReadyContentImage(t, p, repository, "latest", first) seedReadyContentImage(t, p, repository, "stable", second) + readyBytes, err := m.TotalImageBytes(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(14), readyBytes) + tagImage(t, m, repository+":latest", repository+":stable", first) requireTagResolvesTo(t, p, repository, "stable", first) - _, err := os.Stat(p.ImageContentDir(second)) + _, err = os.Stat(p.ImageContentDir(second)) + require.ErrorIs(t, err, os.ErrNotExist) + + readyBytes, err = m.TotalImageBytes(context.Background()) require.NoError(t, err) + require.Equal(t, int64(7), readyBytes) } diff --git a/openapi.yaml b/openapi.yaml index e7cef8a6c..3b5585ec0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2693,6 +2693,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 500: description: Internal server error content: From d9e198c9b99c3355dcc300ac261db36beec28359 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:56:32 +0000 Subject: [PATCH 11/11] Address review findings on image tagging - Log replaced-content cleanup failures instead of failing the call after the tag is live, and document the collection in TagImage's doc comment - Wrap the existing-target resolve error for consistent 500 context - Clarify the digest-target rejection message - Explain why TagImage bumps tag generation after install, unlike updateExistingReference - Skip restaging tag symlinks that already point at shared content during legacy promotion - Extract shared image-seeding test helper into lib/images/testutil --- cmd/api/api/images.go | 4 ++ cmd/api/api/images_test.go | 68 +++++------------------- lib/images/storage.go | 6 +++ lib/images/tag.go | 23 ++++---- lib/images/tag_test.go | 37 +++++-------- lib/images/testutil/testutil.go | 93 +++++++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 88 deletions(-) create mode 100644 lib/images/testutil/testutil.go diff --git a/cmd/api/api/images.go b/cmd/api/api/images.go index 60df40052..8c4078de5 100644 --- a/cmd/api/api/images.go +++ b/cmd/api/api/images.go @@ -94,6 +94,10 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe return oapi.CreateImage202JSONResponse(imageToOAPI(*img)), nil } +// TagImage handles POST /images/{name}/tag. +// Note: the ResolveResource middleware also resolves {name} via GetImage before +// this handler runs, so a missing source returns the middleware's generic 404 +// body and the source is resolved twice per request. func (s *ApiService) TagImage(ctx context.Context, request oapi.TagImageRequestObject) (oapi.TagImageResponseObject, error) { if request.Body == nil { return oapi.TagImage400JSONResponse{ diff --git a/cmd/api/api/images_test.go b/cmd/api/api/images_test.go index 5f22c589a..b6a1857b5 100644 --- a/cmd/api/api/images_test.go +++ b/cmd/api/api/images_test.go @@ -2,14 +2,12 @@ package api import ( "context" - "encoding/json" "fmt" - "os" - "path/filepath" "testing" "time" "github.com/kernel/hypeman/lib/images" + "github.com/kernel/hypeman/lib/images/testutil" "github.com/kernel/hypeman/lib/oapi" "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/assert" @@ -515,30 +513,12 @@ func seedReadyDigestOnlyImage(t *testing.T, svc *ApiService, imageRef string, im require.NoError(t, err) require.True(t, ref.IsDigest(), "test helper expects a digest reference") - p := paths.New(svc.Config.DataDir) - digestDir := p.ImageDigestDir(ref.Repository(), ref.DigestHex()) - require.NoError(t, os.MkdirAll(digestDir, 0o755)) - require.NoError(t, os.WriteFile(p.ImageDigestPath(ref.Repository(), ref.DigestHex()), []byte("rootfs"), 0o644)) - - meta := struct { - Name string `json:"name"` - Digest string `json:"digest"` - Status string `json:"status"` - SizeBytes int64 `json:"size_bytes"` - Tags map[string]string `json:"tags,omitempty"` - CreatedAt time.Time `json:"created_at"` - }{ - Name: imageRef, - Digest: "sha256:" + ref.DigestHex(), - Status: "ready", - SizeBytes: int64(len("rootfs")), - Tags: imageTags, - CreatedAt: time.Now().UTC(), - } - - data, err := json.Marshal(meta) - require.NoError(t, err) - require.NoError(t, os.WriteFile(p.ImageMetadata(ref.Repository(), ref.DigestHex()), data, 0o644)) + testutil.SeedReadyImage(t, paths.New(svc.Config.DataDir), testutil.Seed{ + Repository: ref.Repository(), + DigestHex: ref.DigestHex(), + Name: imageRef, + Tags: imageTags, + }) } func TestTagImage_ErrorStatusMapping(t *testing.T) { @@ -594,34 +574,12 @@ func TestTagImage_MissingBody(t *testing.T) { // plus a repository tag reference, without pulling from a registry. func seedReadyContentImage(t *testing.T, svc *ApiService, repository, tag, digestHex string) { t.Helper() - - p := paths.New(svc.Config.DataDir) - contentDir := p.ImageContentDir(digestHex) - require.NoError(t, os.MkdirAll(contentDir, 0o755)) - require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs"), 0o644)) - - meta := struct { - Name string `json:"name"` - Digest string `json:"digest"` - Status string `json:"status"` - SizeBytes int64 `json:"size_bytes"` - CreatedAt time.Time `json:"created_at"` - }{ - Name: repository + ":" + tag, - Digest: "sha256:" + digestHex, - Status: "ready", - SizeBytes: int64(len("rootfs")), - CreatedAt: time.Now().UTC(), - } - data, err := json.Marshal(meta) - require.NoError(t, err) - require.NoError(t, os.WriteFile(p.ImageContentMetadata(digestHex), data, 0o644)) - - linkPath := p.ImageRepositoryTagSymlink(repository, tag) - target, err := filepath.Rel(filepath.Dir(linkPath), contentDir) - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) - require.NoError(t, os.Symlink(target, linkPath)) + testutil.SeedReadyImage(t, paths.New(svc.Config.DataDir), testutil.Seed{ + Repository: repository, + Tag: tag, + DigestHex: digestHex, + Content: true, + }) } func TestTagImage_Success(t *testing.T) { diff --git a/lib/images/storage.go b/lib/images/storage.go index d97e54101..9c1c79521 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -340,6 +340,12 @@ func promoteLegacyTags(p *paths.Paths, repository, digestHex string) error { if err != nil || target != digestHex { continue } + // resolveTag validated that content-relative links resolve to this + // digest's content dir, so only legacy links (bare digest target) + // still need restaging. + if raw, err := os.Readlink(tagSymlinkPath(p, repository, tag)); err == nil && raw != digestHex { + continue + } ref, err := stageTagSymlink(p, repository, tag, digestHex) if err != nil { return fmt.Errorf("stage legacy tag %s: %w", tag, err) diff --git a/lib/images/tag.go b/lib/images/tag.go index 1391e36b6..2d3e184e2 100644 --- a/lib/images/tag.go +++ b/lib/images/tag.go @@ -4,13 +4,17 @@ import ( "context" "errors" "fmt" + "log/slog" ) // TagImage creates a ready-image tag without pulling or converting content. // Cross-repository tags promote legacy content into the shared layout. A // failed call leaves no side effects: the target tag's generation is only // bumped after the new tag is on disk, so pending pulls that claimed the -// target tag keep their claim. +// target tag keep their claim. When the target previously pointed at +// different content, that digest is collected after the new tag is live; +// cleanup failures are logged and do not fail the call, since the tag is +// already installed at that point. // // Promotion deliberately runs before the symlink install, so a symlink // failure after a cross-repo promotion leaves the content promoted with no @@ -30,7 +34,7 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, if errors.Is(err, ErrNotFound) { previousDigest = "" } else if err != nil { - return nil, err + return nil, fmt.Errorf("resolve existing target tag: %w", err) } digestHex, meta, err := m.readyTagImage(sourceRef) @@ -45,22 +49,23 @@ func (m *manager) TagImage(ctx context.Context, source, target string) (*Image, if err := createTagSymlink(m.paths, targetRef.Repository(), targetRef.Tag(), digestHex); err != nil { return nil, fmt.Errorf("create image tag: %w", err) } + // Unlike updateExistingReference, which bumps the generation before + // installing the symlink, the bump happens after the install here so a + // failed tag call cannot invalidate a pending pull's claim on the target + // tag (pinned by TestTagImageFailureLeavesNoSideEffects). m.nextTagGeneration(targetRef.Repository(), targetRef.Tag()) - if err := m.cleanupReplacedTag(targetRef, previousDigest, digestHex); err != nil { - return nil, err - } + m.cleanupReplacedTag(targetRef, previousDigest, digestHex) return meta.toImageFor(targetRef.String()), nil } -func (m *manager) cleanupReplacedTag(ref *NormalizedRef, previousDigest, digestHex string) error { +func (m *manager) cleanupReplacedTag(ref *NormalizedRef, previousDigest, digestHex string) { if previousDigest != "" && previousDigest != digestHex { if err := removeDigestIfUnreferenced(m.paths, ref.Repository(), previousDigest, true); err != nil { - return fmt.Errorf("remove replaced digest %s: %w", previousDigest, err) + slog.Warn("failed to collect replaced image content", "repository", ref.Repository(), "digest", previousDigest, "error", err) } } m.refreshDiskUsageTotals() - return nil } func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, error) { @@ -73,7 +78,7 @@ func parseTagReferences(source, target string) (*NormalizedRef, *NormalizedRef, return nil, nil, fmt.Errorf("%w: invalid target reference: %s", ErrInvalidName, err) } if targetRef.IsDigest() { - return nil, nil, fmt.Errorf("%w: target must include a tag", ErrInvalidName) + return nil, nil, fmt.Errorf("%w: target must be a tag reference, not a digest", ErrInvalidName) } return sourceRef, targetRef, nil } diff --git a/lib/images/tag_test.go b/lib/images/tag_test.go index c0130ab68..cf76b9802 100644 --- a/lib/images/tag_test.go +++ b/lib/images/tag_test.go @@ -6,30 +6,19 @@ import ( "testing" "time" + "github.com/kernel/hypeman/lib/images/testutil" "github.com/kernel/hypeman/lib/paths" "github.com/stretchr/testify/require" ) -func seedReadyContentImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { +func seedContent(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { t.Helper() - require.NoError(t, os.MkdirAll(p.ImageContentDir(digestHex), 0o755)) - require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digestHex), &imageMetadata{ - Name: repository + ":" + tag, Digest: "sha256:" + digestHex, - Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), - })) - require.NoError(t, os.WriteFile(p.ImageContentPath(digestHex), []byte("rootfs!"), 0o644)) - require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) + testutil.SeedReadyImage(t, p, testutil.Seed{Repository: repository, Tag: tag, DigestHex: digestHex, Content: true}) } -func seedLegacyImage(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { +func seedLegacy(t *testing.T, p *paths.Paths, repository, tag, digestHex string) { t.Helper() - require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digestHex), 0o755)) - require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digestHex), &imageMetadata{ - Name: repository + ":" + tag, Digest: "sha256:" + digestHex, - Status: StatusReady, SizeBytes: 7, CreatedAt: time.Now().UTC(), - })) - require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digestHex), []byte("rootfs!"), 0o644)) - require.NoError(t, createTagSymlink(p, repository, tag, digestHex)) + testutil.SeedReadyImage(t, p, testutil.Seed{Repository: repository, Tag: tag, DigestHex: digestHex}) } func newTagTestCase(t *testing.T) (*paths.Paths, *manager, string) { @@ -63,7 +52,7 @@ func TestTagImageAliasesReadyImage(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { p, m, repository := newTagTestCase(t) - seedReadyContentImage(t, p, repository, "latest", tc.digest) + seedContent(t, p, repository, "latest", tc.digest) tagImage(t, m, tc.source, tc.target, tc.digest) targetRef, err := ParseNormalizedRef(tc.target) require.NoError(t, err) @@ -75,7 +64,7 @@ func TestTagImageAliasesReadyImage(t *testing.T) { func TestTagImageSameRepositoryDeletesContent(t *testing.T) { p, m, repository := newTagTestCase(t) digest := "a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" - seedReadyContentImage(t, p, repository, "latest", digest) + seedContent(t, p, repository, "latest", digest) tagImage(t, m, repository+":latest", repository+":stable", digest) requireTagResolvesTo(t, p, repository, "stable", digest) @@ -91,7 +80,7 @@ func TestTagImageSameRepositoryDeletesContent(t *testing.T) { func TestTagImageLegacyLayoutSource(t *testing.T) { p, m, repository := newTagTestCase(t) digest := "d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8d8" - seedLegacyImage(t, p, repository, "latest", digest) + seedLegacy(t, p, repository, "latest", digest) tagImage(t, m, repository+":latest", repository+":v2", digest) requireTagResolvesTo(t, p, repository, "v2", digest) @@ -101,7 +90,7 @@ func TestTagImageCrossRepository(t *testing.T) { p, m, sourceRepo := newTagTestCase(t) targetRepo := "registry.example/apps/alpine" digest := "c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - seedLegacyImage(t, p, sourceRepo, "latest", digest) + seedLegacy(t, p, sourceRepo, "latest", digest) tagImage(t, m, sourceRepo+":latest", targetRepo+":v1", digest) data, err := os.ReadFile(p.ImageContentPath(digest)) @@ -146,7 +135,7 @@ func TestTagImageSourceNotFound(t *testing.T) { func TestTagImageFailureLeavesNoSideEffects(t *testing.T) { p, m, repository := newTagTestCase(t) digest := "d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5" - seedReadyContentImage(t, p, repository, "latest", digest) + seedContent(t, p, repository, "latest", digest) // Failed calls must not create the target tag or bump its generation, // which would invalidate a pending pull's claim on the target tag. @@ -163,7 +152,7 @@ func TestTagImageFailureLeavesNoSideEffects(t *testing.T) { func TestTagImageRejectsDigestTarget(t *testing.T) { p, m, repository := newTagTestCase(t) digest := "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" - seedReadyContentImage(t, p, repository, "latest", digest) + seedContent(t, p, repository, "latest", digest) _, err := m.TagImage(context.Background(), repository+":latest", repository+"@sha256:"+digest) require.ErrorIs(t, err, ErrInvalidName) } @@ -172,8 +161,8 @@ func TestTagImageReplacesExistingTagAndCollectsOldContent(t *testing.T) { p, m, repository := newTagTestCase(t) first := "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6" second := "a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7" - seedReadyContentImage(t, p, repository, "latest", first) - seedReadyContentImage(t, p, repository, "stable", second) + seedContent(t, p, repository, "latest", first) + seedContent(t, p, repository, "stable", second) readyBytes, err := m.TotalImageBytes(context.Background()) require.NoError(t, err) diff --git a/lib/images/testutil/testutil.go b/lib/images/testutil/testutil.go new file mode 100644 index 000000000..ebf356d2d --- /dev/null +++ b/lib/images/testutil/testutil.go @@ -0,0 +1,93 @@ +// Package testutil provides helpers for seeding on-disk image state in tests. +package testutil + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/kernel/hypeman/lib/paths" + "github.com/stretchr/testify/require" +) + +const seedImageContent = "rootfs!" + +// Seed describes a ready image to write directly to disk, bypassing pulls, +// builds, and conversion. +type Seed struct { + Repository string + DigestHex string + // Tag optionally creates a tag symlink pointing at the image. + Tag string + // Name overrides the metadata "name" field; it defaults to + // repository:tag, or repository@sha256:digest when Tag is empty. + Name string + // Tags records resource tags in the metadata. + Tags map[string]string + // Content writes the image into the shared content layout instead of + // the legacy per-repository digest layout. + Content bool +} + +// SeedReadyImage writes a ready image to disk per s. The metadata struct +// mirrors the unexported images.imageMetadata schema; fields left unset +// decode as zero values. +func SeedReadyImage(t testing.TB, p *paths.Paths, s Seed) { + t.Helper() + + if s.Content { + require.NoError(t, os.MkdirAll(p.ImageContentDir(s.DigestHex), 0o755)) + require.NoError(t, os.WriteFile(p.ImageContentPath(s.DigestHex), []byte(seedImageContent), 0o644)) + } else { + require.NoError(t, os.MkdirAll(p.ImageDigestDir(s.Repository, s.DigestHex), 0o755)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(s.Repository, s.DigestHex), []byte(seedImageContent), 0o644)) + } + + name := s.Name + if name == "" { + if s.Tag != "" { + name = s.Repository + ":" + s.Tag + } else { + name = s.Repository + "@sha256:" + s.DigestHex + } + } + meta := struct { + Name string `json:"name"` + Digest string `json:"digest"` + Status string `json:"status"` + SizeBytes int64 `json:"size_bytes"` + Tags map[string]string `json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + }{ + Name: name, + Digest: "sha256:" + s.DigestHex, + Status: "ready", + SizeBytes: int64(len(seedImageContent)), + Tags: s.Tags, + CreatedAt: time.Now().UTC(), + } + data, err := json.Marshal(meta) + require.NoError(t, err) + if s.Content { + require.NoError(t, os.WriteFile(p.ImageContentMetadata(s.DigestHex), data, 0o644)) + } else { + require.NoError(t, os.WriteFile(p.ImageMetadata(s.Repository, s.DigestHex), data, 0o644)) + } + + if s.Tag == "" { + return + } + if s.Content { + linkPath := p.ImageRepositoryTagSymlink(s.Repository, s.Tag) + target, err := filepath.Rel(filepath.Dir(linkPath), p.ImageContentDir(s.DigestHex)) + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + require.NoError(t, os.Symlink(target, linkPath)) + } else { + linkPath := p.ImageTagSymlink(s.Repository, s.Tag) + require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) + require.NoError(t, os.Symlink(s.DigestHex, linkPath)) + } +}