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 }