github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/api/client/charms/localcharmclient.go (about)

     1  // Copyright 2023 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package charms
     5  
     6  import (
     7  	"archive/zip"
     8  	"context"
     9  	"crypto/sha256"
    10  	"encoding/hex"
    11  	"fmt"
    12  	"io"
    13  	"os"
    14  	"strings"
    15  
    16  	"github.com/juju/charm/v12"
    17  	"github.com/juju/errors"
    18  	"github.com/juju/version/v2"
    19  
    20  	"github.com/juju/juju/api/base"
    21  	"github.com/juju/juju/core/lxdprofile"
    22  	jujuversion "github.com/juju/juju/version"
    23  )
    24  
    25  // LocalCharmClient allows access to the API endpoints
    26  // required to add a local charm
    27  type LocalCharmClient struct {
    28  	base.ClientFacade
    29  	facade      base.FacadeCaller
    30  	charmPutter CharmPutter
    31  }
    32  
    33  func NewLocalCharmClient(st base.APICallCloser) (*LocalCharmClient, error) {
    34  	httpPutter, err := newHTTPPutter(st)
    35  	if err != nil {
    36  		return nil, errors.Trace(err)
    37  	}
    38  	s3Putter, err := newS3Putter(st)
    39  	if err != nil {
    40  		return nil, errors.Trace(err)
    41  	}
    42  	fallbackPutter, err := newFallbackPutter(s3Putter, httpPutter)
    43  	if err != nil {
    44  		return nil, errors.Trace(err)
    45  	}
    46  	frontend, backend := base.NewClientFacade(st, "Charms")
    47  	return &LocalCharmClient{ClientFacade: frontend, facade: backend, charmPutter: fallbackPutter}, nil
    48  }
    49  
    50  // AddLocalCharm prepares the given charm with a local: schema in its
    51  // URL, and uploads it via the API server, returning the assigned
    52  // charm URL.
    53  func (c *LocalCharmClient) AddLocalCharm(curl *charm.URL, ch charm.Charm, force bool, agentVersion version.Number) (*charm.URL, error) {
    54  	if curl.Schema != "local" {
    55  		return nil, errors.Errorf("expected charm URL with local: schema, got %q", curl.String())
    56  	}
    57  
    58  	if err := c.validateCharmVersion(ch, agentVersion); err != nil {
    59  		return nil, errors.Trace(err)
    60  	}
    61  	if err := lxdprofile.ValidateLXDProfile(lxdCharmProfiler{Charm: ch}); err != nil {
    62  		if !force {
    63  			return nil, errors.Trace(err)
    64  		}
    65  	}
    66  
    67  	// Package the charm for uploading.
    68  	var archive *os.File
    69  	switch ch := ch.(type) {
    70  	case *charm.CharmDir:
    71  		var err error
    72  		if archive, err = os.CreateTemp("", "charm"); err != nil {
    73  			return nil, errors.Annotate(err, "cannot create temp file")
    74  		}
    75  		defer func() {
    76  			_ = archive.Close()
    77  			_ = os.Remove(archive.Name())
    78  		}()
    79  
    80  		if err := ch.ArchiveTo(archive); err != nil {
    81  			return nil, errors.Annotate(err, "cannot repackage charm")
    82  		}
    83  		if _, err := archive.Seek(0, os.SEEK_SET); err != nil {
    84  			return nil, errors.Annotate(err, "cannot rewind packaged charm")
    85  		}
    86  	case *charm.CharmArchive:
    87  		var err error
    88  		if archive, err = os.Open(ch.Path); err != nil {
    89  			return nil, errors.Annotate(err, "cannot read charm archive")
    90  		}
    91  		defer archive.Close()
    92  	default:
    93  		return nil, errors.Errorf("unknown charm type %T", ch)
    94  	}
    95  
    96  	anyHooksOrDispatch, err := hasHooksOrDispatch(archive.Name())
    97  	if err != nil {
    98  		return nil, errors.Trace(err)
    99  	}
   100  	if !anyHooksOrDispatch {
   101  		return nil, errors.Errorf("invalid charm %q: has no hooks nor dispatch file", curl.Name)
   102  	}
   103  
   104  	hash, err := hashArchive(archive)
   105  	if err != nil {
   106  		return nil, errors.Trace(err)
   107  	}
   108  	charmRef := fmt.Sprintf("%s-%s", curl.Name, hash)
   109  
   110  	modelTag, _ := c.facade.RawAPICaller().ModelTag()
   111  
   112  	newCurlStr, err := c.charmPutter.PutCharm(context.Background(), modelTag.Id(), charmRef, curl.String(), archive)
   113  	if err != nil {
   114  		return nil, errors.Trace(err)
   115  	}
   116  	newCurl, err := charm.ParseURL(newCurlStr)
   117  	if err != nil {
   118  		return nil, errors.Trace(err)
   119  	}
   120  	return newCurl, nil
   121  }
   122  
   123  // lxdCharmProfiler massages a charm.Charm into a LXDProfiler inside of the
   124  // core package.
   125  type lxdCharmProfiler struct {
   126  	Charm charm.Charm
   127  }
   128  
   129  // LXDProfile implements core.lxdprofile.LXDProfiler
   130  func (p lxdCharmProfiler) LXDProfile() lxdprofile.LXDProfile {
   131  	if p.Charm == nil {
   132  		return nil
   133  	}
   134  	if profiler, ok := p.Charm.(charm.LXDProfiler); ok {
   135  		profile := profiler.LXDProfile()
   136  		if profile == nil {
   137  			return nil
   138  		}
   139  		return profile
   140  	}
   141  	return nil
   142  }
   143  
   144  var hasHooksOrDispatch = hasHooksFolderOrDispatchFile
   145  
   146  func hasHooksFolderOrDispatchFile(name string) (bool, error) {
   147  	zipr, err := zip.OpenReader(name)
   148  	if err != nil {
   149  		return false, err
   150  	}
   151  	defer zipr.Close()
   152  	count := 0
   153  	// zip file spec 4.4.17.1 says that separators are always "/" even on Windows.
   154  	hooksPath := "hooks/"
   155  	dispatchPath := "dispatch"
   156  	for _, f := range zipr.File {
   157  		if strings.Contains(f.Name, hooksPath) {
   158  			count++
   159  		}
   160  		if count > 1 {
   161  			// 1 is the magic number here.
   162  			// Charm zip archive is expected to contain several files and folders.
   163  			// All properly built charms will have a non-empty "hooks" folders OR
   164  			// a dispatch file.
   165  			// File names in the archive will be of the form "hooks/" - for hooks folder; and
   166  			// "hooks/*" for the actual charm hooks implementations.
   167  			// For example, install hook may have a file with a name "hooks/install".
   168  			// Once we know that there are, at least, 2 files that have names that start with "hooks/", we
   169  			// know for sure that the charm has a non-empty hooks folder.
   170  			return true, nil
   171  		}
   172  		if strings.Contains(f.Name, dispatchPath) {
   173  			return true, nil
   174  		}
   175  	}
   176  	return false, nil
   177  }
   178  
   179  func (c *LocalCharmClient) validateCharmVersion(ch charm.Charm, agentVersion version.Number) error {
   180  	minver := ch.Meta().MinJujuVersion
   181  	if minver != version.Zero {
   182  		return jujuversion.CheckJujuMinVersion(minver, agentVersion)
   183  	}
   184  	return nil
   185  }
   186  
   187  func hashArchive(archive *os.File) (string, error) {
   188  	hash := sha256.New()
   189  	_, err := io.Copy(hash, archive)
   190  	if err != nil {
   191  		return "", errors.Trace(err)
   192  	}
   193  	_, err = archive.Seek(0, os.SEEK_SET)
   194  	if err != nil {
   195  		return "", errors.Trace(err)
   196  	}
   197  	return hex.EncodeToString(hash.Sum(nil))[0:7], nil
   198  }