github.com/meulengracht/snapd@v0.0.0-20210719210640-8bde69bcc84e/cmd/snap/cmd_quota_test.go (about)

     1  // -*- Mode: Go; indent-tabs-mode: t -*-
     2  
     3  /*
     4   * Copyright (C) 2021 Canonical Ltd
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU General Public License version 3 as
     8   * published by the Free Software Foundation.
     9   *
    10   * This program is distributed in the hope that it will be useful,
    11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13   * GNU General Public License for more details.
    14   *
    15   * You should have received a copy of the GNU General Public License
    16   * along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17   *
    18   */
    19  
    20  package main_test
    21  
    22  import (
    23  	"bytes"
    24  	"encoding/json"
    25  	"fmt"
    26  	"io/ioutil"
    27  	"net/http"
    28  
    29  	"gopkg.in/check.v1"
    30  
    31  	main "github.com/snapcore/snapd/cmd/snap"
    32  	"github.com/snapcore/snapd/jsonutil"
    33  )
    34  
    35  type quotaSuite struct {
    36  	BaseSnapSuite
    37  }
    38  
    39  var _ = check.Suite(&quotaSuite{})
    40  
    41  func makeFakeGetQuotaGroupNotFoundHandler(c *check.C, group string) func(w http.ResponseWriter, r *http.Request) {
    42  	return func(w http.ResponseWriter, r *http.Request) {
    43  		c.Check(r.URL.Path, check.Equals, "/v2/quotas/"+group)
    44  		c.Check(r.Method, check.Equals, "GET")
    45  		w.WriteHeader(404)
    46  		fmt.Fprintln(w, `{
    47  			"result": {
    48  				"message": "not found"
    49  			},
    50  			"status": "Not Found",
    51  			"status-code": 404,
    52  			"type": "error"
    53  		}`)
    54  	}
    55  
    56  }
    57  
    58  func makeFakeGetQuotaGroupHandler(c *check.C, body string) func(w http.ResponseWriter, r *http.Request) {
    59  	var called bool
    60  	return func(w http.ResponseWriter, r *http.Request) {
    61  		if called {
    62  			c.Fatalf("expected a single request")
    63  		}
    64  		called = true
    65  		c.Check(r.URL.Path, check.Equals, "/v2/quotas/foo")
    66  		c.Check(r.Method, check.Equals, "GET")
    67  		w.WriteHeader(200)
    68  		fmt.Fprintln(w, body)
    69  	}
    70  }
    71  
    72  func makeFakeGetQuotaGroupsHandler(c *check.C, body string) func(w http.ResponseWriter, r *http.Request) {
    73  	var called bool
    74  	return func(w http.ResponseWriter, r *http.Request) {
    75  		if called {
    76  			c.Fatalf("expected a single request")
    77  		}
    78  		called = true
    79  		c.Check(r.URL.Path, check.Equals, "/v2/quotas")
    80  		c.Check(r.Method, check.Equals, "GET")
    81  		w.WriteHeader(200)
    82  		fmt.Fprintln(w, body)
    83  	}
    84  }
    85  
    86  func dispatchFakeHandlers(c *check.C, routes map[string]http.HandlerFunc) func(w http.ResponseWriter, r *http.Request) {
    87  	return func(w http.ResponseWriter, r *http.Request) {
    88  		if router, ok := routes[r.URL.Path]; ok {
    89  			router(w, r)
    90  			return
    91  		}
    92  		c.Errorf("unexpected call to %s", r.URL.Path)
    93  	}
    94  }
    95  
    96  type fakeQuotaGroupPostHandlerOpts struct {
    97  	action     string
    98  	body       string
    99  	groupName  string
   100  	parentName string
   101  	snaps      []string
   102  	maxMemory  int64
   103  }
   104  
   105  type quotasEnsureBody struct {
   106  	Action      string                 `json:"action"`
   107  	GroupName   string                 `json:"group-name,omitempty"`
   108  	ParentName  string                 `json:"parent,omitempty"`
   109  	Snaps       []string               `json:"snaps,omitempty"`
   110  	Constraints map[string]interface{} `json:"constraints,omitempty"`
   111  }
   112  
   113  func makeFakeQuotaPostHandler(c *check.C, opts fakeQuotaGroupPostHandlerOpts) func(w http.ResponseWriter, r *http.Request) {
   114  	var called bool
   115  	return func(w http.ResponseWriter, r *http.Request) {
   116  		if called {
   117  			c.Fatalf("expected a single request")
   118  		}
   119  		called = true
   120  		c.Check(r.URL.Path, check.Equals, "/v2/quotas")
   121  		c.Check(r.Method, check.Equals, "POST")
   122  
   123  		buf, err := ioutil.ReadAll(r.Body)
   124  		c.Assert(err, check.IsNil)
   125  
   126  		switch opts.action {
   127  		case "remove":
   128  			c.Check(string(buf), check.Equals, fmt.Sprintf(`{"action":"remove","group-name":%q}`+"\n", opts.groupName))
   129  		case "ensure":
   130  			exp := quotasEnsureBody{
   131  				Action:      "ensure",
   132  				GroupName:   opts.groupName,
   133  				ParentName:  opts.parentName,
   134  				Snaps:       opts.snaps,
   135  				Constraints: map[string]interface{}{},
   136  			}
   137  			if opts.maxMemory != 0 {
   138  				exp.Constraints["memory"] = json.Number(fmt.Sprintf("%d", opts.maxMemory))
   139  			}
   140  
   141  			postJSON := quotasEnsureBody{}
   142  			err := jsonutil.DecodeWithNumber(bytes.NewReader(buf), &postJSON)
   143  			c.Assert(err, check.IsNil)
   144  			c.Assert(postJSON, check.DeepEquals, exp)
   145  		default:
   146  			c.Fatalf("unexpected action %q", opts.action)
   147  		}
   148  		w.WriteHeader(202)
   149  		fmt.Fprintln(w, opts.body)
   150  	}
   151  }
   152  
   153  func makeChangesHandler(c *check.C) func(w http.ResponseWriter, r *http.Request) {
   154  	n := 0
   155  	return func(w http.ResponseWriter, r *http.Request) {
   156  		n++
   157  		switch n {
   158  		case 1:
   159  			c.Check(r.Method, check.Equals, "GET")
   160  			c.Check(r.URL.Path, check.Equals, "/v2/changes/42")
   161  			fmt.Fprintln(w, `{"type": "sync", "result": {"status": "Doing"}}`)
   162  		case 2:
   163  			c.Check(r.Method, check.Equals, "GET")
   164  			c.Check(r.URL.Path, check.Equals, "/v2/changes/42")
   165  			fmt.Fprintln(w, `{"type": "sync", "result": {"ready": true, "status": "Done"}}`)
   166  		default:
   167  			c.Fatalf("expected to get 2 requests, now on %d", n+1)
   168  		}
   169  	}
   170  }
   171  
   172  func (s *quotaSuite) TestSetQuotaInvalidArgs(c *check.C) {
   173  	for _, args := range []struct {
   174  		args []string
   175  		err  string
   176  	}{
   177  		{[]string{"set-quota"}, "the required argument `<group-name>` was not provided"},
   178  		{[]string{"set-quota", "--memory=99B"}, "the required argument `<group-name>` was not provided"},
   179  		{[]string{"set-quota", "--memory=99", "foo"}, `cannot parse "99": need a number with a unit as input`},
   180  		{[]string{"set-quota", "--memory=888X", "foo"}, `cannot parse "888X\": try 'kB' or 'MB'`},
   181  		// remove-quota command
   182  		{[]string{"remove-quota"}, "the required argument `<group-name>` was not provided"},
   183  	} {
   184  		s.stdout.Reset()
   185  		s.stderr.Reset()
   186  
   187  		_, err := main.Parser(main.Client()).ParseArgs(args.args)
   188  		c.Assert(err, check.ErrorMatches, args.err)
   189  	}
   190  }
   191  
   192  func (s *quotaSuite) TestGetQuotaGroup(c *check.C) {
   193  	restore := main.MockIsStdinTTY(true)
   194  	defer restore()
   195  
   196  	const json = `{
   197  		"type": "sync",
   198  		"status-code": 200,
   199  		"result": {
   200  			"group-name":"foo",
   201  			"parent":"bar",
   202  			"subgroups":["subgrp1"],
   203  			"snaps":["snap-a","snap-b"],
   204  			"constraints": { "memory": 1000 },
   205  			"current": { "memory": 900 }
   206  		}
   207  	}`
   208  
   209  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupHandler(c, json))
   210  
   211  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"})
   212  	c.Assert(err, check.IsNil)
   213  	c.Check(rest, check.HasLen, 0)
   214  	c.Check(s.Stderr(), check.Equals, "")
   215  	c.Check(s.Stdout(), check.Equals, `
   216  name:    foo
   217  parent:  bar
   218  constraints:
   219    memory:  1000B
   220  current:
   221    memory:  900B
   222  subgroups:
   223    - subgrp1
   224  snaps:
   225    - snap-a
   226    - snap-b
   227  `[1:])
   228  }
   229  
   230  func (s *quotaSuite) TestGetQuotaGroupSimple(c *check.C) {
   231  	restore := main.MockIsStdinTTY(true)
   232  	defer restore()
   233  
   234  	const jsonTemplate = `{
   235  		"type": "sync",
   236  		"status-code": 200,
   237  		"result": {
   238  			"group-name": "foo",
   239  			"constraints": {"memory": 1000},
   240  			"current": {"memory": %d}
   241  		}
   242  	}`
   243  
   244  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 0)))
   245  
   246  	outputTemplate := `
   247  name:  foo
   248  constraints:
   249    memory:  1000B
   250  current:
   251    memory:  %dB
   252  `[1:]
   253  
   254  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"})
   255  	c.Assert(err, check.IsNil)
   256  	c.Check(rest, check.HasLen, 0)
   257  	c.Check(s.Stderr(), check.Equals, "")
   258  	c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 0))
   259  
   260  	s.stdout.Reset()
   261  	s.stderr.Reset()
   262  
   263  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(jsonTemplate, 500)))
   264  
   265  	rest, err = main.Parser(main.Client()).ParseArgs([]string{"quota", "foo"})
   266  	c.Assert(err, check.IsNil)
   267  	c.Check(rest, check.HasLen, 0)
   268  	c.Check(s.Stderr(), check.Equals, "")
   269  	c.Check(s.Stdout(), check.Equals, fmt.Sprintf(outputTemplate, 500))
   270  }
   271  
   272  func (s *quotaSuite) TestSetQuotaGroupCreateNew(c *check.C) {
   273  	const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}`
   274  	fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{
   275  		action:     "ensure",
   276  		body:       postJSON,
   277  		groupName:  "foo",
   278  		parentName: "bar",
   279  		snaps:      []string{"snap-a"},
   280  		maxMemory:  999,
   281  	}
   282  
   283  	routes := map[string]http.HandlerFunc{
   284  		"/v2/quotas": makeFakeQuotaPostHandler(
   285  			c,
   286  			fakeHandlerOpts,
   287  		),
   288  		// the foo quota group is not found since it doesn't exist yet
   289  		"/v2/quotas/foo": makeFakeGetQuotaGroupNotFoundHandler(c, "foo"),
   290  
   291  		"/v2/changes/42": makeChangesHandler(c),
   292  	}
   293  
   294  	s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes))
   295  
   296  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "--memory=999B", "--parent=bar", "snap-a"})
   297  	c.Assert(err, check.IsNil)
   298  	c.Check(rest, check.HasLen, 0)
   299  	c.Check(s.Stderr(), check.Equals, "")
   300  	c.Check(s.Stdout(), check.Equals, "")
   301  }
   302  
   303  func (s *quotaSuite) TestSetQuotaGroupUpdateExistingUnhappy(c *check.C) {
   304  	const exists = true
   305  	s.testSetQuotaGroupUpdateExistingUnhappy(c, "no options set to change quota group", exists)
   306  }
   307  
   308  func (s *quotaSuite) TestSetQuotaGroupCreateNewUnhappy(c *check.C) {
   309  	const exists = false
   310  	s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot create quota group without memory limit", exists)
   311  }
   312  
   313  func (s *quotaSuite) TestSetQuotaGroupCreateNewUnhappyWithParent(c *check.C) {
   314  	const exists = false
   315  	s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot create quota group without memory limit", exists, "--parent=bar")
   316  }
   317  
   318  func (s *quotaSuite) TestSetQuotaGroupUpdateExistingUnhappyWithParent(c *check.C) {
   319  	const exists = true
   320  	s.testSetQuotaGroupUpdateExistingUnhappy(c, "cannot move a quota group to a new parent", exists, "--parent=bar")
   321  }
   322  
   323  func (s *quotaSuite) testSetQuotaGroupUpdateExistingUnhappy(c *check.C, errPattern string, exists bool, args ...string) {
   324  	if exists {
   325  		// existing group has 1000 memory limit
   326  		const getJson = `{
   327  			"type": "sync",
   328  			"status-code": 200,
   329  			"result": {
   330  				"group-name":"foo",
   331  				"current": {
   332  					"memory": 500
   333  				},
   334  				"constraints": {
   335  					"memory": 1000
   336  				}
   337  			}
   338  		}`
   339  
   340  		s.RedirectClientToTestServer(makeFakeGetQuotaGroupHandler(c, getJson))
   341  	} else {
   342  		s.RedirectClientToTestServer(makeFakeGetQuotaGroupNotFoundHandler(c, "foo"))
   343  	}
   344  
   345  	cmdArgs := append([]string{"set-quota", "foo"}, args...)
   346  	_, err := main.Parser(main.Client()).ParseArgs(cmdArgs)
   347  	c.Assert(err, check.ErrorMatches, errPattern)
   348  	c.Check(s.Stdout(), check.Equals, "")
   349  }
   350  
   351  func (s *quotaSuite) TestSetQuotaGroupUpdateExisting(c *check.C) {
   352  	const postJSON = `{"type": "async", "status-code": 202,"change":"42", "result": []}`
   353  	fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{
   354  		action:    "ensure",
   355  		body:      postJSON,
   356  		groupName: "foo",
   357  		maxMemory: 2000,
   358  	}
   359  
   360  	const getJsonTemplate = `{
   361  		"type": "sync",
   362  		"status-code": 200,
   363  		"result": {
   364  			"group-name":"foo",
   365  			"constraints": { "memory": %d },
   366  			"current": { "memory": 500 }
   367  		}
   368  	}`
   369  
   370  	routes := map[string]http.HandlerFunc{
   371  		"/v2/quotas": makeFakeQuotaPostHandler(
   372  			c,
   373  			fakeHandlerOpts,
   374  		),
   375  		"/v2/quotas/foo": makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(getJsonTemplate, 1000)),
   376  		"/v2/changes/42": makeChangesHandler(c),
   377  	}
   378  
   379  	s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes))
   380  
   381  	// increase the memory limit to 2000
   382  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "--memory=2000B"})
   383  	c.Assert(err, check.IsNil)
   384  	c.Check(rest, check.HasLen, 0)
   385  	c.Check(s.Stderr(), check.Equals, "")
   386  	c.Check(s.Stdout(), check.Equals, "")
   387  
   388  	s.stdout.Reset()
   389  	s.stderr.Reset()
   390  
   391  	fakeHandlerOpts2 := fakeQuotaGroupPostHandlerOpts{
   392  		action:    "ensure",
   393  		body:      postJSON,
   394  		groupName: "foo",
   395  		snaps:     []string{"some-snap"},
   396  	}
   397  
   398  	routes = map[string]http.HandlerFunc{
   399  		"/v2/quotas": makeFakeQuotaPostHandler(
   400  			c,
   401  			fakeHandlerOpts2,
   402  		),
   403  		// the group was updated to have a 2000 memory limit now
   404  		"/v2/quotas/foo": makeFakeGetQuotaGroupHandler(c, fmt.Sprintf(getJsonTemplate, 2000)),
   405  
   406  		"/v2/changes/42": makeChangesHandler(c),
   407  	}
   408  
   409  	s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes))
   410  
   411  	// add a snap to the group
   412  	rest, err = main.Parser(main.Client()).ParseArgs([]string{"set-quota", "foo", "some-snap"})
   413  	c.Assert(err, check.IsNil)
   414  	c.Check(rest, check.HasLen, 0)
   415  	c.Check(s.Stderr(), check.Equals, "")
   416  	c.Check(s.Stdout(), check.Equals, "")
   417  }
   418  
   419  func (s *quotaSuite) TestRemoveQuotaGroup(c *check.C) {
   420  	const json = `{"type": "async", "status-code": 202,"change": "42"}`
   421  	fakeHandlerOpts := fakeQuotaGroupPostHandlerOpts{
   422  		action:    "remove",
   423  		body:      json,
   424  		groupName: "foo",
   425  	}
   426  
   427  	routes := map[string]http.HandlerFunc{
   428  		"/v2/quotas": makeFakeQuotaPostHandler(c, fakeHandlerOpts),
   429  
   430  		"/v2/changes/42": makeChangesHandler(c),
   431  	}
   432  
   433  	s.RedirectClientToTestServer(dispatchFakeHandlers(c, routes))
   434  
   435  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"remove-quota", "foo"})
   436  	c.Assert(err, check.IsNil)
   437  	c.Check(rest, check.HasLen, 0)
   438  	c.Check(s.Stderr(), check.Equals, "")
   439  	c.Check(s.Stdout(), check.Equals, "")
   440  }
   441  
   442  func (s *quotaSuite) TestGetAllQuotaGroups(c *check.C) {
   443  	restore := main.MockIsStdinTTY(true)
   444  	defer restore()
   445  
   446  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupsHandler(c,
   447  		`{"type": "sync", "status-code": 200, "result": [
   448  			{"group-name":"aaa","subgroups":["ccc","ddd","fff"],"parent":"zzz","constraints":{"memory":1000}},
   449  			{"group-name":"ddd","parent":"aaa","constraints":{"memory":400}},
   450  			{"group-name":"bbb","parent":"zzz","constraints":{"memory":1000},"current":{"memory":400}},
   451  			{"group-name":"yyyyyyy","constraints":{"memory":1000}},
   452  			{"group-name":"zzz","subgroups":["bbb","aaa"],"constraints":{"memory":5000}},
   453  			{"group-name":"ccc","parent":"aaa","constraints":{"memory":400}},
   454  			{"group-name":"fff","parent":"aaa","constraints":{"memory":1000},"current":{"memory":0}},
   455  			{"group-name":"xxx","constraints":{"memory":9900},"current":{"memory":10000}}
   456  			]}`))
   457  
   458  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"})
   459  	c.Assert(err, check.IsNil)
   460  	c.Check(rest, check.HasLen, 0)
   461  	c.Check(s.Stderr(), check.Equals, "")
   462  	c.Check(s.Stdout(), check.Equals, `
   463  Quota    Parent  Constraints   Current
   464  xxx              memory=9.9kB  memory=10.0kB
   465  yyyyyyy          memory=1000B  
   466  zzz              memory=5000B  
   467  aaa      zzz     memory=1000B  
   468  ccc      aaa     memory=400B   
   469  ddd      aaa     memory=400B   
   470  fff      aaa     memory=1000B  
   471  bbb      zzz     memory=1000B  memory=400B
   472  `[1:])
   473  }
   474  
   475  func (s *quotaSuite) TestGetAllQuotaGroupsInconsistencyError(c *check.C) {
   476  	restore := main.MockIsStdinTTY(true)
   477  	defer restore()
   478  
   479  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupsHandler(c,
   480  		`{"type": "sync", "status-code": 200, "result": [
   481  			{"group-name":"aaa","subgroups":["ccc"],"max-memory":1000}]}`))
   482  
   483  	_, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"})
   484  	c.Assert(err, check.ErrorMatches, `internal error: inconsistent groups received, unknown subgroup "ccc"`)
   485  }
   486  
   487  func (s *quotaSuite) TestNoQuotaGroups(c *check.C) {
   488  	restore := main.MockIsStdinTTY(true)
   489  	defer restore()
   490  
   491  	s.RedirectClientToTestServer(makeFakeGetQuotaGroupsHandler(c,
   492  		`{"type": "sync", "status-code": 200, "result": []}`))
   493  
   494  	rest, err := main.Parser(main.Client()).ParseArgs([]string{"quotas"})
   495  	c.Assert(err, check.IsNil)
   496  	c.Check(rest, check.HasLen, 0)
   497  	c.Check(s.Stderr(), check.Equals, "")
   498  	c.Check(s.Stdout(), check.Equals, "No quota groups defined.\n")
   499  }