Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Agent] Enable post install hooks #17241

Merged
merged 17 commits into from
Apr 1, 2020
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/eclipse/paho.mqtt.golang v1.2.1-0.20200121105743-0d940dd29fd2 h1:DW6WrARxK5J+o8uAKCiACi5wy9EK1UzrsCpGBPsKHAA=
github.com/eclipse/paho.mqtt.golang v1.2.1-0.20200121105743-0d940dd29fd2/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts=
github.com/elastic/beats v7.6.1+incompatible h1:4iPVpFr8BSJW2fUVi+/tYXQ4v/IYVx0k2PPLTg8cfks=
github.com/elastic/dhcp v0.0.0-20200227161230-57ec251c7eb3 h1:lnDkqiRFKm0rxdljqrj3lotWinO9+jFmeDXIC4gvIQs=
github.com/elastic/dhcp v0.0.0-20200227161230-57ec251c7eb3/go.mod h1:aPqzac6AYkipvp4hufTyMj5PDIphF3+At8zr7r51xjY=
github.com/elastic/ecs v1.5.0 h1:/VEIBsRU4ecq2+U3RPfKNc6bFyomP6qnthYEcQZu8GU=
Expand Down
1 change: 1 addition & 0 deletions x-pack/agent/CHANGELOG.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@

- Generate index name in a format type-dataset-namespace {pull}16903[16903]
- OS agnostic default configuration {pull}17016[17016]
- Introduced post install hooks {pull}17241[17241]
- Support for config constraints {pull}17112[17112]
13 changes: 7 additions & 6 deletions x-pack/agent/pkg/agent/program/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ var ErrMissingWhen = errors.New("program must define a 'When' expression")
// NOTE: Current spec are build at compile time, we want to revisit that to allow other program
// to register their spec in a secure way.
type Spec struct {
Name string `yaml:"name"`
Cmd string `yaml:"cmd"`
Configurable string `yaml:"configurable"`
Args []string `yaml:"args"`
Rules *transpiler.RuleList `yaml:"rules"`
When string `yaml:"when"`
Name string `yaml:"name"`
Cmd string `yaml:"cmd"`
Configurable string `yaml:"configurable"`
Args []string `yaml:"args"`
Rules *transpiler.RuleList `yaml:"rules"`
PostInstallSteps *transpiler.StepList `yaml:"post_install"`
When string `yaml:"when"`
}

