mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
All add-ons now publish to ADDON_OCI_REPOSITORY as tags of one repository. Each tag starts with the add-on id: <id>-sha-<short sha> for a commit, <id>-<version> for a release, <id>-development and <id>-latest for the channel aliases. The publish step, the alias promotion and the catalog renderer add the prefix, so the per-add-on repository step of the workflow is gone. A new addonpkg target command prints the reference an add-on publishes to without a network request. The workflow policy now refuses a derived repository. The host parser already reads a tag reference and a repository with a nested path, so both the new form and the old per-repository form install. A test covers each. The artifact fields in gallery/addons/index.yaml were changed by hand to the tag form. The digests are unchanged and stay until the add-ons are published again. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
580 lines
19 KiB
Go
580 lines
19 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package packaging
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/layout"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
|
|
)
|
|
|
|
var (
|
|
immutableTagRE = regexp.MustCompile(`^(sha-[0-9a-f]{12}|(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+(\.[0-9A-Za-z-]+)*)?)$`)
|
|
branchAliasRE = regexp.MustCompile(`^branch-[a-z0-9]+(-[a-z0-9]+)*-[0-9a-f]{12}$`)
|
|
addonIDRE = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`)
|
|
)
|
|
|
|
// addonTag is the registry tag of one add-on in the shared add-on repository:
|
|
// the add-on id, a dash, then the immutable tag or the channel alias. Every
|
|
// add-on lives in the same repository, so the id prefix keeps their tags apart,
|
|
// for example fine-tuning-sha-99d1dca2c869 or tables-development.
|
|
func addonTag(id, tag string) (string, error) {
|
|
if !addonIDRE.MatchString(id) {
|
|
return "", fmt.Errorf("%q is not an add-on id", id)
|
|
}
|
|
return id + "-" + tag, nil
|
|
}
|
|
|
|
// backendRepositoryPrefixes are repositories that belong to the backend
|
|
// image pipeline. Add-on packages never go there, whatever the input says.
|
|
var backendRepositoryPrefixes = []string{
|
|
"quay.io/go-skynet/",
|
|
"index.docker.io/localai/",
|
|
"docker.io/localai/",
|
|
"ghcr.io/mudler/localai",
|
|
}
|
|
|
|
// addonRepositoryNamespace is the one repository under the backend registry
|
|
// organization that belongs to add-on packages. It is the only exception to
|
|
// backendRepositoryPrefixes.
|
|
const addonRepositoryNamespace = "quay.io/go-skynet/local-ai-addons"
|
|
|
|
// parseRepository validates the registry and repository input. It has no
|
|
// default: an empty value is an error, so a missing configuration variable
|
|
// fails the run instead of publishing somewhere unintended.
|
|
func parseRepository(s string) (name.Repository, error) {
|
|
if strings.TrimSpace(s) == "" {
|
|
return name.Repository{}, errors.New("repository input is required: set the add-on registry and repository configuration")
|
|
}
|
|
if strings.ContainsAny(s, "@: ") && !strings.Contains(s, ":") {
|
|
return name.Repository{}, errors.New("repository must not carry a tag or digest")
|
|
}
|
|
repo, err := name.NewRepository(s, name.StrictValidation)
|
|
if err != nil {
|
|
return name.Repository{}, fmt.Errorf("repository %q: %w", s, err)
|
|
}
|
|
full := strings.ToLower(repo.Name())
|
|
if full == addonRepositoryNamespace || strings.HasPrefix(full, addonRepositoryNamespace+"/") {
|
|
return repo, nil
|
|
}
|
|
for _, p := range backendRepositoryPrefixes {
|
|
if strings.HasPrefix(full+"/", p) || strings.HasPrefix(full, p) {
|
|
return name.Repository{}, fmt.Errorf("repository %q is a backend image repository; add-on packages use their own namespace", s)
|
|
}
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func isNotFound(err error) bool {
|
|
var te *transport.Error
|
|
return errors.As(err, &te) && te.StatusCode == 404
|
|
}
|
|
|
|
func remoteOptions(ctx context.Context) []remote.Option {
|
|
return []remote.Option{remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain)}
|
|
}
|
|
|
|
// guardImmutable refuses a tag that already names different content.
|
|
func guardImmutable(ctx context.Context, tag name.Tag, digest v1.Hash) (exists bool, err error) {
|
|
d, err := remote.Head(tag, remoteOptions(ctx)...)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("reading %s: %w", tag, err)
|
|
}
|
|
if d.Digest != digest {
|
|
return true, fail("E_IMMUTABLE", "%s already names %s; an immutable tag is never overwritten with %s", tag.Identifier(), d.Digest, digest)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// publishIndex pushes a verified merged layout: every platform image and the
|
|
// index by digest first, then the immutable tag. Re-running it with the same
|
|
// layout resumes: pushes of existing blobs are no-ops and the tag already
|
|
// names the same digest.
|
|
func publishIndex(ctx context.Context, dir string, cfg *PlatformsFile, repo name.Repository, immutable string, adopt bool) (v1.Hash, error) {
|
|
var zero v1.Hash
|
|
if !immutableTagRE.MatchString(immutable) {
|
|
return zero, fail("E_RESERVED", "%q is not an immutable version or commit tag", immutable)
|
|
}
|
|
m, digest, err := verifyMerged(dir, cfg)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if strings.HasPrefix(immutable, "sha-") {
|
|
if len(m.Revision) != 40 || immutable != "sha-"+m.Revision[:12] {
|
|
return zero, fail("E_RESERVED", "commit tag %q does not name the source commit of the layout", immutable)
|
|
}
|
|
} else if immutable != m.Version {
|
|
return zero, fail("E_RESERVED", "version tag %q differs from the layout version %q", immutable, m.Version)
|
|
}
|
|
regTag, err := addonTag(cfg.Addon, immutable)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
tag := repo.Tag(regTag)
|
|
if _, err := guardImmutable(ctx, tag, digest); err != nil {
|
|
if adopt && strings.HasPrefix(err.Error(), "E_IMMUTABLE") {
|
|
// A rebuild of the same version and commit differs in bytes only
|
|
// when the build is not reproducible. The published content is
|
|
// kept, never replaced, after it passes the same completeness
|
|
// rules; the workflow verifies its signature next.
|
|
if d, aerr := adoptExisting(ctx, repo, tag, m, cfg); aerr == nil {
|
|
return d, nil
|
|
} else {
|
|
return zero, fmt.Errorf("%w; cannot adopt: %v", err, aerr)
|
|
}
|
|
}
|
|
return zero, err
|
|
}
|
|
lp, err := layout.FromPath(dir)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
root, err := lp.ImageIndex()
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
idx, err := root.ImageIndex(digest)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
opts := remoteOptions(ctx)
|
|
if err := remote.WriteIndex(repo.Digest(digest.String()), idx, opts...); err != nil {
|
|
return zero, fmt.Errorf("pushing %s: %w", digest, err)
|
|
}
|
|
// A second look closes the window between the first check and the tag.
|
|
if _, err := guardImmutable(ctx, tag, digest); err != nil {
|
|
return zero, err
|
|
}
|
|
if err := remote.Tag(tag, idx, opts...); err != nil {
|
|
return zero, fmt.Errorf("tagging %s: %w", tag, err)
|
|
}
|
|
return digest, nil
|
|
}
|
|
|
|
// adoptExisting accepts the content that an immutable tag already names when
|
|
// it is a complete package index of the same add-on, version and commit.
|
|
func adoptExisting(ctx context.Context, repo name.Repository, tag name.Tag, m *merged, cfg *PlatformsFile) (v1.Hash, error) {
|
|
var zero v1.Hash
|
|
d, err := remote.Get(tag, remoteOptions(ctx)...)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
idx, err := d.ImageIndex()
|
|
if err != nil {
|
|
return zero, errors.New("existing tag does not name an image index")
|
|
}
|
|
im, err := idx.IndexManifest()
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
a := im.Annotations
|
|
if im.ArtifactType != IndexArtifactType || a[AnnotationID] != cfg.Addon || a[AnnotationVersion] != m.Version || a[AnnotationRevision] != m.Revision || m.Revision == "" {
|
|
return zero, errors.New("existing index was not built from the same add-on, version and commit")
|
|
}
|
|
enabled, err := cfg.enabled()
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
have := map[string]bool{}
|
|
for _, c := range im.Manifests {
|
|
if c.Platform != nil {
|
|
have[c.Platform.OS+"/"+c.Platform.Architecture] = true
|
|
}
|
|
}
|
|
if len(have) != len(enabled) || len(im.Manifests) != len(enabled) {
|
|
return zero, errors.New("existing index does not match the enabled platforms")
|
|
}
|
|
for _, e := range enabled {
|
|
if !have[e.Platform] {
|
|
return zero, fmt.Errorf("existing index has no %s image", e.Platform)
|
|
}
|
|
}
|
|
return d.Digest, nil
|
|
}
|
|
|
|
func cmdPublish(args []string) error {
|
|
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
|
|
dir := fs.String("layout", "", "merged layout")
|
|
repository := fs.String("repository", "", "shared add-on registry and repository; every add-on is a tag of it, prefixed with its id")
|
|
immutable := fs.String("tag", "", "immutable version or commit tag")
|
|
config := fs.String("config", "", "platform matrix file")
|
|
adopt := fs.Bool("adopt-existing", false, "keep a published index of the same version and commit when a rebuild differs")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
repo, err := parseRepository(*repository)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := loadPlatforms(*config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *dir == "" {
|
|
return errors.New("--layout is required")
|
|
}
|
|
ctx := context.Background()
|
|
d, err := publishIndex(ctx, *dir, cfg, repo, *immutable, *adopt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
children, err := childDigests(ctx, repo, d)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = fmt.Fprintf(stdout, "digest=%s\nreference=%s@%s\nchildren=%s\n", d, repo.Name(), d, strings.Join(children, ","))
|
|
return nil
|
|
}
|
|
|
|
// Promotion outcomes.
|
|
const (
|
|
promoted = "promoted"
|
|
unchanged = "unchanged"
|
|
stale = "stale"
|
|
)
|
|
|
|
type aliasState struct {
|
|
digest v1.Hash
|
|
version string
|
|
revision string
|
|
}
|
|
|
|
func readAlias(ctx context.Context, repo name.Repository, addon, alias string) (*aliasState, error) {
|
|
regTag, err := addonTag(addon, alias)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ref := repo.Tag(regTag)
|
|
d, err := remote.Get(ref, remoteOptions(ctx)...)
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
st := &aliasState{digest: d.Digest}
|
|
idx, err := d.ImageIndex()
|
|
if err != nil {
|
|
return nil, fail("E_STALE", "alias %s does not name an image index", alias)
|
|
}
|
|
im, err := idx.IndexManifest()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
st.version, st.revision = im.Annotations[AnnotationVersion], im.Annotations[AnnotationRevision]
|
|
return st, nil
|
|
}
|
|
|
|
func compareSemver(a, b string) int {
|
|
pa, pb := splitSemver(a), splitSemver(b)
|
|
for i := 0; i < 3; i++ {
|
|
if pa.num[i] != pb.num[i] {
|
|
if pa.num[i] < pb.num[i] {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
switch {
|
|
case pa.pre == "" && pb.pre == "":
|
|
return 0
|
|
case pa.pre == "":
|
|
return 1
|
|
case pb.pre == "":
|
|
return -1
|
|
}
|
|
as, bs := strings.Split(pa.pre, "."), strings.Split(pb.pre, ".")
|
|
for i := 0; i < len(as) && i < len(bs); i++ {
|
|
if as[i] == bs[i] {
|
|
continue
|
|
}
|
|
an, aok := atoi(as[i])
|
|
bn, bok := atoi(bs[i])
|
|
switch {
|
|
case aok && bok:
|
|
if an < bn {
|
|
return -1
|
|
}
|
|
return 1
|
|
case aok:
|
|
return -1
|
|
case bok:
|
|
return 1
|
|
case as[i] < bs[i]:
|
|
return -1
|
|
default:
|
|
return 1
|
|
}
|
|
}
|
|
return len(as) - len(bs)
|
|
}
|
|
|
|
type semver struct {
|
|
num [3]int
|
|
pre string
|
|
}
|
|
|
|
func splitSemver(v string) semver {
|
|
core, pre, _ := strings.Cut(v, "-")
|
|
var s semver
|
|
for i, p := range strings.SplitN(core, ".", 3) {
|
|
n, _ := atoi(p)
|
|
if i < 3 {
|
|
s.num[i] = n
|
|
}
|
|
}
|
|
s.pre = pre
|
|
return s
|
|
}
|
|
|
|
func atoi(s string) (int, bool) {
|
|
if s == "" {
|
|
return 0, false
|
|
}
|
|
n := 0
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' || n > 1<<40 {
|
|
return 0, false
|
|
}
|
|
n = n*10 + int(r-'0')
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
// errCommitUnreachable reports that git does not have one of the commits.
|
|
// It is not the same as "not an ancestor": the history that would answer the
|
|
// question is missing from the checkout.
|
|
var errCommitUnreachable = errors.New("commit is not reachable in this checkout")
|
|
|
|
// isAncestor asks git whether candidate (the commit of this run) is an
|
|
// ancestor of named (the commit an alias names). It returns
|
|
// errCommitUnreachable when named is unknown to the repository, as happens
|
|
// after a force-push. A missing candidate and any other git failure are plain
|
|
// errors.
|
|
func isAncestor(ctx context.Context, gitDir, candidate, named string) (bool, error) {
|
|
if !shaRE.MatchString(candidate) || !shaRE.MatchString(named) {
|
|
return false, errors.New("commits must be 40 hex characters")
|
|
}
|
|
var stderr bytes.Buffer
|
|
cmd := exec.CommandContext(ctx, "git", "--git-dir", gitDir, "merge-base", "--is-ancestor", candidate, named) // #nosec G204 -- fixed git arguments; the revisions are 40-hex commit identifiers checked by the caller
|
|
cmd.Stderr = &stderr
|
|
err := cmd.Run()
|
|
var ee *exec.ExitError
|
|
switch {
|
|
case err == nil:
|
|
return true, nil
|
|
case errors.As(err, &ee) && ee.ExitCode() == 1:
|
|
return false, nil
|
|
case errors.As(err, &ee) && ee.ExitCode() == 128 && strings.Contains(stderr.String(), "Not a valid commit name"):
|
|
// Which side is missing? This run's commit must be in the checkout.
|
|
if !commitExists(ctx, gitDir, candidate) {
|
|
return false, fmt.Errorf("git merge-base: commit %s of this run is missing: %w", candidate, err)
|
|
}
|
|
return false, fmt.Errorf("%w: %s", errCommitUnreachable, named)
|
|
}
|
|
return false, fmt.Errorf("git merge-base: %w: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
func commitExists(ctx context.Context, gitDir, sha string) bool {
|
|
return exec.CommandContext(ctx, "git", "--git-dir", gitDir, "cat-file", "-e", sha+"^{commit}").Run() == nil // #nosec G204 -- fixed git arguments; the revisions are 40-hex commit identifiers checked by the caller
|
|
}
|
|
|
|
// promotionSource says which event asks for the promotion.
|
|
type promotionSource struct {
|
|
Ref string // full git ref of the event, for example refs/heads/feature/x
|
|
Event string // GitHub event name
|
|
}
|
|
|
|
// pushToOwnBranch reports whether the event is a push to the ref that owns
|
|
// this branch channel. The channel name is derived from the ref, so the name
|
|
// itself is the record of which ref the channel follows.
|
|
func (s promotionSource) pushToOwnBranch(alias string) bool {
|
|
return s.Event == "push" && strings.HasPrefix(s.Ref, "refs/heads/") && branchTag(s.Ref) == alias
|
|
}
|
|
|
|
// promoteAlias moves one moving tag to a digest that was already published,
|
|
// verified and signed. It never moves an alias backwards: a slower, older run
|
|
// finds a newer alias and leaves it alone.
|
|
func promoteAlias(ctx context.Context, repo name.Repository, addon, alias string, digest v1.Hash, version, revision, gitDir string) (string, error) {
|
|
return promoteAliasFrom(ctx, repo, addon, alias, digest, version, revision, gitDir, promotionSource{})
|
|
}
|
|
|
|
// promoteAliasFrom is promoteAlias with the event that asks for the move. A
|
|
// branch channel whose current commit is gone from the checkout (the branch
|
|
// was force-pushed) accepts only a push to its own ref as the new truth.
|
|
func promoteAliasFrom(ctx context.Context, repo name.Repository, addon, alias string, digest v1.Hash, version, revision, gitDir string, src promotionSource) (string, error) {
|
|
switch {
|
|
case alias == aliasLatest:
|
|
if !versionRE.MatchString(version) || strings.Contains(version, "-") {
|
|
return "", fail("E_RESERVED", "latest only follows a stable release version, got %q", version)
|
|
}
|
|
case alias == aliasDevelopment || branchAliasRE.MatchString(alias):
|
|
if !shaRE.MatchString(revision) {
|
|
return "", fail("E_RESERVED", "alias %s needs the source commit", alias)
|
|
}
|
|
default:
|
|
return "", fail("E_RESERVED", "%q is not a channel alias", alias)
|
|
}
|
|
cur, err := readAlias(ctx, repo, addon, alias)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// The target must already exist in the registry: a promotion never
|
|
// creates content, it only names content that was published and signed.
|
|
target, err := remote.Get(repo.Digest(digest.String()), remoteOptions(ctx)...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("target %s is not published: %w", digest, err)
|
|
}
|
|
if cur != nil {
|
|
if cur.digest == digest {
|
|
return unchanged, nil
|
|
}
|
|
if alias == aliasLatest {
|
|
if cur.version != "" && version != "" {
|
|
switch c := compareSemver(version, cur.version); {
|
|
case c < 0:
|
|
return stale, nil
|
|
case c == 0:
|
|
return "", fail("E_IMMUTABLE", "latest already names version %s with another digest %s", cur.version, cur.digest)
|
|
}
|
|
}
|
|
} else if cur.revision != "" {
|
|
if cur.revision == revision {
|
|
return "", fail("E_IMMUTABLE", "channel %s already names commit %s with another digest %s", alias, revision, cur.digest)
|
|
}
|
|
// The new commit is older than the one the alias names: a slower
|
|
// run. A descendant, or a diverged commit, is the new truth of
|
|
// the branch. When the named commit is gone from the checkout
|
|
// the branch was rewritten: only a push to the channel's own
|
|
// branch may take it over. Everything else fails closed.
|
|
older, err := isAncestor(ctx, gitDir, revision, cur.revision)
|
|
switch {
|
|
case errors.Is(err, errCommitUnreachable):
|
|
if alias == aliasDevelopment || !src.pushToOwnBranch(alias) {
|
|
return "", fail("E_UNREACHABLE", "channel %s names commit %s, which is not in this checkout; only a push to the channel's own branch may replace it", alias, cur.revision)
|
|
}
|
|
older = false
|
|
case err != nil:
|
|
return "", err
|
|
}
|
|
if older {
|
|
return stale, nil
|
|
}
|
|
}
|
|
}
|
|
idx, err := target.ImageIndex()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
regTag, err := addonTag(addon, alias)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := remote.Tag(repo.Tag(regTag), idx, remoteOptions(ctx)...); err != nil {
|
|
return "", fmt.Errorf("tagging %s: %w", alias, err)
|
|
}
|
|
return promoted, nil
|
|
}
|
|
|
|
func cmdPromote(args []string) error {
|
|
fs := flag.NewFlagSet("promote", flag.ContinueOnError)
|
|
repository := fs.String("repository", "", "registry and repository")
|
|
addon := fs.String("addon", "", "add-on id; the alias tag is <id>-<alias> in the shared repository")
|
|
alias := fs.String("alias", "", "latest, development or a branch channel tag")
|
|
digest := fs.String("digest", "", "published index digest")
|
|
version := fs.String("version", "", "release version of the digest")
|
|
revision := fs.String("revision", "", "source commit of the digest")
|
|
gitDir := fs.String("git-dir", ".git", "git directory with the history of both commits")
|
|
ref := fs.String("ref", "", "git ref of the event that asks for the promotion")
|
|
event := fs.String("event", "", "GitHub event name that asks for the promotion")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
repo, err := parseRepository(*repository)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !digestRE.MatchString(*digest) {
|
|
return errors.New("--digest must be sha256:<64 hex>")
|
|
}
|
|
h, err := v1.NewHash(*digest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
defer cancel()
|
|
if _, err := os.Stat(*gitDir); err != nil && *alias != aliasLatest {
|
|
return fmt.Errorf("git directory: %w", err)
|
|
}
|
|
out, err := promoteAliasFrom(ctx, repo, *addon, *alias, h, *version, *revision, *gitDir, promotionSource{Ref: *ref, Event: *event})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = fmt.Fprintf(stdout, "result=%s\nalias=%s\ndigest=%s\n", out, *alias, h)
|
|
return nil
|
|
}
|
|
|
|
// childDigests lists the platform images of a published index, so the
|
|
// workflow can verify each one the way an installer will.
|
|
func childDigests(ctx context.Context, repo name.Repository, digest v1.Hash) ([]string, error) {
|
|
d, err := remote.Get(repo.Digest(digest.String()), remoteOptions(ctx)...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
idx, err := d.ImageIndex()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
im, err := idx.IndexManifest()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, c := range im.Manifests {
|
|
out = append(out, c.Digest.String())
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// cmdTarget prints the registry reference one add-on publishes to: the shared
|
|
// repository and the tag <id>-<tag>. It does not touch the network, so the
|
|
// workflow and the tests can show the target before anything is pushed.
|
|
func cmdTarget(args []string) error {
|
|
fs := flag.NewFlagSet("target", flag.ContinueOnError)
|
|
repository := fs.String("repository", "", "shared add-on registry and repository")
|
|
addon := fs.String("addon", "", "add-on id")
|
|
tag := fs.String("tag", "", "immutable version or commit tag, or a channel alias")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
repo, err := parseRepository(*repository)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *tag == "" {
|
|
return errors.New("--tag is required")
|
|
}
|
|
regTag, err := addonTag(*addon, *tag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, _ = fmt.Fprintf(stdout, "target=%s\n", repo.Tag(regTag))
|
|
return nil
|
|
}
|