blob: 94c1e2dd4a8ea1576216851eded96a1afc677442 [file] [log] [blame]
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +04001package installer
2
3import (
4 "context"
5 _ "embed"
6 "fmt"
7 "log"
8 "net/netip"
9 "path/filepath"
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040010 "strings"
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040011 "time"
12
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040013 "helm.sh/helm/v3/pkg/action"
14 "helm.sh/helm/v3/pkg/chart"
15 "helm.sh/helm/v3/pkg/chart/loader"
16
17 "github.com/giolekva/pcloud/core/installer/soft"
18)
19
20const IPAddressPoolLocal = "local"
21const IPAddressPoolConfigRepo = "config-repo"
22const IPAddressPoolIngressPublic = "ingress-public"
23
Giorgi Lekveishvili5c2c0b92023-12-07 17:35:40 +040024const dnsAPIConfigMapName = "api-config"
25
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040026type Bootstrapper struct {
27 cl ChartLoader
28 ns NamespaceCreator
29 ha HelmActionConfigFactory
30}
31
32func NewBootstrapper(cl ChartLoader, ns NamespaceCreator, ha HelmActionConfigFactory) Bootstrapper {
33 return Bootstrapper{cl, ns, ha}
34}
35
36func (b Bootstrapper) Run(env EnvConfig) error {
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +040037 if err := b.ns.Create(env.Name); err != nil {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040038 return err
39 }
40 if err := b.installMetallb(env); err != nil {
41 return err
42 }
43 if err := b.installLonghorn(env.Name, env.StorageDir, env.VolumeDefaultReplicaCount); err != nil {
44 return err
45 }
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +040046 bootstrapJobKeys, err := NewSSHKeyPair("bootstrapper")
47 if err != nil {
48 return err
49 }
50 if err := b.installSoftServe(bootstrapJobKeys.AuthorizedKey(), env.Name, env.ServiceIPs.ConfigRepo); err != nil {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040051 return err
52 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040053 ss, err := soft.WaitForClient(
54 netip.AddrPortFrom(env.ServiceIPs.ConfigRepo, 22).String(),
55 bootstrapJobKeys.RawPrivateKey(),
56 log.Default())
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040057 if err != nil {
58 return err
59 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040060 defer func() {
61 if ss.RemovePublicKey("admin", bootstrapJobKeys.AuthorizedKey()); err != nil {
62 fmt.Printf("Failed to remove admin public key: %s\n", err.Error())
63 }
64 }()
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040065 if ss.AddPublicKey("admin", string(env.AdminPublicKey)); err != nil {
66 return err
67 }
68 if err := b.installFluxcd(ss, env.Name); err != nil {
69 return err
70 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040071 fmt.Println("Fluxcd installed")
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +040072 repo, err := ss.GetRepo("config")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040073 if err != nil {
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040074 fmt.Println("Failed to get config repo")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040075 return err
76 }
77 repoIO := NewRepoIO(repo, ss.Signer)
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040078 fmt.Println("Configuring main repo")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040079 if err := configureMainRepo(repoIO, env); err != nil {
80 return err
81 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040082 fmt.Println("Installing infrastructure services")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040083 nsGen := NewPrefixGenerator(env.NamespacePrefix)
84 if err := b.installInfrastructureServices(repoIO, nsGen, b.ns, env); err != nil {
85 return err
86 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040087 fmt.Println("Installing DNS Zone Manager")
88 if err := b.installDNSZoneManager(ss, repoIO, nsGen, b.ns, env); err != nil {
89 return err
90 }
91 fmt.Println("Installing env manager")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040092 if err := b.installEnvManager(ss, repoIO, nsGen, b.ns, env); err != nil {
93 return err
94 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +040095 fmt.Println("Environment ready to use")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +040096 return nil
97}
98
99func (b Bootstrapper) installMetallb(env EnvConfig) error {
100 if err := b.installMetallbNamespace(env); err != nil {
101 return err
102 }
103 if err := b.installMetallbService(); err != nil {
104 return err
105 }
106 if err := b.installMetallbIPAddressPool(IPAddressPoolLocal, true, env.ServiceIPs.From, env.ServiceIPs.To); err != nil {
107 return err
108 }
109 if err := b.installMetallbIPAddressPool(IPAddressPoolConfigRepo, false, env.ServiceIPs.ConfigRepo, env.ServiceIPs.ConfigRepo); err != nil {
110 return err
111 }
112 if err := b.installMetallbIPAddressPool(IPAddressPoolIngressPublic, false, env.ServiceIPs.IngressPublic, env.ServiceIPs.IngressPublic); err != nil {
113 return err
114 }
115 return nil
116}
117
118func (b Bootstrapper) installMetallbNamespace(env EnvConfig) error {
119 fmt.Println("Installing metallb namespace")
120 config, err := b.ha.New(env.Name)
121 if err != nil {
122 return err
123 }
124 chart, err := b.cl.Load("namespace")
125 if err != nil {
126 return err
127 }
128 values := map[string]any{
129 "namespace": "metallb-system",
130 "labels": []string{
131 "pod-security.kubernetes.io/audit: privileged",
132 "pod-security.kubernetes.io/enforce: privileged",
133 "pod-security.kubernetes.io/warn: privileged",
134 },
135 }
136 installer := action.NewInstall(config)
137 installer.Namespace = env.Name
138 installer.ReleaseName = "metallb-ns"
139 installer.Wait = true
140 installer.WaitForJobs = true
141 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
142 return err
143 }
144 return nil
145}
146
147func (b Bootstrapper) installMetallbService() error {
148 fmt.Println("Installing metallb")
149 config, err := b.ha.New("metallb-system")
150 if err != nil {
151 return err
152 }
153 chart, err := b.cl.Load("metallb")
154 if err != nil {
155 return err
156 }
157 values := map[string]any{ // TODO(giolekva): add loadBalancerClass?
158 "controller": map[string]any{
159 "image": map[string]any{
160 "repository": "quay.io/metallb/controller",
Giorgi Lekveishvilic06164d2023-11-22 13:51:29 +0400161 "tag": "v0.13.12",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400162 "pullPolicy": "IfNotPresent",
163 },
164 "logLevel": "info",
165 },
166 "speaker": map[string]any{
167 "image": map[string]any{
168 "repository": "quay.io/metallb/speaker",
Giorgi Lekveishvilic06164d2023-11-22 13:51:29 +0400169 "tag": "v0.13.12",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400170 "pullPolicy": "IfNotPresent",
171 },
172 "logLevel": "info",
173 },
174 }
175 installer := action.NewInstall(config)
176 installer.Namespace = "metallb-system"
177 installer.CreateNamespace = true
178 installer.ReleaseName = "metallb"
179 installer.IncludeCRDs = true
180 installer.Wait = true
181 installer.WaitForJobs = true
182 installer.Timeout = 20 * time.Minute
183 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
184 return err
185 }
186 return nil
187}
188
189func (b Bootstrapper) installMetallbIPAddressPool(name string, autoAssign bool, from, to netip.Addr) error {
190 fmt.Printf("Installing metallb-ipaddresspool: %s\n", name)
191 config, err := b.ha.New("metallb-system")
192 if err != nil {
193 return err
194 }
195 chart, err := b.cl.Load("metallb-ipaddresspool")
196 if err != nil {
197 return err
198 }
199 values := map[string]any{
200 "name": name,
201 "autoAssign": autoAssign,
202 "from": from.String(),
203 "to": to.String(),
204 }
205 installer := action.NewInstall(config)
206 installer.Namespace = "metallb-system"
207 installer.CreateNamespace = true
208 installer.ReleaseName = name
209 installer.Wait = true
210 installer.WaitForJobs = true
211 installer.Timeout = 20 * time.Minute
212 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
213 return err
214 }
215 return nil
216}
217
218func (b Bootstrapper) installLonghorn(envName string, storageDir string, volumeDefaultReplicaCount int) error {
219 fmt.Println("Installing Longhorn")
220 config, err := b.ha.New(envName)
221 if err != nil {
222 return err
223 }
224 chart, err := b.cl.Load("longhorn")
225 if err != nil {
226 return err
227 }
228 values := map[string]any{
229 "defaultSettings": map[string]any{
230 "defaultDataPath": storageDir,
231 },
232 "persistence": map[string]any{
233 "defaultClassReplicaCount": volumeDefaultReplicaCount,
234 },
235 "service": map[string]any{
236 "ui": map[string]any{
237 "type": "LoadBalancer",
238 },
239 },
240 "ingress": map[string]any{
241 "enabled": false,
242 },
243 }
244 installer := action.NewInstall(config)
245 installer.Namespace = "longhorn-system"
246 installer.CreateNamespace = true
247 installer.ReleaseName = "longhorn"
248 installer.Wait = true
249 installer.WaitForJobs = true
250 installer.Timeout = 20 * time.Minute
251 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
252 return err
253 }
254 return nil
255}
256
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400257func (b Bootstrapper) installSoftServe(adminPublicKey string, namespace string, repoIP netip.Addr) error {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400258 fmt.Println("Installing SoftServe")
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400259 keys, err := NewSSHKeyPair("soft-serve")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400260 if err != nil {
261 return err
262 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400263 config, err := b.ha.New(namespace)
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400264 if err != nil {
265 return err
266 }
267 chart, err := b.cl.Load("soft-serve")
268 if err != nil {
269 return err
270 }
271 values := map[string]any{
272 "image": map[string]any{
273 "repository": "charmcli/soft-serve",
Giorgi Lekveishvilic06164d2023-11-22 13:51:29 +0400274 "tag": "v0.7.1",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400275 "pullPolicy": "IfNotPresent",
276 },
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400277 "privateKey": string(keys.RawPrivateKey()),
278 "publicKey": string(keys.RawAuthorizedKey()),
279 "adminKey": adminPublicKey,
280 "reservedIP": repoIP.String(),
281 "serviceType": "LoadBalancer",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400282 }
283 installer := action.NewInstall(config)
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400284 installer.Namespace = namespace
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400285 installer.CreateNamespace = true
286 installer.ReleaseName = "soft-serve"
287 installer.Wait = true
288 installer.WaitForJobs = true
289 installer.Timeout = 20 * time.Minute
290 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
291 return err
292 }
293 return nil
294}
295
296func (b Bootstrapper) installFluxcd(ss *soft.Client, envName string) error {
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400297 keys, err := NewSSHKeyPair("fluxcd")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400298 if err != nil {
299 return err
300 }
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400301 if err := ss.AddUser("flux", keys.AuthorizedKey()); err != nil {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400302 return err
303 }
304 if err := ss.MakeUserAdmin("flux"); err != nil {
305 return err
306 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400307 if err := ss.AddRepository("config"); err != nil {
308 return err
309 }
310 repo, err := ss.GetRepo("config")
311 if err != nil {
312 return err
313 }
314 repoIO := NewRepoIO(repo, ss.Signer)
315 if err := repoIO.WriteCommitAndPush("README.md", fmt.Sprintf("# %s systems", envName), "readme"); err != nil {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400316 return err
317 }
318 fmt.Println("Installing Flux")
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400319 ssPublicKeys, err := ss.GetPublicKeys()
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400320 if err != nil {
321 return err
322 }
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400323 host := strings.Split(ss.Addr, ":")[0]
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400324 if err := b.installFluxBootstrap(
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400325 ss.GetRepoAddress("config"),
326 host,
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400327 ssPublicKeys,
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400328 string(keys.RawPrivateKey()),
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400329 envName,
330 ); err != nil {
331 return err
332 }
333 return nil
334}
335
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400336func (b Bootstrapper) installFluxBootstrap(repoAddr, repoHost string, repoHostPubKeys []string, privateKey, envName string) error {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400337 config, err := b.ha.New(envName)
338 if err != nil {
339 return err
340 }
341 chart, err := b.cl.Load("flux-bootstrap")
342 if err != nil {
343 return err
344 }
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400345 var lines []string
346 for _, k := range repoHostPubKeys {
347 lines = append(lines, fmt.Sprintf("%s %s", repoHost, k))
348 }
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400349 values := map[string]any{
350 "image": map[string]any{
Giorgi Lekveishvilic06164d2023-11-22 13:51:29 +0400351 "repository": "fluxcd/flux-cli",
352 "tag": "v2.1.2",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400353 "pullPolicy": "IfNotPresent",
354 },
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400355 "repositoryAddress": repoAddr,
356 "repositoryHost": repoHost,
357 "repositoryHostPublicKeys": strings.Join(lines, "\n"),
358 "privateKey": privateKey,
359 "installationNamespace": fmt.Sprintf("%s-flux", envName),
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400360 }
361 installer := action.NewInstall(config)
362 installer.Namespace = envName
363 installer.CreateNamespace = true
364 installer.ReleaseName = "flux"
365 installer.Wait = true
366 installer.WaitForJobs = true
367 installer.Timeout = 20 * time.Minute
368 if _, err := installer.RunWithContext(context.TODO(), chart, values); err != nil {
369 return err
370 }
371 return nil
372}
373
374func (b Bootstrapper) installInfrastructureServices(repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
375 appRepo := NewInMemoryAppRepository(CreateAllApps())
376 install := func(name string) error {
377 app, err := appRepo.Find(name)
378 if err != nil {
379 return err
380 }
381 namespaces := make([]string, len(app.Namespaces))
382 for i, n := range app.Namespaces {
383 namespaces[i], err = nsGen.Generate(n)
384 if err != nil {
385 return err
386 }
387 }
388 for _, n := range namespaces {
389 if err := nsCreator.Create(n); err != nil {
390 return err
391 }
392 }
393 derived := Derived{
394 Global: Values{
395 PCloudEnvName: env.Name,
396 },
397 }
398 if len(namespaces) > 0 {
399 derived.Release.Namespace = namespaces[0]
400 }
401 values := map[string]any{
402 "IngressPublicIP": env.ServiceIPs.IngressPublic.String(),
403 }
404 return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), values, derived)
405 }
406 appsToInstall := []string{
407 "resource-renderer-controller",
408 "headscale-controller",
409 "csi-driver-smb",
410 "ingress-public",
411 "cert-manager",
Giorgi Lekveishvili5c2c0b92023-12-07 17:35:40 +0400412 "cert-manager-webhook-pcloud",
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400413 }
414 for _, name := range appsToInstall {
415 if err := install(name); err != nil {
416 return err
417 }
418 }
419 return nil
420}
421
422func configureMainRepo(repo RepoIO, env EnvConfig) error {
423 if err := repo.WriteYaml("config.yaml", env); err != nil {
424 return err
425 }
426 kust := NewKustomization()
427 kust.AddResources(
428 fmt.Sprintf("%s-flux", env.Name),
429 "infrastructure",
430 "environments",
431 )
432 if err := repo.WriteKustomization("kustomization.yaml", kust); err != nil {
433 return err
434 }
435 {
436 out, err := repo.Writer("infrastructure/pcloud-charts.yaml")
437 if err != nil {
438 return err
439 }
440 defer out.Close()
441 _, err = out.Write([]byte(fmt.Sprintf(`
442apiVersion: source.toolkit.fluxcd.io/v1
443kind: GitRepository
444metadata:
445 name: pcloud # TODO(giolekva): use more generic name
446 namespace: %s
447spec:
448 interval: 1m0s
449 url: https://github.com/giolekva/pcloud
450 ref:
451 branch: main
452`, env.Name)))
453 if err != nil {
454 return err
455 }
456 }
457 infraKust := NewKustomization()
458 infraKust.AddResources("pcloud-charts.yaml")
459 if err := repo.WriteKustomization("infrastructure/kustomization.yaml", infraKust); err != nil {
460 return err
461 }
462 if err := repo.WriteKustomization("environments/kustomization.yaml", NewKustomization()); err != nil {
463 return err
464 }
465 if err := repo.CommitAndPush("initialize pcloud directory structure"); err != nil {
466 return err
467 }
468 return nil
469}
470
471func (b Bootstrapper) installEnvManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400472 keys, err := NewSSHKeyPair("env-manager")
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400473 if err != nil {
474 return err
475 }
476 user := fmt.Sprintf("%s-env-manager", env.Name)
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400477 if err := ss.AddUser(user, keys.AuthorizedKey()); err != nil {
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400478 return err
479 }
480 if err := ss.MakeUserAdmin(user); err != nil {
481 return err
482 }
483 appRepo := NewInMemoryAppRepository(CreateAllApps())
484 app, err := appRepo.Find("env-manager")
485 if err != nil {
486 return err
487 }
488 namespaces := make([]string, len(app.Namespaces))
489 for i, n := range app.Namespaces {
490 namespaces[i], err = nsGen.Generate(n)
491 if err != nil {
492 return err
493 }
494 }
495 for _, n := range namespaces {
496 if err := nsCreator.Create(n); err != nil {
497 return err
498 }
499 }
500 derived := Derived{
501 Global: Values{
502 PCloudEnvName: env.Name,
503 },
504 Values: map[string]any{
505 "RepoIP": env.ServiceIPs.ConfigRepo,
506 "RepoPort": 22,
Giorgi Lekveishvili724885f2023-11-29 16:18:42 +0400507 "RepoName": "config",
Giorgi Lekveishvilia1e77902023-11-06 14:48:27 +0400508 "SSHPrivateKey": string(keys.RawPrivateKey()),
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400509 },
510 }
511 if len(namespaces) > 0 {
512 derived.Release.Namespace = namespaces[0]
513 }
514 return repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived)
515}
516
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400517func (b Bootstrapper) installDNSZoneManager(ss *soft.Client, repo RepoIO, nsGen NamespaceGenerator, nsCreator NamespaceCreator, env EnvConfig) error {
518 const (
519 volumeClaimName = "dns-zone-configs"
520 volumeMountPath = "/etc/pcloud/dns-zone-configs"
521 )
522 ns, err := nsGen.Generate("dns-zone-manager")
523 if err != nil {
524 return err
525 }
526 if err := nsCreator.Create(ns); err != nil {
527 return err
528 }
529 appRepo := NewInMemoryAppRepository(CreateAllApps())
530 {
531 app, err := appRepo.Find("dns-zone-manager")
532 if err != nil {
533 return err
534 }
535 derived := Derived{
536 Global: Values{
537 PCloudEnvName: env.Name,
538 },
539 Values: map[string]any{
540 "Volume": map[string]any{
541 "ClaimName": volumeClaimName,
542 "MountPath": volumeMountPath,
543 "Size": "1Gi",
544 },
Giorgi Lekveishvili5c2c0b92023-12-07 17:35:40 +0400545 "APIConfigMapName": dnsAPIConfigMapName,
Giorgi Lekveishvili106a9352023-12-04 11:20:11 +0400546 },
547 Release: Release{
548 Namespace: ns,
549 },
550 }
551 if err := repo.InstallApp(*app, filepath.Join("/infrastructure", app.Name), derived.Values, derived); err != nil {
552 return err
553 }
554 }
555 return nil
556}
557
Giorgi Lekveishvili94cda9d2023-07-20 10:16:09 +0400558type HelmActionConfigFactory interface {
559 New(namespace string) (*action.Configuration, error)
560}
561
562type ChartLoader interface {
563 Load(name string) (*chart.Chart, error)
564}
565
566type fsChartLoader struct {
567 baseDir string
568}
569
570func NewFSChartLoader(baseDir string) ChartLoader {
571 return &fsChartLoader{baseDir}
572}
573
574func (l *fsChartLoader) Load(name string) (*chart.Chart, error) {
575 return loader.Load(filepath.Join(l.baseDir, name))
576}