// ReadSpecs reads all the specs that match the provided globbing path.
Expand Down
12 changes: 12 additions & 0 deletions x-pack/agent/pkg/agent/program/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ func TestSerialization(t *testing.T) {
"log",
),
),
PostInstallSteps: transpiler.NewStepList(
transpiler.DeleteFile("d-1", true),
transpiler.MoveFile("m-1", "m-2", false),
),
When: "1 == 1",
}
yml := `name: hello
Expand Down Expand Up @@ -85,6 +89,14 @@ rules:
key: type
values:
- log
post_install:
- delete_file:
path: d-1
fail_on_missing: true
- move_file:
path: m-1
target: m-2
fail_on_missing: false
when: 1 == 1
`
t.Run("serialization", func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion x-pack/agent/pkg/agent/program/supported.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion x-pack/agent/pkg/agent/transpiler/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func (r *RuleList) MarshalYAML() (interface{}, error) {
return doc, nil
}

// UnmarshalYAML unmashal a YAML document into a RuleList.
// UnmarshalYAML unmarshal a YAML document into a RuleList.
func (r *RuleList) UnmarshalYAML(unmarshal func(interface{}) error) error {
var unpackTo []map[string]interface{}

Expand Down
248 changes: 248 additions & 0 deletions x-pack/agent/pkg/agent/transpiler/steps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// 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 transpiler

import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"

"gopkg.in/yaml.v2"
)

// StepList is a container that allow the same tree to be executed on multiple defined Step.
type StepList struct {
Steps []Step
}

// NewStepList returns a new list of rules to be executed.
func NewStepList(steps ...Step) *StepList {
return &StepList{Steps: steps}
}

// Step is an execution step which needs to be run.
type Step interface {
Execute(rootDir string) error
}

// Execute executes a list of steps.
func (r *StepList) Execute(rootDir string) error {
var err error
for _, step := range r.Steps {
err = step.Execute(rootDir)
if err != nil {
return err
}
}

return nil
}

// MarshalYAML marsharl a steps list to YAML.
func (r *StepList) MarshalYAML() (interface{}, error) {
doc := make([]map[string]Step, 0, len(r.Steps))

for _, step := range r.Steps {
var name string
switch step.(type) {
case *DeleteFileStep:
name = "delete_file"
case *MoveFileStep:
name = "move_file"

default:
return nil, fmt.Errorf("unknown rule of type %T", step)
}

subdoc := map[string]Step{
name: step,
}

doc = append(doc, subdoc)
}
return doc, nil
}

// UnmarshalYAML unmarshal a YAML document into a RuleList.
func (r *StepList) UnmarshalYAML(unmarshal func(interface{}) error) error {
var unpackTo []map[string]interface{}

err := unmarshal(&unpackTo)
if err != nil {
return err
}

// NOTE: this is a bit of a hack because I want to make sure
// the unpack strategy stay in the struct implementation and yaml
// doesn't have a RawMessage similar to the JSON package, so partial unpack
// is not possible.
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I this is what I hate the most about the yaml package, there is a way to work around that go-yaml/yaml#13 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can remove my name next to NOTE :)

unpack := func(in interface{}, out interface{}) error {
b, err := yaml.Marshal(in)
if err != nil {
return err
}
return yaml.Unmarshal(b, out)
}

var steps []Step

for _, m := range unpackTo {
ks := keys(m)
if len(ks) > 1 {
return fmt.Errorf("unknown rule identifier, expecting one identifier and received %d", len(ks))
}

name := ks[0]
fields := m[name]

var s Step
switch name {
case "delete_file":
s = &DeleteFileStep{}
case "move_file":
s = &MoveFileStep{}
default:
return fmt.Errorf("unknown rule of type %s", name)
}

if err := unpack(fields, s); err != nil {
return err
}

steps = append(steps, s)
}
r.Steps = steps
return nil
}

// DeleteFileStep removes a file from disk.
type DeleteFileStep struct {
Path string
// FailOnMissing fails if file is already missing
FailOnMissing bool `yaml:"fail_on_missing" config:"fail_on_missing"`
}

// Execute executes delete file step.
func (r *DeleteFileStep) Execute(rootDir string) error {
path, isSubpath := joinPaths(rootDir, r.Path)
if !isSubpath {
return fmt.Errorf("invalid path value for operation 'Delete': %s", path)
}

err := os.Remove(path)
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably want to add some defensive programming here, even if we control the spec, we never know when this will not be the case. I think we should convert the Filepath.Join() into an absolute path and make sure we are in the "rootdir" and not somewhere else.

I believe this is the case, we want to sandbox every command into a specific directly. I assume all our commands operate in the extracted directory.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is something i was thinking about but did not want to sound too paraniod

Copy link
Contributor

Choose a reason for hiding this comment

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

Better to be paranoid than sorry :)


if os.IsNotExist(err) && r.FailOnMissing {
// is not found and should be reported
return err
}

if err != nil && !os.IsNotExist(err) {
// report others
return err
}

return nil
}

// DeleteFile creates a DeleteFileStep
func DeleteFile(path string, failOnMissing bool) *DeleteFileStep {
return &DeleteFileStep{
Path: path,
FailOnMissing: failOnMissing,
}
}

// MoveFileStep moves a file to a new location.
type MoveFileStep struct {
Path string
Target string
// FailOnMissing fails if file is already missing
FailOnMissing bool `yaml:"fail_on_missing" config:"fail_on_missing"`
}

// Execute executes move file step.
func (r *MoveFileStep) Execute(rootDir string) error {
path, isSubpath := joinPaths(rootDir, r.Path)
if !isSubpath {
return fmt.Errorf("invalid path value for operation 'Move': %s", path)
}

target, isSubpath := joinPaths(rootDir, r.Target)
if !isSubpath {
return fmt.Errorf("invalid target value for operation 'Move': %s", target)
}

err := os.Rename(path, target)

if os.IsNotExist(err) && r.FailOnMissing {
// is not found and should be reported
return err
}

if err != nil && !os.IsNotExist(err) {
// report others
return err
}

return nil
}

