Skip to content
Merged
31 changes: 27 additions & 4 deletions internal/kibana/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,32 @@ func (c *Client) RemovePackage(ctx context.Context, name, version string) ([]pac

// FleetPackage contains information about a package in Fleet.
type FleetPackage struct {
Name string `json:"string"`
Type string `json:"type"`
Status string `json:"status"`
Name string `json:"name"`
Version string `json:"version"`
Type string `json:"type"`
Status string `json:"status"`
SavedObject struct {
Attributes struct {
InstalledElasticsearchAssets []packages.Asset `json:"installed_es"`
InstalledKibanaAssets []packages.Asset `json:"installed_kibana"`
PackageAssets []packages.Asset `json:"package_assets"`
} `json:"attributes"`
} `json:"savedObject"`
}

func (p *FleetPackage) Assets() []packages.Asset {
var assets []packages.Asset
assets = append(assets, p.SavedObject.Attributes.InstalledElasticsearchAssets...)
assets = append(assets, p.SavedObject.Attributes.InstalledKibanaAssets...)
return assets
}

type ErrPackageNotFound struct {
name string
}

func (e *ErrPackageNotFound) Error() string {
return fmt.Sprintf("package %s not found", e.name)
}

// GetPackage obtains information about a package from Fleet.
Expand All @@ -78,7 +101,7 @@ func (c *Client) GetPackage(ctx context.Context, name string) (*FleetPackage, er
return nil, fmt.Errorf("could not get package: %w", err)
}
if statusCode == http.StatusNotFound {
return nil, fmt.Errorf("package %s not found", name)
return nil, &ErrPackageNotFound{name: name}
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("could not get package; API status code = %d; response body = %s", statusCode, string(respBody))
Expand Down
185 changes: 185 additions & 0 deletions internal/resources/fleetpackage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package resources

import (
"context"
"errors"
"fmt"

"github.com/Masterminds/semver/v3"
"github.com/elastic/go-resource"

"github.com/elastic/elastic-package/internal/kibana"
"github.com/elastic/elastic-package/internal/packages"
"github.com/elastic/elastic-package/internal/packages/installer"
)

type FleetPackage struct {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct allows us to isolate the logic for the state of a package. It knows how to get the current state and update to the desired one. Having it as an independent object allows to test the logic more easily.

// Provider is the name of the provider to use, defaults to "kibana".
Provider string

// RootPath is the root of the package source to install.
RootPath string

// Absent is set to true to indicate that the package should not be installed.
Absent bool

// Force forces operations, as reinstalling a package that seems to
// be already installed.
Force bool
}

func (f *FleetPackage) String() string {
return fmt.Sprintf("[FleetPackage:%s:%s]", f.Provider, f.RootPath)
}

func (f *FleetPackage) provider(ctx resource.Context) (*KibanaProvider, error) {
name := f.Provider
if name == "" {
name = DefaultKibanaProviderName
}
var provider *KibanaProvider
ok := ctx.Provider(name, &provider)
if !ok {
return nil, fmt.Errorf("provider %q must be explicitly defined", name)
}
return provider, nil
}

func (f *FleetPackage) installer(ctx resource.Context) (installer.Installer, error) {
provider, err := f.provider(ctx)
if err != nil {
return nil, err
}

return installer.NewForPackage(installer.Options{
Kibana: provider.Client,
RootPath: f.RootPath,
SkipValidation: true,
})
}

func (f *FleetPackage) Get(ctx resource.Context) (current resource.ResourceState, err error) {
provider, err := f.provider(ctx)
if err != nil {
return nil, err
}

manifest, err := packages.ReadPackageManifestFromPackageRoot(f.RootPath)
if err != nil {
return nil, fmt.Errorf("failed to read manifest from %s: %w", f.RootPath, err)
}

fleetPackage, err := provider.Client.GetPackage(ctx, manifest.Name)
var notFoundError *kibana.ErrPackageNotFound
if errors.As(err, &notFoundError) {
fleetPackage = &kibana.FleetPackage{
Name: manifest.Name,
Status: "not_installed",
}
} else if err != nil {
return nil, fmt.Errorf("failed to get current installation state for package %q: %w", manifest.Name, err)
}

kibanaVersion, err := provider.version()
if err != nil {
return nil, fmt.Errorf("failed to get current kibana version: %w", err)
}

return &FleetPackageState{
manifest: manifest,
current: fleetPackage,
expected: !f.Absent,
kibanaVersion: kibanaVersion,
}, nil
}

func (f *FleetPackage) Create(ctx resource.Context) error {
installer, err := f.installer(ctx)
if err != nil {
return err
}

_, err = installer.Install(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
provider, uninstallErr := f.provider(ctx)
if uninstallErr != nil {
return fmt.Errorf("failed to get client (%w) after installation failed: %w", uninstallErr, err)
}

// Using uninstallPachage instead of f.uninstall because we want to pass a context without cancellation.
uninstallErr = uninstallPackage(context.WithoutCancel(ctx), provider.Client, f.RootPath)
if uninstallErr != nil {
return fmt.Errorf("failed to uninstall package (%w) after installation failed: %w", uninstallErr, err)
}
}
return fmt.Errorf("installation failed: %w", err)
}

return nil
}

func (f *FleetPackage) uninstall(ctx resource.Context) error {
provider, err := f.provider(ctx)
if err != nil {
return err
}

return uninstallPackage(ctx, provider.Client, f.RootPath)
}

func uninstallPackage(ctx context.Context, client *kibana.Client, rootPath string) error {
manifest, err := packages.ReadPackageManifestFromPackageRoot(rootPath)
if err != nil {
return fmt.Errorf("failed to read manifest from %s: %w", rootPath, err)
}

_, err = client.RemovePackage(ctx, manifest.Name, manifest.Version)
if err != nil {
return fmt.Errorf("can't remove the package: %w", err)
}
return nil
}

func (f *FleetPackage) Update(ctx resource.Context) error {
if f.Absent {
return f.uninstall(ctx)
}

return f.Create(ctx)
}

type FleetPackageState struct {
manifest *packages.PackageManifest
current *kibana.FleetPackage
expected bool
kibanaVersion *semver.Version
}

func (s *FleetPackageState) Found() bool {
return !s.expected || (s.current != nil && s.current.Status != "not_installed")
}

func (s *FleetPackageState) NeedsUpdate(resource resource.Resource) (bool, error) {
fleetPackage := resource.(*FleetPackage)
if fleetPackage.Absent {
if s.current.Status == "not_installed" {
return fleetPackage.Force, nil
}
if s.manifest.Name == "system" && s.kibanaVersion.LessThan(semver.MustParse("8.0.0")) {
// in Elastic stack 7.* , system package is installed in the default Agent policy and it cannot be deleted
// error: system is installed by default and cannot be removed
return false, nil
}
} else {
if s.current.Status == "installed" && s.current.Version == s.manifest.Version {
return fleetPackage.Force, nil
}
}

return true, nil
}
103 changes: 103 additions & 0 deletions internal/resources/fleetpackage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package resources

import (
"context"
"errors"
"fmt"
"path/filepath"
"testing"

"github.com/elastic/go-resource"
"github.com/stretchr/testify/assert"

"github.com/elastic/elastic-package/internal/kibana"
kibanatest "github.com/elastic/elastic-package/internal/kibana/test"
)

func TestRequiredProvider(t *testing.T) {
manager := resource.NewManager()
_, err := manager.Apply(resource.Resources{
&FleetPackage{
RootPath: "../../test/packages/parallel/nginx",
},
})
if assert.Error(t, err) {
assert.Contains(t, err.Error(), fmt.Sprintf("provider %q must be explicitly defined", DefaultKibanaProviderName))
}
}

func TestPackageLifecycle(t *testing.T) {
cases := []struct {
title string
name string
}{
{title: "nginx", name: "nginx"},
{title: "package not found", name: "sql_input"},
}

for _, c := range cases {
t.Run(c.title, func(t *testing.T) {
recordPath := filepath.Join("testdata", "kibana-8-mock-package-lifecycle-"+c.name)
kibanaClient := kibanatest.NewClient(t, recordPath)
if !assertPackageInstalled(t, kibanaClient, "not_installed", c.name) {
t.FailNow()
}

fleetPackage := FleetPackage{
RootPath: filepath.Join("..", "..", "test", "packages", "parallel", c.name),
}
manager := resource.NewManager()
manager.RegisterProvider(DefaultKibanaProviderName, &KibanaProvider{Client: kibanaClient})
_, err := manager.Apply(resource.Resources{&fleetPackage})
assert.NoError(t, err)
assertPackageInstalled(t, kibanaClient, "installed", c.name)

fleetPackage.Absent = true
_, err = manager.Apply(resource.Resources{&fleetPackage})
assert.NoError(t, err)
assertPackageInstalled(t, kibanaClient, "not_installed", c.name)
})
}
}

func TestSystemPackageIsNotRemoved(t *testing.T) {
kibanaClient := kibanatest.NewClient(t, "testdata/kibana-7-mock-system-package-is-not-removed")
if !assertPackageInstalled(t, kibanaClient, "installed", "system") {
t.FailNow()
}

fleetPackage := FleetPackage{
RootPath: "../../test/packages/parallel/system",
Absent: true,
}
manager := resource.NewManager()
manager.RegisterProvider(DefaultKibanaProviderName, &KibanaProvider{Client: kibanaClient})

// Try to uninstall the package, it should not be installed.
_, err := manager.Apply(resource.Resources{&fleetPackage})
assert.NoError(t, err)
assertPackageInstalled(t, kibanaClient, "installed", "system")

// Try to force-uninstall the package, it should neither be uninstalled.
fleetPackage.Force = true
_, err = manager.Apply(resource.Resources{&fleetPackage})
assert.NoError(t, err)
assertPackageInstalled(t, kibanaClient, "installed", "system")
}

func assertPackageInstalled(t *testing.T, client *kibana.Client, expected string, packageName string) bool {
t.Helper()

p, err := client.GetPackage(context.Background(), packageName)
var notFoundError *kibana.ErrPackageNotFound
if errors.As(err, &notFoundError) {
return assert.Equal(t, expected, "not_installed")
} else if !assert.NoError(t, err) {
return false
}
return assert.Equal(t, expected, p.Status)
}
31 changes: 31 additions & 0 deletions internal/resources/kibana.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

package resources

import (
"fmt"

"github.com/Masterminds/semver/v3"

"github.com/elastic/elastic-package/internal/kibana"
)

const DefaultKibanaProviderName = "kibana"

type KibanaProvider struct {
Client *kibana.Client
}

func (p *KibanaProvider) version() (*semver.Version, error) {
kibanaVersion, err := p.Client.Version()
if err != nil {
return nil, fmt.Errorf("failed to retrieve kibana version: %w", err)
}
stackVersion, err := semver.NewVersion(kibanaVersion.Version())
if err != nil {
return nil, fmt.Errorf("failed to parse kibana version: %w", err)
}
return stackVersion, nil
}
17 changes: 17 additions & 0 deletions internal/resources/manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License;
// you may not use this file except in compliance with the Elastic License.

// This file contains helpers so we don't need to import go-resource out of this package.

package resources

import "github.com/elastic/go-resource"

type Resources = resource.Resources

type Manager = resource.Manager

func NewManager() *Manager {
return resource.NewManager()
}
Loading