diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..8636c5b --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,8 @@ +packages: + github.com/fortnoxab/gitmachinecontroller/pkg/agent/command: + interfaces: + # select the interfaces you want mocked + Commander: + config: + outpkg: mocks + dir: ../../mocks diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d08669c --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: test imports +SHELL := /bin/bash + +test: imports + go test -v ./... + +imports: SHELL:=/bin/bash +imports: + go install golang.org/x/tools/cmd/goimports@latest + ASD=$$(goimports -l . 2>&1); test -z "$$ASD" || (echo "Code is not formatted correctly according to goimports! $$ASD" && exit 1) + diff --git a/cmd/gmc/main.go b/cmd/gmc/main.go index 96e5d99..bfc0ba9 100644 --- a/cmd/gmc/main.go +++ b/cmd/gmc/main.go @@ -1,3 +1,5 @@ +//go:generate go install github.com/vektra/mockery/v2@v2.45.1 +//go:generate mockery package main import ( diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..7fb8b42 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,269 @@ +package e2e_test + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/fortnoxab/gitmachinecontroller/pkg/admin" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestMasterAgentAccept(t *testing.T) { + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, closer := initMasterAgent(t, ctx) + defer closer() + + resp, err := c.client.Get("/machines") + assert.NoError(t, err) + body := getBody(t, resp.Body) + assert.Contains(t, body, "acceptHost('mycooltestagent')") + assert.Contains(t, body, ">Accept<") + + _, err = c.client.Post("/api/machines/accept-v1", bytes.NewBufferString(`{"host":"mycooltestagent"}`)) + assert.NoError(t, err) + + time.Sleep(200 * time.Millisecond) + resp, err = c.client.Get("/machines") + assert.NoError(t, err) + body = getBody(t, resp.Body) + assert.NotContains(t, body, ">Accept<") + + resp, err = c.client.Get("/api/machines-v1") + assert.NoError(t, err) + body = getBody(t, resp.Body) + assert.Contains(t, body, `"name":"mycooltestagent"`) + assert.Contains(t, body, `"ip":"127.0.0.1"`) + assert.Contains(t, body, `"Online":true`) + assert.Contains(t, body, `"Accepted":true`) + assert.Contains(t, body, `"Git":false`) + + cancel() + c.wg.Wait() +} +func TestMasterAgentGitOps(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "the file content") + })) + defer ts.Close() + machineYaml := fmt.Sprintf(`apiVersion: gitmachinecontroller.io/v1beta1 +metadata: + annotations: + feature: ihavecoolfeature + labels: + os: rocky9 + type: server + name: mycooltestagent +spec: + commands: [] + files: + - checksum: 9b76e7ea790545334ea524f3ca33db8eb6c4541a9b476911e5abf850a566b41c + path: /tmp/testfromurl + url: %s + - content: | + [Unit] + Description=Mimir Service + After=network.target + + [Service] + Type=simple + User=root + Group=root + ExecStart=/usr/local/sbin/mimir -config.file=/etc/mimir.yml + SuccessExitStatus=0 + TimeoutSec=30 + SyslogIdentifier=mimir + Restart=on-failure + RestartSec=3 + LimitNOFILE=1048576 + + [Install] + WantedBy=multi-user.target + path: /tmp/test.systemd + systemd: + action: restart + name: exporter_exporter + daemonreload: true + ip: 10.81.22.150 + lines: [] + packages: + - name: vim-enhanced + version: '*' + - name: mycoolpackage + version: '*'`, ts.URL) + + err := os.WriteFile("./gitrepo/mycooltestagent.yml", []byte(machineYaml), 0666) + assert.NoError(t, err) + defer os.Remove("./gitrepo/mycooltestagent.yml") + defer os.Remove("/tmp/test.systemd") + defer os.Remove("/tmp/testfromurl") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, closer := initMasterAgent(t, ctx) + defer closer() + + c.commander.Mock.On("Run", "systemctl restart exporter_exporter").Return("", "", nil).Once() + c.commander.Mock.On("Run", "systemctl daemon reload").Return("", "", nil).Once() + + // return 0 means its already installed + c.commander.Mock.On("RunExpectCodes", "rpm -q vim-enhanced", 0, 1).Return("", 0, nil).Twice() + + // return 1 means its not installed yet and we'll try to install it + c.commander.Mock.On("RunExpectCodes", "rpm -q mycoolpackage", 0, 1).Return("", 1, nil).Twice() + c.commander.Mock.On("Run", "rpm -q --whatprovides mycoolpackage").Return("mycoolpackage", "", nil).Twice() + c.commander.Mock.On("Run", "yum install -y mycoolpackage").Return("", "", nil).Twice() + + _, err = c.client.Post("/api/machines/accept-v1", bytes.NewBufferString(`{"host":"mycooltestagent"}`)) + assert.NoError(t, err) + + time.Sleep(2 * time.Second) + + // make sure we fetched file from http server with sha256 hash + content, err := os.ReadFile("/tmp/testfromurl") + assert.NoError(t, err) + assert.EqualValues(t, "the file content", content) + + cancel() + c.wg.Wait() +} + +func TestCliCommand(t *testing.T) { + machineYaml := `apiVersion: gitmachinecontroller.io/v1beta1 +metadata: + annotations: + feature: ihavecoolfeature + labels: + os: rocky9 + type: server + name: mycooltestagent +spec: + ip: 127.0.0.1 + lines: []` + + err := os.WriteFile("./gitrepo/mycooltestagent.yml", []byte(machineYaml), 0666) + assert.NoError(t, err) + defer os.Remove("./gitrepo/mycooltestagent.yml") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, closer := initMasterAgent(t, ctx) + defer closer() + + c.commander.Mock.On("RunWithCode", "uptime").Return(" 14:43:12 up 56 days, 23:56, 1 user, load average: 0,71, 0,58, 0,46", "", 0, nil).Once() + + _, err = c.client.Post("/api/machines/accept-v1", bytes.NewBufferString(`{"host":"mycooltestagent"}`)) + assert.NoError(t, err) + + time.Sleep(2 * time.Second) + + err = os.WriteFile("./adminConfig", []byte(fmt.Sprintf(` + {"masters":[{"name":"http://localhost:%s","zone":"zone1"}], + "token":"%s"}`, c.master.WsPort, c.client.Token)), 0666) + assert.NoError(t, err) + defer os.Remove("./adminConfig") + + stdout := captureStdout() + + a := admin.NewAdmin("./adminConfig", "", "") + err = a.Exec(context.TODO(), "uptime") + assert.NoError(t, err) + + out := stdout() + assert.Contains(t, out, " 14:43:12 up 56 days, 23:56, 1 user, load average: 0,71, 0,58, 0,46") + assert.Contains(t, out, "mycooltestagent:") + + cancel() + c.wg.Wait() +} + +func TestCliCommandInvalidToken(t *testing.T) { + machineYaml := `apiVersion: gitmachinecontroller.io/v1beta1 +metadata: + annotations: + feature: ihavecoolfeature + labels: + os: rocky9 + type: server + name: mycooltestagent +spec: + ip: 127.0.0.1 + lines: []` + + err := os.WriteFile("./gitrepo/mycooltestagent.yml", []byte(machineYaml), 0666) + assert.NoError(t, err) + defer os.Remove("./gitrepo/mycooltestagent.yml") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, closer := initMasterAgent(t, ctx) + defer closer() + + _, err = c.client.Post("/api/machines/accept-v1", bytes.NewBufferString(`{"host":"mycooltestagent"}`)) + assert.NoError(t, err) + + time.Sleep(2 * time.Second) + + err = os.WriteFile("./adminConfig", []byte(fmt.Sprintf(` + {"masters":[{"name":"http://localhost:%s","zone":"zone1"}], + "token":"%s"}`, c.master.WsPort, "blaha")), 0666) + assert.NoError(t, err) + defer os.Remove("./adminConfig") + + buf := &bytes.Buffer{} + logrus.SetOutput(buf) + a := admin.NewAdmin("./adminConfig", "", "") + err = a.Exec(context.TODO(), "uptime") + assert.Equal(t, "websocket: bad handshake", err.Error()) + + assert.Contains(t, buf.String(), "Error #01: auth: error validating") + cancel() + c.wg.Wait() +} + +func TestCliCommandNotAdminToken(t *testing.T) { + machineYaml := `apiVersion: gitmachinecontroller.io/v1beta1 +metadata: + annotations: + feature: ihavecoolfeature + labels: + os: rocky9 + type: server + name: mycooltestagent +spec: + ip: 127.0.0.1 + lines: []` + + err := os.WriteFile("./gitrepo/mycooltestagent.yml", []byte(machineYaml), 0666) + assert.NoError(t, err) + defer os.Remove("./gitrepo/mycooltestagent.yml") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + c, closer := initMasterAgent(t, ctx) + defer closer() + + _, err = c.client.Post("/api/machines/accept-v1", bytes.NewBufferString(`{"host":"mycooltestagent"}`)) + assert.NoError(t, err) + + time.Sleep(2 * time.Second) + + buf := &bytes.Buffer{} + logrus.SetOutput(buf) + a := admin.NewAdmin("./agentConfig", "", "") + err = a.Exec(context.TODO(), "uptime") + assert.NoError(t, err) + + assert.Contains(t, buf.String(), "run-command-request permission denied") + + cancel() + c.wg.Wait() +} diff --git a/e2e/httpclient_test.go b/e2e/httpclient_test.go new file mode 100644 index 0000000..bff11bf --- /dev/null +++ b/e2e/httpclient_test.go @@ -0,0 +1,67 @@ +package e2e_test + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +type authedHttpClient struct { + Token string + baseURL string +} + +func NewAuthedHttpClient(t *testing.T, baseURL string) *authedHttpClient { + type tokenStruct struct { + Jwt string + } + + resp, err := http.Post(baseURL+"/api/admin-v1", "application/json", nil) + assert.NoError(t, err) + defer resp.Body.Close() + + token := &tokenStruct{} + err = json.NewDecoder(resp.Body).Decode(token) + assert.NoError(t, err) + + return &authedHttpClient{ + Token: token.Jwt, + baseURL: baseURL, + } +} + +func (ahc *authedHttpClient) Post(u string, body io.Reader) (resp *http.Response, err error) { + u = strings.TrimLeft(u, "/") + + req, err := http.NewRequest(http.MethodPost, ahc.baseURL+"/"+u, body) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", ahc.Token) + + return http.DefaultClient.Do(req) +} +func (ahc *authedHttpClient) Get(u string) (resp *http.Response, err error) { + u = strings.TrimLeft(u, "/") + + req, err := http.NewRequest(http.MethodGet, ahc.baseURL+"/"+u, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", ahc.Token) + + return http.DefaultClient.Do(req) +} + +func getBody(t *testing.T, b io.ReadCloser) string { + + d, err := io.ReadAll(b) + assert.NoError(t, err) + return string(d) +} diff --git a/e2e/utils_test.go b/e2e/utils_test.go new file mode 100644 index 0000000..fa05c67 --- /dev/null +++ b/e2e/utils_test.go @@ -0,0 +1,131 @@ +package e2e_test + +import ( + "bytes" + "context" + "io" + "net" + "os" + "strconv" + "sync" + "testing" + "time" + + "github.com/fortnoxab/gitmachinecontroller/mocks" + "github.com/fortnoxab/gitmachinecontroller/pkg/agent" + "github.com/fortnoxab/gitmachinecontroller/pkg/agent/config" + "github.com/fortnoxab/gitmachinecontroller/pkg/master" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +type testWrapper struct { + client *authedHttpClient + master *master.Master + agent *agent.Agent + commander *mocks.MockCommander + wg *sync.WaitGroup +} + +func initMasterAgent(t *testing.T, ctx context.Context) (testWrapper, func()) { + + logrus.SetLevel(logrus.DebugLevel) + port, err := freePort() + assert.NoError(t, err) + portStr := strconv.Itoa(port) + master := &master.Master{ + GitURL: "https://test/gitrepo", + GitPollInterval: time.Second, + WsPort: portStr, + JWTKey: "asdfasdf", + Masters: config.Masters{ + { + URL: "http://localhost:" + portStr, + Zone: "zone1", + }, + }, + } + mockedCommander := mocks.NewMockCommander(t) + agent := agent.NewAgent("./agentConfig", mockedCommander) + agent.Master = "http://localhost:" + portStr + agent.Hostname = "mycooltestagent" + + wg := &sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := master.Run(ctx) + assert.NoError(t, err) + }() + + wg.Add(1) + go func() { + defer wg.Done() + time.Sleep(200 * time.Millisecond) + err := agent.Run(ctx) + assert.NoError(t, err) + }() + + time.Sleep(400 * time.Millisecond) + client := NewAuthedHttpClient(t, "http://localhost:"+portStr) + return testWrapper{ + client: client, + master: master, + agent: agent, + commander: mockedCommander, + wg: wg, + }, func() { + os.Remove("./agentConfig") + } +} +func freePort() (port int, err error) { + var a *net.TCPAddr + if a, err = net.ResolveTCPAddr("tcp", "localhost:0"); err == nil { + var l *net.TCPListener + if l, err = net.ListenTCP("tcp", a); err == nil { + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port, nil + } + } + return +} + +func captureStdout() func() string { + old := os.Stdout // keep backup of the real stdout + r, w, _ := os.Pipe() + os.Stdout = w + + outC := make(chan string) + // copy the output in a separate goroutine so printing can't block indefinitely + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + return func() string { + w.Close() + os.Stdout = old // restoring the real stdout + return <-outC + } +} +func captureStderr() func() string { + old := os.Stderr // keep backup of the real stdout + r, w, _ := os.Pipe() + os.Stderr = w + + outC := make(chan string) + // copy the output in a separate goroutine so printing can't block indefinitely + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + return func() string { + w.Close() + os.Stderr = old // restoring the real stdout + return <-outC + } +} diff --git a/go.mod b/go.mod index 115a5a9..0f4c395 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fortnoxab/gitmachinecontroller -go 1.19 +go 1.23 require ( github.com/fatih/color v1.13.0 @@ -16,7 +16,7 @@ require ( github.com/jonaz/ginlogrus v0.0.0-20191118094232-2f4da50f5dd6 github.com/olahol/melody v1.1.1 github.com/sirupsen/logrus v1.9.0 - github.com/stretchr/testify v1.8.1 + github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v2 v2.23.7 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.26.0 @@ -66,6 +66,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/skeema/knownhosts v1.1.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/ugorji/go/codec v1.2.7 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect diff --git a/go.sum b/go.sum index 896482b..f8bc103 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fluxcd/gitkit v0.6.0 h1:iNg5LTx6ePo+Pl0ZwqHTAkhbUHxGVSY3YCxCdw7VIFg= +github.com/fluxcd/gitkit v0.6.0/go.mod h1:svOHuKi0fO9HoawdK4HfHAJJseZDHHjk7I3ihnCIqNo= github.com/fluxcd/go-git/v5 v5.0.0-20221206140629-ec778c2c37df h1:2BHXJp1PwX7D47Q2oaKDekn+BZVZCmxeCWNi+FyownE= github.com/fluxcd/go-git/v5 v5.0.0-20221206140629-ec778c2c37df/go.mod h1:raWgfUV7lDQVXp4QXUaeNNJkRVKz97UQuF+0kdY7Vmo= github.com/fluxcd/pkg/git v0.7.0 h1:sQHRpFMcOzEdqlyGMjFv2LKMdcoE5xeUr2UcRrsLRG8= @@ -93,6 +94,7 @@ github.com/fluxcd/pkg/git v0.7.0/go.mod h1:3deiLPws4DSQ3hqwtQd7Dt66GXTN/4RcT/yHA github.com/fluxcd/pkg/git/gogit v0.3.1 h1:00GjuVuNYcLwJXolwOqnL/tAcDXcNqZATS8cnrO22Pw= github.com/fluxcd/pkg/git/gogit v0.3.1/go.mod h1:5b3+lylk3oPkKazfnK5K7DWC2d6MMhYj8wWG1Qx6v3U= github.com/fluxcd/pkg/gittestserver v0.8.0 h1:YrYe63KScKlLxx0GAiQthx2XqHDx0vKitIIx4JnDtIo= +github.com/fluxcd/pkg/gittestserver v0.8.0/go.mod h1:/LI/xKMrnQbIsTDnTyABQ71iaYhFIZ8fb4cvY7WAlBU= github.com/fluxcd/pkg/ssh v0.7.0 h1:FX5ky8SU9dYwbM6zEIDR3TSveLF01iyS95CtB5Ykpno= github.com/fluxcd/pkg/ssh v0.7.0/go.mod h1:tCVZJI8jPOL0XCInJOrYGKapWA/zZCzqPtpiYUSQxww= github.com/fluxcd/pkg/version v0.2.0 h1:jG22c59Bsv6vL51N7Bqn8tjHArYOXrjbIkGArlIrv5w= @@ -146,6 +148,7 @@ github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGF github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA= github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= @@ -272,6 +275,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/olahol/melody v1.1.1 h1:amgBhR7pDY0rA0JHWprgLF0LnVztognAwEQgf/WYLVM= github.com/olahol/melody v1.1.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4= github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E= +github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= @@ -330,6 +334,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -337,8 +343,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= @@ -464,6 +471,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -522,6 +530,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/mocks/mock_Commander.go b/mocks/mock_Commander.go new file mode 100644 index 0000000..31f088c --- /dev/null +++ b/mocks/mock_Commander.go @@ -0,0 +1,243 @@ +// Code generated by mockery v2.45.1. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// MockCommander is an autogenerated mock type for the Commander type +type MockCommander struct { + mock.Mock +} + +type MockCommander_Expecter struct { + mock *mock.Mock +} + +func (_m *MockCommander) EXPECT() *MockCommander_Expecter { + return &MockCommander_Expecter{mock: &_m.Mock} +} + +// Run provides a mock function with given fields: _a0 +func (_m *MockCommander) Run(_a0 string) (string, string, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Run") + } + + var r0 string + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(string) (string, string, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) string); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(_a0) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockCommander_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run' +type MockCommander_Run_Call struct { + *mock.Call +} + +// Run is a helper method to define mock.On call +// - _a0 string +func (_e *MockCommander_Expecter) Run(_a0 interface{}) *MockCommander_Run_Call { + return &MockCommander_Run_Call{Call: _e.mock.On("Run", _a0)} +} + +func (_c *MockCommander_Run_Call) Run(run func(_a0 string)) *MockCommander_Run_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCommander_Run_Call) Return(_a0 string, _a1 string, _a2 error) *MockCommander_Run_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *MockCommander_Run_Call) RunAndReturn(run func(string) (string, string, error)) *MockCommander_Run_Call { + _c.Call.Return(run) + return _c +} + +// RunExpectCodes provides a mock function with given fields: _a0, codes +func (_m *MockCommander) RunExpectCodes(_a0 string, codes ...int) (string, int, error) { + _va := make([]interface{}, len(codes)) + for _i := range codes { + _va[_i] = codes[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for RunExpectCodes") + } + + var r0 string + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(string, ...int) (string, int, error)); ok { + return rf(_a0, codes...) + } + if rf, ok := ret.Get(0).(func(string, ...int) string); ok { + r0 = rf(_a0, codes...) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, ...int) int); ok { + r1 = rf(_a0, codes...) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(string, ...int) error); ok { + r2 = rf(_a0, codes...) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockCommander_RunExpectCodes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunExpectCodes' +type MockCommander_RunExpectCodes_Call struct { + *mock.Call +} + +// RunExpectCodes is a helper method to define mock.On call +// - _a0 string +// - codes ...int +func (_e *MockCommander_Expecter) RunExpectCodes(_a0 interface{}, codes ...interface{}) *MockCommander_RunExpectCodes_Call { + return &MockCommander_RunExpectCodes_Call{Call: _e.mock.On("RunExpectCodes", + append([]interface{}{_a0}, codes...)...)} +} + +func (_c *MockCommander_RunExpectCodes_Call) Run(run func(_a0 string, codes ...int)) *MockCommander_RunExpectCodes_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]int, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(int) + } + } + run(args[0].(string), variadicArgs...) + }) + return _c +} + +func (_c *MockCommander_RunExpectCodes_Call) Return(_a0 string, _a1 int, _a2 error) *MockCommander_RunExpectCodes_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *MockCommander_RunExpectCodes_Call) RunAndReturn(run func(string, ...int) (string, int, error)) *MockCommander_RunExpectCodes_Call { + _c.Call.Return(run) + return _c +} + +// RunWithCode provides a mock function with given fields: _a0 +func (_m *MockCommander) RunWithCode(_a0 string) (string, string, int, error) { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for RunWithCode") + } + + var r0 string + var r1 string + var r2 int + var r3 error + if rf, ok := ret.Get(0).(func(string) (string, string, int, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(_a0) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) string); ok { + r1 = rf(_a0) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(string) int); ok { + r2 = rf(_a0) + } else { + r2 = ret.Get(2).(int) + } + + if rf, ok := ret.Get(3).(func(string) error); ok { + r3 = rf(_a0) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + +// MockCommander_RunWithCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunWithCode' +type MockCommander_RunWithCode_Call struct { + *mock.Call +} + +// RunWithCode is a helper method to define mock.On call +// - _a0 string +func (_e *MockCommander_Expecter) RunWithCode(_a0 interface{}) *MockCommander_RunWithCode_Call { + return &MockCommander_RunWithCode_Call{Call: _e.mock.On("RunWithCode", _a0)} +} + +func (_c *MockCommander_RunWithCode_Call) Run(run func(_a0 string)) *MockCommander_RunWithCode_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *MockCommander_RunWithCode_Call) Return(_a0 string, _a1 string, _a2 int, _a3 error) *MockCommander_RunWithCode_Call { + _c.Call.Return(_a0, _a1, _a2, _a3) + return _c +} + +func (_c *MockCommander_RunWithCode_Call) RunAndReturn(run func(string) (string, string, int, error)) *MockCommander_RunWithCode_Call { + _c.Call.Return(run) + return _c +} + +// NewMockCommander creates a new instance of MockCommander. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockCommander(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCommander { + mock := &MockCommander{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/admin/admin.go b/pkg/admin/admin.go index a978991..d01692f 100644 --- a/pkg/admin/admin.go +++ b/pkg/admin/admin.go @@ -54,6 +54,15 @@ func NewAdminFromContext(c *cli.Context) *Admin { } } +func NewAdmin(configFile, selector, regexp string) *Admin { + + return &Admin{ + configFile: configFile, + selector: selector, + regexp: regexp, + } +} + func (a *Admin) wsConnect(ctx context.Context, conf *config.Config) (websocket.Websocket, error) { headers := http.Header{} headers.Add("X-VERSION", build.JSON()) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 8e6b8d9..9e48950 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -35,6 +35,7 @@ type Agent struct { mutex sync.RWMutex config *config.Config configFile string + commander command.Commander } func NewAgentFromContext(c *cli.Context) *Agent { @@ -48,6 +49,17 @@ func NewAgentFromContext(c *cli.Context) *Agent { wg: &sync.WaitGroup{}, callbacks: make(map[string][]OnFunc), client: websocket.NewWebsocketClient(), + commander: &command.Exec{}, + } + return m +} +func NewAgent(configFile string, commander command.Commander) *Agent { + m := &Agent{ + wg: &sync.WaitGroup{}, + callbacks: make(map[string][]OnFunc), + client: websocket.NewWebsocketClient(), + configFile: configFile, + commander: commander, } return m } @@ -188,7 +200,7 @@ func (a *Agent) onRunCommand(msg *protocol.WebsocketMessage) error { return err } - stdout, stderr, code, err := command.RunWithCode(cmd) + stdout, stderr, code, err := a.commander.RunWithCode(cmd) cr := &protocol.CommandResult{ Stdout: stdout, Stderr: stderr, @@ -213,9 +225,6 @@ func (a *Agent) onRunCommand(msg *protocol.WebsocketMessage) error { } func (a *Agent) onMachineUpdate(msg *protocol.WebsocketMessage) error { - // TODO if msg.Source is protocol.ManualSource and manifest has annotation gmc.io/ignore = "true" - // then save local state and config on disk that we ignore git updates until the annotation is removed. - if msg.Source == protocol.GitSource && a.config.Ignore { logrus.Debug("ignore reconciliation since we have gmc.io/ignore=true") return nil @@ -242,9 +251,8 @@ func (a *Agent) onMachineUpdate(msg *protocol.WebsocketMessage) error { } } - recon := &reconciliation.MachineReconciler{} + recon := reconciliation.NewMachineReconciler(a.commander) return recon.Reconcile(machine) - // return nil } func (a *Agent) reader(ctx context.Context) { diff --git a/pkg/agent/command/run.go b/pkg/agent/command/run.go index ef989a1..1b1cf46 100644 --- a/pkg/agent/command/run.go +++ b/pkg/agent/command/run.go @@ -8,11 +8,20 @@ import ( "github.com/google/shlex" ) -func Run(command string) (string, string, error) { - stdout, stderr, _, err := RunWithCode(command) +type Commander interface { + Run(command string) (string, string, error) + RunWithCode(command string) (string, string, int, error) + RunExpectCodes(command string, codes ...int) (string, int, error) +} + +type Exec struct { +} + +func (e *Exec) Run(command string) (string, string, error) { + stdout, stderr, _, err := e.RunWithCode(command) return stdout, stderr, err } -func RunWithCode(command string) (string, string, int, error) { +func (e *Exec) RunWithCode(command string) (string, string, int, error) { args, err := shlex.Split(command) if err != nil { return "", "", -1, err @@ -30,7 +39,7 @@ func RunWithCode(command string) (string, string, int, error) { return strings.TrimSpace(outBuf.String()), strings.TrimSpace(errBuf.String()), code, nil } -func RunExpectCodes(command string, codes ...int) (string, int, error) { +func (e *Exec) RunExpectCodes(command string, codes ...int) (string, int, error) { args, err := shlex.Split(command) if err != nil { return "", 1, err diff --git a/pkg/agent/reconciliation/commands.go b/pkg/agent/reconciliation/commands.go index 23e38f1..1cca9da 100644 --- a/pkg/agent/reconciliation/commands.go +++ b/pkg/agent/reconciliation/commands.go @@ -3,7 +3,6 @@ package reconciliation import ( "fmt" - "github.com/fortnoxab/gitmachinecontroller/pkg/agent/command" "github.com/fortnoxab/gitmachinecontroller/pkg/api/v1/types" "github.com/sirupsen/logrus" ) @@ -13,13 +12,13 @@ func (mr *MachineReconciler) commands(commands types.Commands) error { // command.Command // command.Check - _, _, err := command.Run(cmd.Check) // dont run command if check did not exit 0 + _, _, err := mr.commander.Run(cmd.Check) // dont run command if check did not exit 0 if err != nil { continue } // TODO report errors back to master or with local metrics? - stdOut, stdErr, err := command.Run(cmd.Command) + stdOut, stdErr, err := mr.commander.Run(cmd.Command) if err != nil { logrus.Error(err) continue diff --git a/pkg/agent/reconciliation/files.go b/pkg/agent/reconciliation/files.go index dc7da77..75edc99 100644 --- a/pkg/agent/reconciliation/files.go +++ b/pkg/agent/reconciliation/files.go @@ -31,7 +31,7 @@ func (mr *MachineReconciler) files(files types.Files) error { if file.URL != "" { changed, err := fetchFromURL(file) if err != nil { - logrus.Errorf("files: %s failed with error: %s", file.Path, err) + logrus.Errorf("files: %s failed to fetch from URL with error: %s", file.Path, err) continue } if changed { @@ -43,7 +43,7 @@ func (mr *MachineReconciler) files(files types.Files) error { if file.Content != "" { changed, err := writeContentIfNeeded(file) if err != nil { - logrus.Errorf("files: %s failed with error: %s", file.Path, err) + logrus.Errorf("files: %s failed write file content with error: %s", file.Path, err) continue } if changed { @@ -60,18 +60,29 @@ func writeContentIfNeeded(file *types.File) (bool, error) { if err != nil { return false, err } - if _, err := os.Stat(file.Path); errors.Is(err, os.ErrNotExist) { // New file we can write directly to desired location + statedFile, err := os.Stat(file.Path) + if errors.Is(err, os.ErrNotExist) { // New file we can write directly to desired location err = os.WriteFile(file.Path, []byte(file.Content), mode) if err != nil { return false, err } return true, nil } - equal, err := fileEqual(file.Content, file.Path) - if err != nil { - return false, err + + equal := true + if int64(len(file.Content)) != statedFile.Size() { + equal, err = fileEqual(file.Content, file.Path) + if err != nil { + return false, err + } } if equal { + if mode != statedFile.Mode() { // check if content was same but we need to update mode + err = os.Chmod(file.Path, mode) + if err != nil { + return false, err + } + } logrus.Debug(file.Path, " already equal") return false, nil } @@ -82,15 +93,23 @@ func writeContentIfNeeded(file *types.File) (bool, error) { return false, err } defer tempFile.Close() - tempFile.Chmod(mode) + err = tempFile.Chmod(mode) + + if err != nil { + return false, err + } _, err = io.Copy(tempFile, bytes.NewBufferString(file.Content)) if err != nil { return false, err } - tempFile.Close() - return true, os.Rename(tempFile.Name(), file.Path) + tempFile.Close() // close it so we can rename it (move it) + err = os.Rename(tempFile.Name(), file.Path) + if err != nil { + return false, err + } + return true, nil } func needsFetch(file *types.File) (bool, error) { diff --git a/pkg/agent/reconciliation/packages.go b/pkg/agent/reconciliation/packages.go index 54da856..f326e77 100644 --- a/pkg/agent/reconciliation/packages.go +++ b/pkg/agent/reconciliation/packages.go @@ -2,8 +2,8 @@ package reconciliation import ( "fmt" + "strings" - "github.com/fortnoxab/gitmachinecontroller/pkg/agent/command" "github.com/fortnoxab/gitmachinecontroller/pkg/api/v1/types" "github.com/sirupsen/logrus" ) @@ -11,7 +11,7 @@ import ( // Only yum for now. func (mr *MachineReconciler) packages(packages types.Packages) error { for _, pkg := range packages { - err := installPackage(pkg) + err := mr.installPackage(pkg) if err != nil { logrus.Errorf("package: installing %s@%s: %s", pkg.Name, pkg.Version, err) continue @@ -20,13 +20,13 @@ func (mr *MachineReconciler) packages(packages types.Packages) error { return nil } -func installPackage(pkg *types.Package) error { +func (mr *MachineReconciler) installPackage(pkg *types.Package) error { name := pkg.Name + "-" + pkg.Version if pkg.Version == "*" || pkg.Version == "" { name = pkg.Name } - _, c, err := command.RunExpectCodes(fmt.Sprintf("rpm -q %s", name), 0, 1) + _, c, err := mr.commander.RunExpectCodes(fmt.Sprintf("rpm -q %s", name), 0, 1) if err != nil { return err } @@ -34,14 +34,11 @@ func installPackage(pkg *types.Package) error { return nil } // also check provides if you are installing for example vim which provides vim-enhanced package. - _, c, err = command.RunExpectCodes(fmt.Sprintf("rpm -q --whatprovides %s", name), 0, 1) + providedBy, _, err := mr.commander.Run(fmt.Sprintf("rpm -q --whatprovides %s", name)) if err != nil { return err } - if c == 0 { - return nil - } - _, _, err = command.Run(fmt.Sprintf("yum install -y %s", name)) + _, _, err = mr.commander.Run(fmt.Sprintf("yum install -y %s", strings.TrimSpace(providedBy))) return err } diff --git a/pkg/agent/reconciliation/reconciliation.go b/pkg/agent/reconciliation/reconciliation.go index cc0b141..83517f9 100644 --- a/pkg/agent/reconciliation/reconciliation.go +++ b/pkg/agent/reconciliation/reconciliation.go @@ -5,11 +5,17 @@ import ( "github.com/fortnoxab/gitmachinecontroller/pkg/agent/command" "github.com/fortnoxab/gitmachinecontroller/pkg/api/v1/types" + "github.com/sirupsen/logrus" ) type MachineReconciler struct { restartUnits map[string]string daemonReloadNeeded bool + commander command.Commander +} + +func NewMachineReconciler(c command.Commander) *MachineReconciler { + return &MachineReconciler{commander: c} } func (mr *MachineReconciler) Reconcile(machine *types.Machine) error { @@ -40,6 +46,7 @@ func (mr *MachineReconciler) unitNeedsTrigger(systemd *types.SystemdReference) { if systemd == nil { return } + logrus.Debug("unitNeedsTrigger", fmt.Sprintf("%#v", systemd)) mr.restartUnits[systemd.Name] = systemd.Action if systemd.DaemonReload { mr.daemonReloadNeeded = true @@ -48,13 +55,13 @@ func (mr *MachineReconciler) unitNeedsTrigger(systemd *types.SystemdReference) { func (mr *MachineReconciler) runSystemdTriggers() error { if mr.daemonReloadNeeded { - _, _, err := command.Run("systemctl daemon reload") + _, _, err := mr.commander.Run("systemctl daemon reload") if err != nil { return err } } for name, action := range mr.restartUnits { - _, _, err := command.Run(fmt.Sprintf("systemctl %s %s", action, name)) + _, _, err := mr.commander.Run(fmt.Sprintf("systemctl %s %s", action, name)) if err != nil { return err } diff --git a/pkg/agent/reconciliation/reconciliation_test.go b/pkg/agent/reconciliation/reconciliation_test.go new file mode 100644 index 0000000..de8850e --- /dev/null +++ b/pkg/agent/reconciliation/reconciliation_test.go @@ -0,0 +1,113 @@ +package reconciliation + +import ( + "fmt" + "os" + "testing" + + "github.com/fortnoxab/gitmachinecontroller/mocks" + "github.com/fortnoxab/gitmachinecontroller/pkg/api/v1/types" + "github.com/stretchr/testify/assert" +) + +func TestFilesContent(t *testing.T) { + + machine := &types.Machine{ + Spec: &types.Spec{ + Files: types.Files{ + &types.File{ + Path: "testfil1", + Content: "test", + Systemd: &types.SystemdReference{ + DaemonReload: true, + Name: "service1", + Action: "restart", + }, + }, + }, + }, + } + defer os.Remove("testfil1") + + mockedCommander := mocks.NewMockCommander(t) + mockedCommander.Mock.On("Run", "systemctl restart service1").Return("", "", nil).Once() + mockedCommander.Mock.On("Run", "systemctl daemon reload").Return("", "", nil).Once() + + recon := NewMachineReconciler(mockedCommander) + err := recon.Reconcile(machine) + assert.NoError(t, err) + + assert.FileExists(t, "testfil1") + c, err := os.ReadFile("testfil1") + assert.NoError(t, err) + assert.EqualValues(t, c, "test") +} + +func TestInstallPackages(t *testing.T) { + + var tests = []struct { + name string + packageName string + packageVersion string + providesIt string + mock func(*mocks.MockCommander) + }{ + { + name: "provided by other", + packageName: "vim", + packageVersion: "*", + providesIt: "vim-enhanced-8.0.1763-19.el8_6.4.x86_64", + }, + { + name: "provided by same", + packageName: "nano", + packageVersion: "*", + providesIt: "nano", + }, + { + name: "already installed", + packageName: "nano", + packageVersion: "*", + mock: func(m *mocks.MockCommander) { + m.Mock.On("RunExpectCodes", "rpm -q nano", 0, 1).Return("", 0, nil).Once() + }, + }, + { + name: "rpm error", + packageName: "nano", + packageVersion: "*", + mock: func(m *mocks.MockCommander) { + m.Mock.On("RunExpectCodes", "rpm -q nano", 0, 1).Return("", 10, fmt.Errorf("error from rpm")).Once() + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + machine := &types.Machine{ + Spec: &types.Spec{ + Packages: types.Packages{ + { + Name: tt.packageName, + Version: tt.packageVersion, + }, + }, + }, + } + + mockedCommander := mocks.NewMockCommander(t) + if tt.mock != nil { + tt.mock(mockedCommander) + } else { + mockedCommander.Mock.On("RunExpectCodes", fmt.Sprintf("rpm -q %s", tt.packageName), 0, 1).Return("", 1, nil).Once() + mockedCommander.Mock.On("Run", fmt.Sprintf("rpm -q --whatprovides %s", tt.packageName)).Return(tt.providesIt, "", nil).Once() + mockedCommander.Mock.On("Run", fmt.Sprintf("yum install -y %s", tt.providesIt)).Return("", "", nil).Once() + } + + recon := NewMachineReconciler(mockedCommander) + err := recon.Reconcile(machine) + assert.NoError(t, err) + }) + } + +} diff --git a/pkg/master/master.go b/pkg/master/master.go index 55512d3..17bf189 100644 --- a/pkg/master/master.go +++ b/pkg/master/master.go @@ -2,13 +2,8 @@ package master import ( "context" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" "encoding/json" "fmt" - "io" "net/url" "os" "path/filepath" @@ -84,6 +79,7 @@ type Master struct { JWTKey string WsPort string Masters config.Masters + EnableMetrics bool webserver *webserver.Webserver machineStateCh chan types.MachineStateQuestion } @@ -101,6 +97,7 @@ func NewMasterFromContext(c *cli.Context) *Master { SecretKey: c.String("secret-key"), JWTKey: c.String("jwt-key"), WsPort: c.String("port"), + EnableMetrics: true, } masters := config.Masters{} @@ -127,6 +124,7 @@ func (m *Master) Run(ctx context.Context) error { m.machineStateCh = make(chan types.MachineStateQuestion) m.webserver = webserver.New(m.WsPort, m.JWTKey, m.Masters) m.webserver.MachineStateCh = m.machineStateCh + m.webserver.EnableMetrics = m.EnableMetrics go m.webserver.Start(ctx) return m.run(ctx) @@ -342,6 +340,11 @@ func (m *Master) handleRequest(ctx context.Context, machines map[string]*types.M case "run-command-request": if admin, _ := sess.Get("admin"); !admin.(bool) { + err := sendIsLast(sess, r.Request.RequestID) + if err != nil { + logrus.Error(err) + } + return fmt.Errorf("run-command-request permission denied") } cmdReq := &protocol.RunCommandRequest{} @@ -433,6 +436,9 @@ func (m *Master) handleRequest(ctx context.Context, machines map[string]*types.M } ch <- r case "admin-apply-spec": + if admin, _ := sess.Get("admin"); !admin.(bool) { + return fmt.Errorf("admin-apply-spec permission denied") + } defer sendIsLast(sess, r.Request.RequestID) machine := &types.Machine{} err := json.Unmarshal(r.Request.Body, machine) @@ -459,15 +465,49 @@ func sendIsLast(sess *melody.Session, reqID string) error { return sess.Write(b) } +type Cloner interface { + Clone(ctx context.Context, url string, cloneOpts repository.CloneOptions) (*git.Commit, error) + Close() +} + +type testCloner struct { + dir string +} + +func (tc *testCloner) Clone(ctx context.Context, URL string, cloneOpts repository.CloneOptions) (*git.Commit, error) { + + u, err := url.Parse(URL) + if err != nil { + return nil, err + } + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + + from := filepath.Join(cwd, u.Path) + logrus.Infof("testCloner: copy %s to %s", from, tc.dir) + err = os.CopyFS(tc.dir, os.DirFS(from)) + return nil, err +} +func (tc *testCloner) Close() { + +} + func (m *Master) clone(ctx context.Context, authOpts *git.AuthOptions, cloneOpts repository.CloneOptions, manifestCh chan machineUpdate) error { dir, err := os.MkdirTemp("", "gmc") if err != nil { return err } defer os.RemoveAll(dir) - gitClient, err := gogit.NewClient(dir, authOpts) - if err != nil { - return err + var gitClient Cloner + if authOpts.Host == "test" { + gitClient = &testCloner{dir: dir} + } else { + gitClient, err = gogit.NewClient(dir, authOpts) + if err != nil { + return err + } } defer gitClient.Close() @@ -512,13 +552,13 @@ func (m *Master) identity() (map[string][]byte, error) { i, err := os.ReadFile(m.GitIdentifyPath) if err != nil { - return nil, err + logrus.Warnf("error fetching GitIdentifyPath: %s", err) } authData["identity"] = i kh, err := os.ReadFile(m.GitKnownHostsPath) if err != nil { - return nil, err + logrus.Warnf("error fetching GitKnownHostsPath: %s", err) } authData["known_hosts"] = kh @@ -529,6 +569,9 @@ func (m *Master) identity() (map[string][]byte, error) { return authData, nil } +/* + +// TODO implement encrypted strings func (m *Master) encryptSecret(plaintext []byte) (string, error) { key := sha256.Sum256([]byte(m.SecretKey)) @@ -575,3 +618,4 @@ func (m *Master) decryptSecret(ciphertext []byte) (string, error) { return string(plaintext), nil } +*/ diff --git a/pkg/master/webserver/webserver.go b/pkg/master/webserver/webserver.go index 326f363..ba13014 100644 --- a/pkg/master/webserver/webserver.go +++ b/pkg/master/webserver/webserver.go @@ -28,6 +28,7 @@ type Webserver struct { Websocket *melody.Melody jwt *jwt.JWTHandler MachineStateCh chan types.MachineStateQuestion + EnableMetrics bool } func New(port, jwtKey string, masters config.Masters) *Webserver { @@ -43,8 +44,10 @@ func New(port, jwtKey string, masters config.Masters) *Webserver { func (ws *Webserver) Init() *gin.Engine { router := gin.New() - p := ginprometheus.New("http") - p.Use(router) + if ws.EnableMetrics { + p := ginprometheus.New("http") + p.Use(router) + } logIgnorePaths := []string{ "/health", @@ -64,6 +67,16 @@ func (ws *Webserver) Init() *gin.Engine { requireAdmin.POST("/api/machines/accept-v1", err(ws.approveMachine)) requireAdmin.GET("/api/authed-v1", func(*gin.Context) {}) requireAdmin.GET("/machines", err(ws.listPendingMachines)) + requireAdmin.GET("/api/machines-v1", err(func(c *gin.Context) error { + + hostList, err := ws.hostList() + if err != nil { + return err + } + + c.JSON(http.StatusOK, hostList) + return nil + })) ws.initWS(router) @@ -177,16 +190,30 @@ const acceptHost = (hostname) => { return err } - type host struct { - Name string `json:"name"` - IP string `json:"ip"` - Online bool - Accepted bool - Git bool - LastUpdate time.Time + hostList, err := ws.hostList() + if err != nil { + return err } + + return tmpl.Execute(c.Writer, hostList) +} + +type host struct { + Name string `json:"name"` + IP string `json:"ip"` + Online bool + Accepted bool + Git bool + LastUpdate time.Time +} + +func (ws *Webserver) hostList() (map[string]*host, error) { hostList := make(map[string]*host) sessions, err := ws.Websocket.Sessions() + if err != nil { + return nil, err + } + ch := make(chan map[string]*types.MachineState) ws.MachineStateCh <- types.MachineStateQuestion{ReplyCh: ch} list := <-ch @@ -222,8 +249,7 @@ const acceptHost = (hostname) => { hostList[name.(string)].Online = true } } - - return tmpl.Execute(c.Writer, hostList) + return hostList, nil } func (ws *Webserver) initWS(router *gin.Engine) { @@ -336,4 +362,4 @@ func err(f func(c *gin.Context) error) gin.HandlerFunc { } } -var jwtKey = []byte("supersecretkey") +// var jwtKey = []byte("supersecretkey")