// MoveFile creates a MoveFileStep
func MoveFile(path, target string, failOnMissing bool) *MoveFileStep {
return &MoveFileStep{
Path: path,
Target: target,
FailOnMissing: failOnMissing,
}
}

// joinPaths joins paths and returns true if path is subpath of rootDir
func joinPaths(rootDir, path string) (string, bool) {
if !filepath.IsAbs(path) {
path = filepath.Join(rootDir, path)
}

absRoot := filepath.Clean(filepath.FromSlash(rootDir))
absPath := filepath.Clean(filepath.FromSlash(path))

// path on windows are case insensitive
if !isFsCaseSensitive(rootDir) {
absRoot = strings.ToLower(absRoot)
absPath = strings.ToLower(absPath)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This is also true on macOS, by default HFS+ is case insensitive. So the following will work.

mkdir hello
cd HelLO

Copy link
Contributor

Choose a reason for hiding this comment

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

I've been bitten so many times on weird git issues with case insensitive filesystem, the things is if you turn on the case sensitivity on HFS+ it will make many things fails.


return absPath, strings.HasPrefix(absPath, absRoot)
}

func isFsCaseSensitive(rootDir string) bool {
defaultCaseSens := runtime.GOOS != "windows" && runtime.GOOS != "darwin"

dir := filepath.Dir(rootDir)
base := filepath.Base(rootDir)
// if rootdir not exist create it
if _, err := os.Stat(rootDir); os.IsNotExist(err) {
os.MkdirAll(rootDir, 0775)
defer os.RemoveAll(rootDir)
}

lowDir := filepath.Join(base, strings.ToLower(dir))
upDir := filepath.Join(base, strings.ToUpper(dir))

if _, err := os.Stat(rootDir); err != nil {
return defaultCaseSens
}

// check lower/upper dir
if _, lowErr := os.Stat(lowDir); os.IsNotExist(lowErr) {
return true
}
if _, upErr := os.Stat(upDir); os.IsNotExist(upErr) {
return true
}

return defaultCaseSens
}
66 changes: 66 additions & 0 deletions x-pack/agent/pkg/agent/transpiler/steps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// 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 transpiler

import (
"fmt"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
)

func TestIsSubpath(t *testing.T) {
testCases := map[string][]struct {
root string
path string
resultPath string
isSubpath bool
}{
"linux": {
{"/", "a", "/a", true},
{"/a", "b", "/a/b", true},
{"/a", "b/c", "/a/b/c", true},
{"/a/b", "/a/c", "/a/c", false},
{"/a/b", "/a/b/../c", "/a/c", false},
{"/a/b", "../c", "/a/c", false},
{"/a", "/a/b/c", "/a/b/c", true},
{"/a", "/A/b/c", "/A/b/c", false},
},
"darwin": {
{"/", "a", "/a", true},
{"/a", "b", "/a/b", true},
{"/a", "b/c", "/a/b/c", true},
{"/a/b", "/a/c", "/a/c", false},
{"/a/b", "/a/b/../c", "/a/c", false},
{"/a/b", "../c", "/a/c", false},
{"/a", "/a/b/c", "/a/b/c", true},
{"/a", "/A/b/c", "/a/b/c", true},
},
"windows": {
{"/", "a", "\\a", true},
{"/a", "b", "\\a\\b", true},
{"/a", "b/c", "\\a\\b\\c", true},
{"/a/b", "/a/c", "\\a\\c", false},
{"/a/b", "/a/b/../c", "\\a\\c", false},
{"/a/b", "../c", "\\a\\c", false},
{"/a", "/a/b/c", "\\a\\b\\c", true},
{"/a", "/A/b/c", "\\a\\b\\c", true},
},
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add tests for the case sensitivity on windows/darwin?


osSpecificTests, found := testCases[runtime.GOOS]
if !found {
return
}

for _, test := range osSpecificTests {
t.Run(fmt.Sprintf("[%s]'%s-%s'", runtime.GOOS, test.root, test.path), func(t *testing.T) {
newPath, result := joinPaths(test.root, test.path)
assert.Equal(t, test.resultPath, newPath)
assert.Equal(t, test.isSubpath, result)
})
}
}
Loading